From 9520c80dcfc9e057f1f84d343ab3d8cdf55188f3 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 18 Oct 2026 01:08:14 +0200 Subject: [PATCH] Close the smaller state inconsistencies (B-96, BET-02, BET-07, WDR-01, WDR-15, TX-16, TX-18, TX-27, TX-29, DEP-15, DEP-16, AUD-30, AUD-46, DAT-22) - bet/withdrawal routes read listener.client after taking the user lock - a reduced full-balance withdrawal is checked against the minimum again - a bet covered only by pending balance answers balance_pending_confirmation - a recovered bet/withdrawal/payout writes bet_placed/withdrawal_sent/payout_sent - an abandoned payout clears winner/fee/reserve amounts with payout_txid - the reconciler pings SSE only for rows it changed - closing_stalled every 30 minutes past the deadline; closing_waiting_since on /rounds/current - one refresh per user at a time; a failed refresh no longer ends the Electrum session Co-Authored-By: Claude Sonnet 5.5 --- BUGS.md | 18 +- CLAUDE.md | 4 +- README.md | 2 +- app/api/routes/bets.py | 17 +- app/api/routes/rounds.py | 3 + app/api/routes/withdrawals.py | 16 +- app/bets/service.py | 16 +- app/electrum/listener.py | 20 +- app/rounds/scheduler.py | 43 +++++ app/tx/reconcile.py | 42 ++++- app/withdrawals/service.py | 10 + docs/api.md | 16 +- docs/architettura.md | 7 +- docs/audit-log.md | 21 +-- docs/connessione-electrum.md | 6 +- docs/modello-dati.md | 6 +- docs/operativita.md | 4 +- docs/registrazione-e-depositi.md | 19 +- docs/scommesse-e-prelievi.md | 15 +- docs/sicurezza.md | 22 +-- docs/storico-bug.md | 8 +- docs/transazioni-e-riconciliazione.md | 24 +-- tests/unit/test_state_consistency.py | 257 ++++++++++++++++++++++++++ 23 files changed, 445 insertions(+), 151 deletions(-) create mode 100644 tests/unit/test_state_consistency.py diff --git a/BUGS.md b/BUGS.md index a3ad75d..d18fb2b 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,7 +1,7 @@ # BUGS.md — open findings Only the 2026-09-28 specification review (B-73 … B-100) still has open entries -(B-92, B-93, B-96). A finding is removed from this file once it is fixed, and +(B-92, B-93). A finding is removed from this file once it is fixed, and `git log --all --grep 'B-nn'` is the record of how it was closed. Per CLAUDE.md's convention each entry gets its own commit with its own regression test, and the `B-nn` marker goes in a comment next to the fix. @@ -56,19 +56,3 @@ kills after 10s); an older image cannot start on a newer DB; images are untagged Fix: close SSE streams on shutdown, `stop_grace_period: 30s`, await task cancellation; tag images per release; `SITE_ADDRESS` in `.env` with `${SITE_ADDRESS:?}`; documented pre-deploy backup and downgrade procedure. - - -### B-96 — Smaller state inconsistencies - -A reduced full-balance withdrawal is not re-checked against the minimum; an abandoned -payout leaves `winner_amount_sats`/`fee_amount_sats` set; a promoted payout writes no -`payout_sent` and a promoted bet no `bet_placed`; the reconciler pings SSE for every -row it checks; concurrent refreshes of one user can collide on the unique outpoint -and end the Electrum session; `listener.client` is read outside the per-user lock; -a bet covered only by pending change gets `insufficient_balance` instead of -`balance_pending_confirmation`. A round waiting in `closing` on a bet that never -leaves the mempool gives no signal at all. - -Fix: each as described, one regression test per item; for the `closing` wait, a -`closing_stalled` audit row every 1800s past the deadline and `closing_waiting_since` -on `GET /rounds/current` (spec: TX-29). diff --git a/CLAUDE.md b/CLAUDE.md index aa112fd..f3ad4d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin ## Project status -All 10 stages of the original build order are code-complete and unit-tested — 738 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. +All 10 stages of the original build order are code-complete and unit-tested — 749 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), 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. @@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace) PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip -python -m pytest # all 738 tests +python -m pytest # all 749 tests python -m pytest tests/unit/test_hd.py # one file python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test ``` diff --git a/README.md b/README.md index df3caf1..df4c3a3 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ python -m pytest # all tests python -m pytest tests/unit/test_hd.py # one file ``` -738 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +749 unit tests cover HD derivation, PSBT building, the Electrum client, bets, deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config, the pending-inclusive balance calculation, and the SSE push channel. No automated integration tests against a live Electrum connection — mainnet diff --git a/app/api/routes/bets.py b/app/api/routes/bets.py index 52a7d1f..b51d84c 100644 --- a/app/api/routes/bets.py +++ b/app/api/routes/bets.py @@ -26,16 +26,19 @@ async def create_bet( session: AsyncSession = Depends(get_session), ) -> BetResponse: listener = request.app.state.electrum_listener - if listener.client is None: - raise http_error( - status.HTTP_503_SERVICE_UNAVAILABLE, - "network_unavailable", - "not connected to the network, try again shortly", - ) async with request.app.state.user_locks.acquire(user.id): + # B-96 (BET-02): read after the lock is taken — a wait on it across a reconnection + # must not hand place_bet the connection that closed while this request queued. + client = listener.client + if client is None: + raise http_error( + status.HTTP_503_SERVICE_UNAVAILABLE, + "network_unavailable", + "not connected to the network, try again shortly", + ) try: - participant = await place_bet(session, listener.client, user, network=listener) + participant = await place_bet(session, client, user, network=listener) except BetError as exc: # A rejected broadcast isn't the client's fault — it's the network # refusing our transaction, so it answers 502 rather than 400 (B-07). diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index 598bd6d..72a978f 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -119,6 +119,8 @@ class CurrentRoundResponse(BaseModel): # waiting for a block" rather than a countdown implying a bounded wait — this # phase has no timeout, only draw_animation_seconds' cosmetic minimum. draw_waiting_since: str | None = None + # B-96 (TX-29): since when the round has waited in "closing" for in-flight bets; only then. + closing_waiting_since: str | None = None # B-73 (RND-34, RND-14): the commitment published from the opening, and what is fixed # on entering "drawing". The secret itself is never in this response (API-45 has it, once # revealed). @@ -233,6 +235,7 @@ async def current_round( draw_block_height=round_.draw_block_height, draw_block_hash=round_.draw_block_hash, draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None, + closing_waiting_since=isoformat_utc(round_.closed_at) if round_.status == "closing" and round_.closed_at else None, server_seed_hash=round_.server_seed_hash, participants_hash=round_.participants_hash, draw_height=round_.draw_height, diff --git a/app/api/routes/withdrawals.py b/app/api/routes/withdrawals.py index f5c6494..9615e73 100644 --- a/app/api/routes/withdrawals.py +++ b/app/api/routes/withdrawals.py @@ -32,17 +32,19 @@ async def create_withdrawal( session: AsyncSession = Depends(get_session), ) -> WithdrawalResponse: listener = request.app.state.electrum_listener - if listener.client is None: - raise http_error( - status.HTTP_503_SERVICE_UNAVAILABLE, - "network_unavailable", - "not connected to the network, try again shortly", - ) async with request.app.state.user_locks.acquire(user.id): + # B-96 (WDR-01): read after the lock is taken, as in create_bet. + client = listener.client + if client is None: + raise http_error( + status.HTTP_503_SERVICE_UNAVAILABLE, + "network_unavailable", + "not connected to the network, try again shortly", + ) try: withdrawal = await request_withdrawal( - session, listener.client, user, body.external_address, body.amount_sats, network=listener + session, client, user, body.external_address, body.amount_sats, network=listener ) except WithdrawalError as exc: code = ( diff --git a/app/bets/service.py b/app/bets/service.py index 7343781..2e7e6ff 100644 --- a/app/bets/service.py +++ b/app/bets/service.py @@ -16,7 +16,7 @@ from app.rounds.events import broadcaster from app.rounds.service import open_new_round_if_needed, round_accepts_bets from app.tx.versions import delete_versions, record_version from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict -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 ( MAX_PARTICIPANTS_PER_ROUND, @@ -125,7 +125,19 @@ async def place_bet( select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None)) ) ).all() - if sum(u.amount_sats for u in unspent) < bet_amount: + confirmed_sats = sum(u.amount_sats for u in unspent) + if confirmed_sats < bet_amount: + # B-96 (BET-07, DEP-13): the balance on screen includes unconfirmed change and deposits + # short of their threshold; a bet that balance would cover gets "wait for the + # confirmation", as a withdrawal does, instead of a flat "insufficient". + waiting_sats = network.pending_deposit_sats(user.id) if hasattr(network, "pending_deposit_sats") else 0 + pending_inclusive_sats, has_pending = await compute_pending_balance(session, user, waiting_sats) + if has_pending and pending_inclusive_sats >= bet_amount: + raise BetError( + "balance_pending_confirmation", + "the bet cost is covered by your pending balance, which has not confirmed yet", + pending_sats=pending_inclusive_sats - confirmed_sats, + ) raise BetError("insufficient_balance", "insufficient balance", required_sats=bet_amount) user_key = derive_user_key(user.derivation_index) diff --git a/app/electrum/listener.py b/app/electrum/listener.py index 3770a7b..db7b435 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -119,6 +119,7 @@ class ElectrumListener: self._allow_no_quorum = allow_no_quorum self._endpoint_index = 0 self._restore_checked = False + self._refresh_locks: dict[int, asyncio.Lock] = {} self._scripthash_to_user: dict[str, int] = {} # Retains address_for_new_user's fire-and-forget subscribe task so it # can't be garbage-collected mid-flight, and so its exception (if any) is @@ -1143,9 +1144,26 @@ class ElectrumListener: scripthash, _status = await queue.get() user_id = self._scripthash_to_user.get(scripthash) if user_id is not None: - await self.refresh_user(user_id, scripthash) + try: + await self.refresh_user(user_id, scripthash) + except asyncio.CancelledError: + raise + except Exception: + # B-96 (DEP-16): one user's failed refresh (a coin already recorded, a + # corroboration error) ends neither the session nor the other users' + # notifications; a dropped connection ends it through wait_closed(), and + # this user is picked up again by the next refresh or the periodic sweep. + logger.warning("refresh of user %s failed after a notification", user_id, exc_info=True) async def refresh_user(self, user_id: int, scripthash: str) -> None: + """One refresh of a user at a time (B-96, DEP-15): a notification, the re-subscription + and the periodic sweep can ask together, and two of them reading the same new coin would + both try to credit it. The lock is the refresh's own, not the bet/withdrawal one, so a + corroboration in progress never delays a bet. Other users refresh in parallel.""" + async with self._refresh_locks.setdefault(user_id, asyncio.Lock()): + await self._refresh_user_locked(user_id, scripthash) + + async def _refresh_user_locked(self, user_id: int, scripthash: str) -> None: """Three phases, so no DB session is held across a network call (B-18), same shape as _trigger_payout: read what's needed, corroborate every balance-moving candidate against other servers — new credits (B-59) as diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index ff17821..ae3ded5 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -47,6 +47,9 @@ _PAYOUT_RETRY_INTERVAL_SECONDS = 60 # corroboration failure. These only make an already-long wait *observable*, they # never cut it short. _DRAW_PROGRESS_LOG_INTERVAL_SECONDS = 60 +# B-96 (TX-29, AUD-46): how long past its deadline a round may sit in `closing` on an in-flight bet before +# `closing_stalled` is written, and how often it repeats. The wait itself has no limit (ADR-51). +CLOSING_STALL_THRESHOLD_SECONDS = 1800 _DRAW_STALL_THRESHOLD_SECONDS = 360 # a few multiples of PLM's 120s block time @@ -143,10 +146,50 @@ class RoundScheduler: ) ) if pending_count: + await self._note_closing_stall(round_id, deadline) return # wait for in-flight bets to confirm before closing; stays "closing" await self._close_and_draw(round_id) + async def _note_closing_stall(self, round_id: int, deadline: datetime) -> None: + """B-96 (TX-29, AUD-46): the wait in `closing` is unbounded by design, so it is made + visible — one `closing_stalled` row per CLOSING_STALL_THRESHOLD_SECONDS once the round has + been past its deadline for that long. The latest row is the clock of the next one (read + from the log, not from memory, so a restart neither repeats nor anticipates it).""" + now = datetime.now(timezone.utc) + seconds_past = (now - deadline).total_seconds() + if seconds_past < CLOSING_STALL_THRESHOLD_SECONDS: + return + async with self._session_factory() as session: + last = await session.scalar( + select(AuditLog.created_at) + .where(AuditLog.event_type == "closing_stalled", AuditLog.round_id == round_id) + .order_by(AuditLog.id.desc()) + .limit(1) + ) + if last is not None and now < last.replace(tzinfo=timezone.utc) + timedelta( + seconds=CLOSING_STALL_THRESHOLD_SECONDS + ): + return + txids = list( + await session.scalars( + select(RoundParticipant.bet_txid) + .where( + RoundParticipant.round_id == round_id, + RoundParticipant.status.in_(("building", "broadcast")), + ) + .order_by(RoundParticipant.id) + ) + ) + await write_audit_log( + session, + "closing_stalled", + {"in_flight": len(txids), "txids": txids, "seconds_past_deadline": int(seconds_past)}, + round_id=round_id, + ) + await session.commit() + logger.warning("round %s has waited %ss past its deadline for %s in-flight bet(s)", round_id, int(seconds_past), len(txids)) + async def _close_and_draw(self, round_id: int) -> None: """Close a `closing` round once nothing is in flight: either close it (no confirmed participant) or commit to the draw — fix the participant list, its fingerprint and the diff --git a/app/tx/reconcile.py b/app/tx/reconcile.py index 4d645b5..62775b5 100644 --- a/app/tx/reconcile.py +++ b/app/tx/reconcile.py @@ -214,8 +214,9 @@ async def reconcile_once( await _abandon(session, row, payload) changed = True await session.commit() - resolved += changed - broadcaster.publish() + if changed: # B-96 (TX-27): a row that was only examined moved nothing to refetch + resolved += 1 + broadcaster.publish() return resolved @@ -360,6 +361,9 @@ async def _promote(session: AsyncSession, row: PendingTransaction) -> None: """The tx did make it onto the chain before we died — finish what phase 2 of place_bet/request_withdrawal would have done.""" row.status = "pending" + # B-96 (TX-16, AUD-30): besides pending_tx_recovered, the event the successful send would have + # written — a recovered bet, withdrawal or payout is otherwise absent from the audit trail. + sent_event: tuple[str, dict, int | None] | None = None if row.kind == "bet": participant = await session.scalar( select(RoundParticipant).where( @@ -368,10 +372,39 @@ async def _promote(session: AsyncSession, row: PendingTransaction) -> None: ) if participant is not None and participant.status == "building": participant.status = "broadcast" + sent_event = ( + "bet_placed", + {"txid": row.current_txid, "amount_sats": participant.bet_amount_sats}, + row.user_id, + ) elif row.kind == "withdrawal" and row.withdrawal_id is not None: withdrawal = await session.get(Withdrawal, row.withdrawal_id) if withdrawal is not None and withdrawal.status == "building": withdrawal.status = "broadcast" + sent_event = ( + "withdrawal_sent", + { + "txid": row.current_txid, + "amount_sent_sats": withdrawal.amount_sent_sats, + "external_address": withdrawal.external_address, + }, + row.user_id, + ) + elif row.kind == "payout" and row.round_id is not None: + round_ = await session.get(Round, row.round_id) + if round_ is not None: + sent_event = ( + "payout_sent", + { + "txid": row.current_txid, + "winner_sats": round_.winner_amount_sats, + "commission_sats": round_.fee_amount_sats, + }, + round_.winner_user_id, + ) + if sent_event is not None: + event, payload, event_user_id = sent_event + await write_audit_log(session, event, payload, user_id=event_user_id, round_id=row.round_id) await write_audit_log( session, "pending_tx_recovered", @@ -418,6 +451,11 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str) round_ = await session.get(Round, row.round_id) if row.round_id else None if round_ is not None and round_.status == "paying_out": round_.payout_txid = None + # B-96 (TX-18, DAT-22): the amounts belong to the attempt that no longer exists; the + # next attempt writes its own. + round_.winner_amount_sats = None + round_.fee_amount_sats = None + round_.bump_reserve_sats = None logger.error( "round %s payout tx %s vanished — round needs operator attention", round_.id, row.current_txid ) diff --git a/app/withdrawals/service.py b/app/withdrawals/service.py index 3bfa98a..8e3e9b4 100644 --- a/app/withdrawals/service.py +++ b/app/withdrawals/service.py @@ -144,6 +144,16 @@ async def request_withdrawal( except InsufficientFundsError as exc: raise WithdrawalError(exc.code, str(exc), **exc.params) from exc + # B-96 (WDR-15): the reduction above can take a full-balance withdrawal a dust limit under + # the minimum that was checked on the requested amount. + moved_sats = built.recipient_sats + built.fee_sats + if moved_sats < config.bet_amount_sats: + raise WithdrawalError( + "amount_below_minimum", + f"amount below the minimum of {config.bet_amount_sats} sats", + minimum_sats=config.bet_amount_sats, + ) + # Persist the intent before broadcasting, and only promote the rows once the # network has accepted the tx — same two-phase shape as place_bet (B-08). spent_by_key = {(u.txid, u.vout): u for u in unspent} diff --git a/docs/api.md b/docs/api.md index 941fb81..6b0f47f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -117,8 +117,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. modo: torna alla schermata di login. *Codice:* `app/auth/dependencies.py:get_current_user` · *Test:* `tests/unit/test_users.py`, `tests/unit/test_bug_reports.py` - dell'header è intercettata da FastAPI prima del codice applicativo e - risponde con il corpo non strutturato `{"detail": "Not authenticated"}`. - **[API-07]** Autenticazione **facoltativa**: senza header, con un header che non inizia con `Bearer `, con un token invalido o superato, la richiesta è @@ -136,7 +134,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. (vedi [sicurezza.md](sicurezza.md)). *Codice:* `app/api/routes/admin.py:require_admin` · *Test:* `tests/unit/test_admin.py` - strutturato `{"detail": "invalid admin token"}`. - **[API-09]** Intestazioni di cache: - le quattro pagine (`/`, `/admin`, `/guida`, `/report-bug`) rispondono con @@ -265,8 +262,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. `app/api/routes/admin.py:update_bug_report_status`, `app/api/routes/rounds.py:round_stream`, `app/main.py` · *Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py` - hanno `detail` come stringa inglese (colonna "Oggi"), e il frontend la - mostra così com'è. - **[API-17]** Il client traduce `code` con la chiave `error.` di `app/static/i18n.js`, passando `params` all'interpolazione e aggiungendo per @@ -393,8 +388,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. *Codice:* `app/api/routes/users.py:change_password`, `app/auth/rate_limit.py:RateLimiter` · *Test:* `tests/unit/test_users.py` - limite di frequenza (la password attuale si può provare senza limiti) e - nessuna voce di audit. #### GET /users/me/last-round-result @@ -460,8 +453,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. *Codice:* `app/api/routes/bets.py:create_bet`, `app/bets/service.py:place_bet` · *Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py` - *Stato:* da implementare (B-96) · *Oggi:* un costo coperto solo da resto non - confermato risponde `insufficient_balance`. #### POST /withdrawals @@ -507,8 +498,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. *Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/withdrawals/service.py:request_withdrawal` · *Test:* `tests/unit/test_withdrawals.py` - *Stato:* da implementare (B-96) · *Oggi:* l'importo ridotto di un prelievo - dell'intero saldo non è riconfrontato con il minimo. ### Round @@ -539,6 +528,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. | `draw_block_height` | intero o `null` | altezza del blocco usato per l'estrazione | | `draw_block_hash` | stringa o `null` | hash di quel blocco; è **uno** degli ingressi del seme, non il seme (vedi [API-45](#get-roundsround_idproof)) | | `draw_waiting_since` | stringa ISO o `null` | inizio dell'attesa del blocco; valorizzato **solo** mentre `status` è `drawing` | + | `closing_waiting_since` | stringa ISO o `null` | da quando il round attende in `closing` le scommesse in volo ([TX-29](transazioni-e-riconciliazione.md#riconciliazione)); valorizzato **solo** mentre `status` è `closing` | | `server_seed_hash` | stringa o `null` | impegno del server sul segreto del round, pubblicato dall'apertura; `null` per un round aperto prima di B-73 | | `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` | | `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` | @@ -713,7 +703,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. `app/api/routes/bug_reports.py:BugReportCreate`, `app/auth/rate_limit.py:RollingQuota` · *Test:* `tests/unit/test_bug_reports.py` - anonimo può inserire segnalazioni senza limite. #### GET /bug-reports/mine @@ -833,7 +822,6 @@ dei parametri operativi in "balance_sats", "created_at"}`; `balance_sats` è il saldo confermato. *Codice:* `app/api/routes/admin.py:list_users` · *Test:* `tests/unit/test_admin.py` - in una sola risposta, senza paginazione né limite. #### GET /admin/users/{user_id}/privkey @@ -1031,7 +1019,7 @@ dei parametri operativi in | `already_betting` | 400 | `app/bets/service.py:place_bet` | — | l'utente ha già una riga di partecipazione nel round | sì | | `round_full` | 400 | `app/bets/service.py:place_bet` | `max_participants` | il round ha già 400 partecipanti (in qualunque stato) | sì | | `insufficient_balance` | 400 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/wallet/psbt_builder.py:select_utxos` | `required_sats` (assente se sollevato da `select_utxos`) | le monete confermate e non spese non coprono l'importo | sì | -| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal`; anche `app/bets/service.py:place_bet` *da implementare (B-96)* | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì | +| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal`; anche `app/bets/service.py:place_bet` | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì | | `too_many_inputs` | 400 | `app/wallet/psbt_builder.py:select_utxos` | `max_inputs` | servirebbero più di 50 monete in ingresso | sì | | `balance_leaves_no_change` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` (solo scommesse) | `required_extra_sats` | dopo la scommessa il resto sarebbe sotto il limite di polvere (294 sat) | sì | | `amount_below_network_fee` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` | — | l'importo non copre la commissione di rete | sì | diff --git a/docs/architettura.md b/docs/architettura.md index c315358..7a80b11 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -681,9 +681,8 @@ flowchart LR `app/withdrawals/service.py:_release_failed_withdrawal`; - transazione confermata — `app/tx/confirmation.py:poll_once`; - riga promossa o abbandonata dal riconciliatore — - `app/tx/reconcile.py:reconcile_once`. Oggi il riconciliatore pubblica per - **ogni** riga che ricontrolla, anche quando non cambia nulla; la - pubblicazione solo sui cambiamenti è in B-96; + `app/tx/reconcile.py:reconcile_once`. Il riconciliatore pubblica solo + per le righe che ha cambiato ([TX-27](transazioni-e-riconciliazione.md#riconciliazione)); - deposito accreditato, UTXO segnata come spesa fuori piattaforma, UTXO ripristinata — `app/deposits/service.py:credit_confirmed_utxos`, `app/deposits/service.py:mark_utxos_spent_externally`, @@ -691,8 +690,6 @@ flowchart LR - nuovo header ricevuto — `app/electrum/listener.py:ElectrumListener._consume_headers`. *Test:* `tests/unit/test_bets.py` (verifica la notifica di una scommessa) - *Stato:* da implementare (B-96) · *Oggi:* il riconciliatore pubblica una - notifica per ogni riga ricontrollata, anche senza cambiamenti. ## Lock per utente diff --git a/docs/audit-log.md b/docs/audit-log.md index 55e3fb2..4a2cccc 100644 --- a/docs/audit-log.md +++ b/docs/audit-log.md @@ -44,9 +44,6 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in `app/tx/broadcast.py:RbfBumper._tick`, `app/main.py:lifespan`, `app/rounds/scheduler.py:RoundScheduler._tick`, `app/electrum/listener.py:ElectrumListener._record_reorg` - *Stato:* da implementare (B-96), per uno degli eventi - nuovi · *Oggi:* gli eventi osservativi sono i primi quattro, - `chain_reorg_detected` e `rbf_bump_failed`. - **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna `payload_json`. `created_at` è impostato dall'applicazione, non dal @@ -114,10 +111,7 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in `app/tx/broadcast.py:RbfBumper._tick`, `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · - *Test:* `tests/unit/test_scheduler.py` (solo `payout_failed`) - *Stato:* da implementare (B-96), per l'ultima lettura · - *Oggi:* le letture sono quelle di `payout_failed`, di - `draw_baseline_tip_unknown` e di `rbf_bump_failed`. + *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_state_consistency.py` ## Catalogo degli eventi @@ -275,7 +269,6 @@ flowchart LR sbagliata, nuova troppo corta) non scrive nulla. Nessuna logica rilegge la riga. *Codice:* `app/api/routes/users.py:change_password` · *Test:* — - alcun evento. - **[AUD-43]** `restore_gap_detected` — il controllo all'avvio ha trovato cronologia sulla catena per almeno uno dei 20 indici di derivazione @@ -386,9 +379,7 @@ flowchart LR correnti, secondi trascorsi dalla scadenza. `user_id` no, `round_id` sì. Transazione propria ([AUD-03]). La riga più recente del round è l'orologio della ripetizione successiva, 1800 s dopo ([AUD-09]). - *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · *Test:* — - *Stato:* da implementare (B-96) · *Oggi:* l'attesa in `closing` non scrive - alcun evento. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · *Test:* `tests/unit/test_state_consistency.py` - **[AUD-37]** `draw_committed` — il round è entrato in `drawing`: elenco dei partecipanti e altezza del blocco di estrazione sono fissati @@ -536,9 +527,7 @@ flowchart LR ([AUD-21]) o `payout_sent` ([AUD-29]) — con lo stesso payload ([TX-16](transazioni-e-riconciliazione.md#riconciliazione)). *Codice:* `app/tx/reconcile.py:_promote` · - *Test:* nessun test verifica la riga di audit. - *Stato:* da implementare (B-96), per il secondo evento · *Oggi:* si scrive - solo `pending_tx_recovered`. + *Test:* `tests/unit/test_state_consistency.py` - **[AUD-31]** `pending_tx_abandoned` — una transazione `building` o `pending` è stata dichiarata persa **con una prova** @@ -626,9 +615,7 @@ flowchart LR `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/confirmation.py:_on_payout_confirmed`, `app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee` - *Stato:* da implementare (B-92), per user_registered - dall'elenco · *Oggi:* la registrazione e il cambio password riuscito non - producono alcuna riga. + *Stato:* da implementare (B-92), per `user_registered` ## Log applicativo diff --git a/docs/connessione-electrum.md b/docs/connessione-electrum.md index dff4867..bf55c88 100644 --- a/docs/connessione-electrum.md +++ b/docs/connessione-electrum.md @@ -108,11 +108,7 @@ Le difese sono cinque. indirizzo non termina la sessione ([DEP-16](registrazione-e-depositi.md#deposito)). *Codice:* `app/electrum/listener.py:ElectrumListener._run_once`, `app/electrum/client.py:ElectrumClient.wait_closed`, `app/electrum/listener.py:ElectrumListener._consume_scripthash` · - *Test:* `tests/unit/test_electrum_client.py` - *Stato:* da implementare (B-96), per gli errori di aggiornamento di un - utente · *Oggi:* qualunque eccezione in `refresh_user` chiamato da - `_consume_scripthash` (ad esempio la collisione sull'outpoint di due - aggiornamenti concorrenti) termina la sessione. + *Test:* `tests/unit/test_electrum_client.py`, `tests/unit/test_state_consistency.py` - **[ELC-07]** Una connessione viene resa disponibile al resto del sistema **solo dopo** che il primo header è stato applicato: se il client c'è, l'altezza della catena è nota. Se il primo header è inutilizzabile (privo diff --git a/docs/modello-dati.md b/docs/modello-dati.md index ad6d62f..eeecd94 100644 --- a/docs/modello-dati.md +++ b/docs/modello-dati.md @@ -535,11 +535,7 @@ restano nella tabella come storico. *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/broadcast.py:_retarget_txid_references` · `app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`, - `tests/unit/test_reconcile.py` - *Stato:* da implementare (B-96) · *Oggi:* l'abbandono azzera solo - `payout_txid` e lascia `winner_amount_sats`, `fee_amount_sats` e - `bump_reserve_sats` del tentativo abbandonato (le riscrive il tentativo - successivo). + `tests/unit/test_reconcile.py`, `tests/unit/test_state_consistency.py` - **[DAT-60]** Ogni round ha il proprio indirizzo del montepremi, `pool_address`, derivato all'apertura da `m/84'/746'/0'/2/` e scritto diff --git a/docs/operativita.md b/docs/operativita.md index 3fa0528..bdf3c0a 100644 --- a/docs/operativita.md +++ b/docs/operativita.md @@ -492,7 +492,9 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). - **[OPS-14]** **Sintomi.** `GET /rounds/current` riporta `status: "closing"` a lungo, con - `has_pending_bets` vero; nel log compare + `has_pending_bets` vero e `closing_waiting_since` valorizzato; dopo 30 minuti oltre la + scadenza il registro di audit riceve `closing_stalled` (con i txid ancora in volo), ripetuto + ogni 30 minuti ([TX-29](transazioni-e-riconciliazione.md#riconciliazione)); nel log compare `round N: N bet(s) still in flight at close time, waiting` o nulla (il controllo principale non scrive). In "Transazioni pendenti" c'è almeno una riga `bet` di quel round in `building` o `pending`. diff --git a/docs/registrazione-e-depositi.md b/docs/registrazione-e-depositi.md index 3083db1..7da9378 100644 --- a/docs/registrazione-e-depositi.md +++ b/docs/registrazione-e-depositi.md @@ -123,7 +123,6 @@ il freno sui tentativi di login falliti è prima di qualunque verifica. *Codice:* `app/auth/routes.py:login`, `app/auth/routes.py:LoginRequest` · *Test:* `tests/unit/test_auth.py` - del login non hanno limiti di lunghezza. - ~~[REG-10]~~ Assorbito da [SEC-30](sicurezza.md#limiti-di-frequenza): il freno sui login falliti (chiavi, soglie, azzeramento) non ha nulla di specifico del dominio oltre a ciò che SEC-30 già dice. @@ -144,9 +143,7 @@ il freno sui tentativi di login falliti è login. Questo requisito è il proprietario del flusso; [sicurezza.md](sicurezza.md) e [api.md](api.md) vi rimandano. *Codice:* `app/api/routes/users.py:change_password` · - *Test:* `tests/unit/test_users.py` - `password_changed` · *Oggi:* nessun limite di lunghezza, nessun freno sui - tentativi, nessun evento di audit. + *Test:* `tests/unit/test_users.py`, `tests/unit/test_abuse_limits.py` - **[REG-13]** Non esiste recupero password: non viene raccolta alcuna email. L'unica strada è il reset dall'operatore (`POST /admin/users/{id}/reset-password`), che imposta una password casuale @@ -208,8 +205,7 @@ flowchart TB H -- "Sì, e il quorum
la conferma non spesa" --> I["Reintegrata
(utxo_external_spend_reinstated)"] ``` -*Comportamento atteso.* Gli aggiornamenti dello stesso utente non sono -ancora in fila ([DEP-15], B-96). +Gli aggiornamenti dello stesso utente sono messi in fila ([DEP-15]). - **[DEP-01]** Non c'è un importo minimo di deposito oltre alla soglia di polvere: una moneta di valore inferiore a `DUST_LIMIT_SATS` (294 sat) non @@ -328,9 +324,7 @@ ancora in fila ([DEP-15], B-96). prelievi ([scommesse-e-prelievi.md](scommesse-e-prelievi.md)), così una corroborazione in corso non ritarda una scommessa. Utenti diversi si aggiornano in parallelo come oggi. - *Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` · *Test:* — - *Stato:* da implementare (B-96) · *Oggi:* due aggiornamenti dello stesso - utente possono girare insieme e tentare di accreditare la stessa moneta. + *Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` · *Test:* `tests/unit/test_state_consistency.py` - **[DEP-16]** Un errore durante l'aggiornamento di un utente non chiude la connessione Electrum: viene registrato nei log e l'utente viene ripreso all'aggiornamento successivo. In particolare una violazione dell'unicità @@ -338,9 +332,7 @@ ancora in fila ([DEP-15], B-96). si annulla la transazione del database di quell'accredito e si prosegue. Fa eccezione solo la caduta della connessione stessa, che termina la sessione come in [ELC-06](connessione-electrum.md#3-caduta-rilevata). - *Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/deposits/service.py:credit_confirmed_utxos` · *Test:* — - *Stato:* da implementare (B-96) · *Oggi:* l'eccezione risale da - `refresh_user` a `_consume_scripthash` e termina la sessione Electrum. + *Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/deposits/service.py:credit_confirmed_utxos` · *Test:* `tests/unit/test_state_consistency.py` ## Il saldo mostrato @@ -403,6 +395,3 @@ corso (`has_pending`). la scommessa [scommesse-e-prelievi.md](scommesse-e-prelievi.md), B-96). *Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/bets/service.py:place_bet` · *Test:* `tests/unit/test_withdrawals.py` - *Stato:* da implementare (B-96), per la scommessa · *Oggi:* solo il - prelievo distingue i due casi; una scommessa coperta solo dal saldo - mostrato riceve `insufficient_balance`. diff --git a/docs/scommesse-e-prelievi.md b/docs/scommesse-e-prelievi.md index 1c747d3..b8ed809 100644 --- a/docs/scommesse-e-prelievi.md +++ b/docs/scommesse-e-prelievi.md @@ -38,9 +38,7 @@ Prima dei controlli valgono due condizioni di contorno: un'attesa sul blocco durante una riconnessione non fa usare una connessione già chiusa; il controllo di [BET-01] si fa su quel valore. *Codice:* `app/api/routes/bets.py:create_bet`, `app/tx/locks.py:UserLocks.acquire` · - *Test:* — - *Stato:* da implementare (B-96) · *Oggi:* `listener.client` viene letto e - controllato prima di acquisire il blocco. + *Test:* `tests/unit/test_state_consistency.py` Una scommessa (`POST /bets`, senza corpo) passa solo se **tutte** queste condizioni sono vere, verificate nell'ordine della tabella; altrimenti viene @@ -54,7 +52,7 @@ cifra. Il **costo** è quello del round ([BET-21]): | **[BET-05]** | L'utente non ha già una scommessa in questo round (in qualunque stato) | `already_betting` | | **[BET-06]** | Il round ha meno di 400 partecipanti (`MAX_PARTICIPANTS_PER_ROUND`), contando anche quelli non ancora confermati | `round_full` (parametro `max_participants`) | | **[BET-20]** | Se il round ha un tetto al montepremi (`max_round_pool_sats` fotografato all'apertura, [RND-05](ciclo-di-vita-del-round.md#0--apertura)) diverso da 0, la somma degli importi netti dei partecipanti del round, contando anche quelli non ancora confermati, è minore del tetto. | `round_full` | -| **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`), come per il prelievo ([WDR-05](#prelievo)). *Stato:* da implementare (B-96) · *Oggi:* sempre `insufficient_balance`, anche quando il saldo mostrato all'utente copre la scommessa | +| **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`), come per il prelievo ([WDR-05](#prelievo)). | | **[BET-08]** | Il costo si copre con al massimo 50 monete (`MAX_TX_INPUTS`), scelte dalla più grande fino a coprirlo | `too_many_inputs` (parametro `max_inputs`) | | **[BET-09]** | Le monete scelte ([BET-08]) superano il costo di almeno 294 sat (`DUST_LIMIT_SATS`), che restano come resto | `balance_leaves_no_change` (parametro `required_extra_sats`) | | **[BET-10]** | Il costo meno la commissione di rete resta positivo e di almeno 294 sat | `amount_below_network_fee`, `amount_below_dust_limit` | @@ -241,9 +239,7 @@ esterno scelto dall'utente. avviene tenendo il **blocco per-utente**; il client della rete viene letto e controllato dopo aver acquisito il blocco, come in [BET-02]. *Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/tx/locks.py:UserLocks.acquire` · - *Test:* — (nessun test verifica `network_unavailable`) - *Stato:* da implementare (B-96) · *Oggi:* `listener.client` viene letto e - controllato prima di acquisire il blocco. + *Test:* `tests/unit/test_state_consistency.py` Le condizioni sono verificate nell'ordine della tabella; un rifiuto risponde HTTP 400 prima che si muova qualunque cifra: @@ -290,10 +286,7 @@ solo se la rete rifiuta esplicitamente, RBF se non conferma in tempo. prelievo è rifiutato con `amount_below_minimum` (parametro `minimum_sats`) prima che si muova qualunque cifra. *Codice:* `app/withdrawals/service.py:request_withdrawal` · - *Test:* — - *Stato:* da implementare (B-96) · *Oggi:* il minimo è verificato solo - sull'importo richiesto, quindi un prelievo dell'intero saldo può inviare - fino a 294 sat meno del minimo. + *Test:* `tests/unit/test_state_consistency.py` - **[WDR-10]** Prima dell'invio, in un'unica transazione del database ([TX-28](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia)), le monete spese vengono segnate come tali, il saldo ricalcolato, e si diff --git a/docs/sicurezza.md b/docs/sicurezza.md index f0a3380..61d701b 100644 --- a/docs/sicurezza.md +++ b/docs/sicurezza.md @@ -323,9 +323,7 @@ che ne è il proprietario; qui ci sono le regole di sicurezza. [REG-12](registrazione-e-depositi.md#sessione-e-password). *Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`, `app/auth/routes.py:LoginRequest`, `app/api/routes/users.py:ChangePasswordRequest` · - *Test:* `tests/unit/test_users.py` - *Oggi:* il massimo di 256 vale solo per la password della registrazione; - i campi del login e del cambio password non hanno limite di lunghezza. + *Test:* `tests/unit/test_users.py`, `tests/unit/test_abuse_limits.py` - ~~[SEC-18]~~ Assorbito da [REG-01](registrazione-e-depositi.md#registrazione) e [REG-02](registrazione-e-depositi.md#registrazione): formato dello @@ -373,8 +371,6 @@ che ne è il proprietario; qui ci sono le regole di sicurezza. *Codice:* `app/auth/dependencies.py:get_current_user`, `app/api/routes/users.py:change_password`, `app/api/routes/admin.py:reset_user_password` · *Test:* `tests/unit/test_users.py`, `tests/unit/test_security.py`, `tests/unit/test_admin.py` - l'header assente risponde 401 con il corpo non strutturato di FastAPI - `{"detail": "Not authenticated"}`. - **[SEC-23]** Gli endpoint raggiungibili anche da non autenticati (ad esempio `GET /rounds/current`, `POST /bug-reports`) usano l'utente solo per @@ -396,7 +392,6 @@ endpoint `/admin/*`, che richiedono l'header `X-Admin-Token`. `invalid_admin_token` ([API-08](api.md#convenzioni-generali)). *Codice:* `app/api/routes/admin.py:require_admin` · *Test:* `tests/unit/test_admin.py` - non strutturato `{"detail": "invalid admin token"}`. - **[SEC-25]** Il token è **unico e condiviso**: non esiste un'identità per singolo operatore, e l'audit log registra *cosa* è cambiato ma non *chi* @@ -491,7 +486,6 @@ ogni riavvio e non funzionerebbero con più worker. *Codice:* `app/api/routes/users.py:change_password`, `app/auth/rate_limit.py:RateLimiter`, `app/auth/rate_limit.py:AuthRateLimiters` · *Test:* `tests/unit/test_abuse_limits.py` - la password attuale si può provare senza limiti. - **[SEC-55]** L'invio di segnalazioni di bug è una **quota** per IP ([SEC-35]): al massimo 10 segnalazioni per IP ogni 3600s (finestra mobile, @@ -503,7 +497,6 @@ ogni riavvio e non funzionerebbero con più worker. *Codice:* `app/api/routes/bug_reports.py:create_bug_report`, `app/auth/rate_limit.py:RollingQuota` · *Test:* `tests/unit/test_abuse_limits.py` - inserire segnalazioni senza limite, fino a riempire il database. - **[SEC-32]** La memoria dei limitatori è limitata: le voci esaurite vengono rimosse (al più ogni 60s, e alla lettura), e comunque non si superano 50.000 @@ -518,9 +511,7 @@ ogni riavvio e non funzionerebbero con più worker. [API-16](api.md#contratto-degli-errori); il browser ripiega sul polling). *Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe`, `app/api/routes/rounds.py:round_stream` · - *Test:* `tests/unit/test_round_events.py` - *Oggi:* il 503 ha il corpo non strutturato - `{"detail": "too many concurrent update streams"}`. + *Test:* `tests/unit/test_round_events.py`, `tests/unit/test_abuse_limits.py` - **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi, endpoint admin (tentativi sul token compresi), QR, `GET /rounds/current` e @@ -678,10 +669,8 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in validazione e framework compresi — ha il formato strutturato con un codice ([API-16](api.md#contratto-degli-errori)) e non ripete il valore ricevuto, così una password troppo lunga non torna indietro in chiaro. - *Codice:* `app/main.py:log_unhandled_exception`, `app/main.py` - *Oggi:* 401 senza header, 403 admin, 404/405 del framework e 503 dell'SSE - hanno `detail` come stringa, e il 422 di validazione ripete nel campo - `input` il valore ricevuto. + *Codice:* `app/main.py:log_unhandled_exception`, `app/main.py` · + *Test:* `tests/unit/test_abuse_limits.py` Il pannello `/admin` non è collegato da `/` (né viceversa), ma è una scelta di discrezione, non una protezione: la pagina è pubblica, gli endpoint no. @@ -753,8 +742,7 @@ sicurezza. il quorum prima del pagamento ([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-82): un round del backup già pagato risulta impagabile invece di essere pagato due volte - con il denaro dei round successivi. *Oggi:* la pausa si imposta a mano nel - database. + con il denaro dei round successivi. - **Chiave sbagliata.** Una master key che non corrisponde al database ripristinato ferma l'avvio ([SEC-49]). - **Commit persi.** Con `synchronous=FULL` un commit confermato al chiamante diff --git a/docs/storico-bug.md b/docs/storico-bug.md index c4e1fcb..6156cb9 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -244,7 +244,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). | B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS | | B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | *da registrare* | `test_abuse_limits.py` | SEC, API | | B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | *da registrare* | `test_round_snapshot.py`, `test_admin.py`, `test_rounds_route.py`, `test_scheduler.py` | RND, BET | -| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | **aperto** | — | TX, BET, WDR, DEP | +| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | *da registrare* | `test_state_consistency.py` | TX, BET, WDR, DEP | | B-97 | Le migrazioni possono eliminare in silenzio gli indici su espressione | Media | *da registrare* | `test_migrations.py` | DAT | | B-98 | La selezione delle monete si ferma alla prima che copre l'importo e rifiuta una scommessa pagabile | Media | *da registrare* | `test_psbt_builder.py` | BET, WDR | | B-99 | Un server Electrum di Bitcoin supera ogni controllo sugli header (PLM ne condivide genesis e `powLimit`): il genesis non veniva verificato | Media | *da registrare* | `test_electrum_client.py` | ELC | @@ -254,8 +254,8 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). - **Corretti:** B-01 … B-72, tranne B-16, che è chiuso nella sostanza (il crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera - come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-94, B-95, B-98, B-99, B-100. -- **Aperti:** dalla revisione del 2026-09-28, B-92, B-93 e B-96 (0 critici, - 0 alti, 3 medi), da implementare nell'ordine di + come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-94, B-95, B-96, B-98, B-99, B-100. +- **Aperti:** dalla revisione del 2026-09-28, B-92 e B-93 (0 critici, + 0 alti, 2 medi), da implementare nell'ordine di [piano-correzioni.md](piano-correzioni.md). - **Prossimo numero libero:** `B-101`. diff --git a/docs/transazioni-e-riconciliazione.md b/docs/transazioni-e-riconciliazione.md index 7dcefd9..cb769ec 100644 --- a/docs/transazioni-e-riconciliazione.md +++ b/docs/transazioni-e-riconciliazione.md @@ -328,11 +328,7 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre. corrente vale [TX-19]. Da lì la conferma segue la via normale. Una riga già `pending` presente in rete non viene toccata. *Codice:* `app/tx/reconcile.py:_promote` · - *Test:* `tests/unit/test_reconcile.py` - *Stato:* da implementare (B-96) · *Oggi:* si scrive solo - `pending_tx_recovered`: una scommessa o un pagamento recuperati non hanno - mai `bet_placed` o `payout_sent` (e un prelievo recuperato non ha - `withdrawal_sent`). + *Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_state_consistency.py` - **[TX-17]** **Abbandono solo con prova.** Una riga viene abbandonata solo se vale una di queste due prove: 1. un suo ingresso risulta **speso in un blocco** da una transazione che @@ -370,9 +366,7 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre. cui ha cambiato qualcosa (promozione, abbandono, riallineamento del txid), non per ogni riga esaminata. *Codice:* `app/tx/reconcile.py:reconcile_once` · - *Test:* — - *Stato:* da implementare (B-96) · *Oggi:* pubblica una notifica per ogni - riga che ha interrogato, anche quando non cambia nulla. + *Test:* `tests/unit/test_state_consistency.py` - **[TX-25]** Una scommessa in volo esce dall'attesa del round in `closing` ([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#2--chiusura)) in due soli modi: confermata ([TX-07]) o abbandonata con prova ([TX-17]). Non @@ -383,16 +377,14 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre. - **[TX-29]** L'attesa in `closing` senza tempo massimo ([TX-25]) è **visibile**: quando un round resta in `closing` oltre la scadenza per più di `CLOSING_STALL_THRESHOLD_SECONDS` (1800 s), lo scheduler scrive un - evento `closing_stalled` ([audit-log.md](audit-log.md) (B-96)) con il round, + evento `closing_stalled` ([AUD-46](audit-log.md)) con il round, il numero di scommesse ancora in volo e i loro txid, e lo ripete ogni 1800 s finché l'attesa dura; `GET /rounds/current` espone da quando il round attende (`closing_waiting_since`). L'operatore decide con la procedura di - [operativita.md](operativita.md) (B-96); il sistema non esclude da solo una + [OPS-14](operativita.md#round-bloccato-in-closing); il sistema non esclude da solo una scommessa in volo dall'estrazione. *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · - *Test:* — - *Stato:* da implementare (B-96) · *Oggi:* l'attesa non produce alcun - segnale oltre al round che resta in `closing`. + *Test:* `tests/unit/test_state_consistency.py` ## Pagamenti del round: riprovare senza pagare due volte @@ -414,11 +406,7 @@ queste ultime due, un nuovo tentativo potrebbe pagare il vincitore due volte. `paying_out` senza transazione né importi di un pagamento che non esiste, e lo scheduler ricostruisce il pagamento al tentativo successivo. *Codice:* `app/tx/reconcile.py:_abandon`, `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` · - *Test:* — (`tests/unit/test_scheduler.py` verifica i nuovi tentativi di un - round in `paying_out`, non la sequenza abbandono → ricostruzione) - *Stato:* da implementare (B-96) · *Oggi:* azzera solo `payout_txid`; - `winner_amount_sats` e `fee_amount_sats` restano quelli del pagamento - abbandonato. + *Test:* `tests/unit/test_state_consistency.py` ## Dove guardare diff --git a/tests/unit/test_state_consistency.py b/tests/unit/test_state_consistency.py new file mode 100644 index 0000000..6f6e9db --- /dev/null +++ b/tests/unit/test_state_consistency.py @@ -0,0 +1,257 @@ +"""B-96: the smaller state inconsistencies — WDR-15, BET-07 (pending-covered bet), TX-16, TX-18, +TX-27, TX-29/AUD-46, DEP-15/DEP-16 and BET-02/WDR-01.""" + +import asyncio +import json +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest +from sqlalchemy import select + +from app.bets.service import BetError, place_bet +from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal +from app.rounds.events import broadcaster +from app.rounds.scheduler import CLOSING_STALL_THRESHOLD_SECONDS, RoundScheduler +from app.tx.reconcile import reconcile_once +from app.withdrawals.service import WithdrawalError, request_withdrawal +from tests.unit.fake_network import FakeNetwork +from tests.unit.test_reconcile import KnownTxClient, _seed_bet +from tests.unit.test_reconcile import session_factory as reconcile_session_factory # noqa: F401 (fixture) +from tests.unit.test_scheduler import FakeListener, _seed_closing_round +from tests.unit.test_scheduler import session_factory as scheduler_session_factory # noqa: F401 (fixture) +from tests.unit.test_withdrawals import ( + BET_AMOUNT_SATS, + EXTERNAL_ADDRESS, + FakeElectrumClient, + _make_funded_user, +) +from tests.unit.test_withdrawals import session_factory as wallet_session_factory # noqa: F401 (fixture) + + +# --- WDR-15 and the pending-covered bet --------------------------------------------- + + +async def test_a_reduced_full_balance_withdrawal_is_checked_against_the_minimum(wallet_session_factory): + """WDR-15: moving the minimum from a one-coin balance leaves a dust limit behind, which puts + the amount that actually goes out under the minimum.""" + session_factory = wallet_session_factory + user_id = await _make_funded_user(session_factory, 50, BET_AMOUNT_SATS) + client = FakeElectrumClient() + async with session_factory() as session: + user = await session.get(User, user_id) + with pytest.raises(WithdrawalError) as excinfo: + await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS) + assert excinfo.value.code == "amount_below_minimum" + assert excinfo.value.params == {"minimum_sats": BET_AMOUNT_SATS} + assert client.broadcasted == [] + + +async def test_a_bet_covered_only_by_unconfirmed_change_says_wait_not_insufficient(wallet_session_factory): + """BET-07 / DEP-13: the balance on screen covers the bet, the confirmed one does not.""" + session_factory = wallet_session_factory + user_id = await _make_funded_user(session_factory, 51, 3_000_000_000) + async with session_factory() as session: + user = await session.get(User, user_id) + await request_withdrawal(session, FakeElectrumClient(), user, EXTERNAL_ADDRESS, 1_200_000_000) + async with session_factory() as session: + user = await session.get(User, user_id) + assert user.cached_balance_sats == 0 + with pytest.raises(BetError) as excinfo: + await place_bet(session, FakeElectrumClient(), user) + assert excinfo.value.code == "balance_pending_confirmation" + assert excinfo.value.params["pending_sats"] > 0 + + +async def test_a_bet_with_no_money_at_all_is_still_insufficient(wallet_session_factory): + session_factory = wallet_session_factory + user_id = await _make_funded_user(session_factory, 52, 100_000_000) + async with session_factory() as session: + user = await session.get(User, user_id) + with pytest.raises(BetError) as excinfo: + await place_bet(session, FakeElectrumClient(), user) + assert excinfo.value.code == "insufficient_balance" + + +# --- TX-16 / TX-18 / TX-27 ----------------------------------------------------------- + + +async def _events(session_factory): + async with session_factory() as session: + return [e.event_type for e in (await session.scalars(select(AuditLog).order_by(AuditLog.id))).all()] + + +async def test_a_recovered_bet_gets_the_event_its_send_would_have_written(reconcile_session_factory): + """TX-16 / AUD-30: pending_tx_recovered alone left a recovered bet out of the audit trail.""" + session_factory = reconcile_session_factory + await _seed_bet(session_factory, pending_status="building", participant_status="building", age_seconds=300) + await reconcile_once(session_factory, KnownTxClient("betxid")) + assert sorted(await _events(session_factory)) == ["bet_placed", "pending_tx_recovered"] + async with session_factory() as session: + placed = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "bet_placed"))).one() + assert json.loads(placed.payload_json) == {"txid": "betxid", "amount_sats": 999_000_000} + assert placed.user_id is not None and placed.round_id == 1 + + +async def test_a_recovered_withdrawal_and_payout_get_theirs_too(reconcile_session_factory): + from app.tx.reconcile import _promote + + session_factory = reconcile_session_factory + async with session_factory() as session: + withdrawal = Withdrawal( + user_id=1, external_address=EXTERNAL_ADDRESS, amount_requested_sats=5, amount_sent_sats=4, txid="w", status="building" + ) + round_ = Round(status="paying_out", winner_user_id=7, winner_amount_sats=700, fee_amount_sats=300) + session.add_all([withdrawal, round_]) + await session.flush() + w_row = PendingTransaction(kind="withdrawal", withdrawal_id=withdrawal.id, user_id=1, current_txid="w", fee_rate_sat_vb=1, raw_tx_hex="", status="building") + p_row = PendingTransaction(kind="payout", round_id=round_.id, current_txid="p", fee_rate_sat_vb=1, raw_tx_hex="", status="building") + session.add_all([w_row, p_row]) + await session.flush() + await _promote(session, w_row) + await _promote(session, p_row) + await session.commit() + rows = {e.event_type: e for e in (await session.scalars(select(AuditLog))).all()} + assert json.loads(rows["withdrawal_sent"].payload_json) == { + "txid": "w", "amount_sent_sats": 4, "external_address": EXTERNAL_ADDRESS, + } + assert json.loads(rows["payout_sent"].payload_json) == {"txid": "p", "winner_sats": 700, "commission_sats": 300} + assert rows["payout_sent"].user_id == 7 + + +async def test_an_abandoned_payout_leaves_no_amounts_behind(reconcile_session_factory): + """TX-18 / DAT-22.""" + from app.tx.reconcile import _abandon + + session_factory = reconcile_session_factory + async with session_factory() as session: + round_ = Round( + status="paying_out", payout_txid="p", winner_amount_sats=700, fee_amount_sats=300, bump_reserve_sats=50 + ) + session.add(round_) + await session.flush() + row = PendingTransaction(kind="payout", round_id=round_.id, current_txid="p", fee_rate_sat_vb=1, raw_tx_hex="", status="pending") + session.add(row) + await session.flush() + await _abandon(session, row, "no server knows it") + await session.commit() + await session.refresh(round_) + assert (round_.payout_txid, round_.winner_amount_sats, round_.fee_amount_sats, round_.bump_reserve_sats) == ( + None, None, None, None, + ) + + +async def test_the_reconciler_only_pings_the_dashboards_for_rows_it_changed(reconcile_session_factory, monkeypatch): + """TX-27: a row found present and already `pending` is examined, not changed.""" + session_factory = reconcile_session_factory + await _seed_bet(session_factory, pending_status="pending", participant_status="broadcast", age_seconds=30 * 3600) + pings = [] + monkeypatch.setattr(broadcaster, "publish", lambda: pings.append(1)) + assert await reconcile_once(session_factory, KnownTxClient("betxid")) == 0 + assert pings == [] + + +# --- TX-29 / AUD-46 ------------------------------------------------------------------ + + +async def _stalled_rows(session_factory): + async with session_factory() as session: + return list( + (await session.scalars(select(AuditLog).where(AuditLog.event_type == "closing_stalled").order_by(AuditLog.id))).all() + ) + + +async def test_closing_stalled_is_written_past_the_threshold_and_repeats_every_30_minutes(scheduler_session_factory): + session_factory = scheduler_session_factory + round_id = await _seed_closing_round(session_factory, n=2) + async with session_factory() as session: + for participant in (await session.scalars(select(RoundParticipant))).all(): + participant.status = "broadcast" + await session.commit() + scheduler = RoundScheduler(session_factory, FakeListener()) + now = datetime.now(timezone.utc) + + await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS - 60)) + assert await _stalled_rows(session_factory) == [] # not long enough yet + + await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 60)) + (row,) = await _stalled_rows(session_factory) + payload = json.loads(row.payload_json) + assert payload["in_flight"] == 2 and len(payload["txids"]) == 2 and payload["seconds_past_deadline"] >= 1860 + assert row.round_id == round_id and row.user_id is None + + await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 120)) + assert len(await _stalled_rows(session_factory)) == 1 # the last row is the clock: not again yet + + async with session_factory() as session: + last = await session.get(AuditLog, row.id) + last.created_at = last.created_at - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 1) + await session.commit() + await scheduler._note_closing_stall(round_id, now - timedelta(seconds=2 * CLOSING_STALL_THRESHOLD_SECONDS)) + assert len(await _stalled_rows(session_factory)) == 2 + + +# --- DEP-15 / DEP-16 / BET-02 / WDR-01 ------------------------------------------------ + + +async def test_refreshes_of_one_user_run_one_at_a_time_and_other_users_in_parallel(scheduler_session_factory): + from app.electrum.listener import ElectrumListener + + listener = ElectrumListener(lambda endpoint: None, scheduler_session_factory) + running, peak, order = {}, {}, [] + + async def fake_locked(user_id, scripthash): + running[user_id] = running.get(user_id, 0) + 1 + peak[user_id] = max(peak.get(user_id, 0), running[user_id]) + order.append((user_id, scripthash)) + await asyncio.sleep(0.01) + running[user_id] -= 1 + + listener._refresh_user_locked = fake_locked + await asyncio.gather( + listener.refresh_user(1, "a"), listener.refresh_user(1, "b"), listener.refresh_user(2, "c") + ) + assert peak == {1: 1, 2: 1} + assert [s for u, s in order if u == 1] == ["a", "b"] # in order of arrival + + +async def test_one_users_failed_refresh_does_not_end_the_session(scheduler_session_factory): + from app.electrum.listener import ElectrumListener + + listener = ElectrumListener(lambda endpoint: None, scheduler_session_factory) + listener._scripthash_to_user = {"s1": 1, "s2": 2} + calls = [] + + async def fake_refresh(user_id, scripthash): + calls.append(user_id) + if user_id == 1: + raise RuntimeError("duplicate txid/vout") + + listener.refresh_user = fake_refresh + queue = asyncio.Queue() + for item in (("s1", None), ("s2", None)): + queue.put_nowait(item) + task = asyncio.create_task(listener._consume_scripthash(queue)) + await asyncio.sleep(0.05) + assert calls == [1, 2] and not task.done() + task.cancel() + + +async def test_the_connection_is_read_after_the_user_lock_is_taken(): + """BET-02 / WDR-01: a request queued on the lock across a reconnection must use the + connection that exists when it gets the lock, not the one it saw on arrival.""" + from contextlib import asynccontextmanager + + from app.api.routes import bets as bets_route + + listener = SimpleNamespace(client="old") + + @asynccontextmanager + async def acquire(_user_id): + listener.client = None # the connection dropped while this request waited + yield + + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(electrum_listener=listener, user_locks=SimpleNamespace(acquire=acquire)))) + with pytest.raises(Exception) as excinfo: + await bets_route.create_bet(request, SimpleNamespace(), SimpleNamespace(id=1), None) + assert excinfo.value.detail["code"] == "network_unavailable"