diff --git a/.gitignore b/.gitignore index e08c19e..ea894d4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,3 @@ master.xprv.enc *.egg-info/ logs/ data/ -flowchart/*.pdf diff --git a/CLAUDE.md b/CLAUDE.md index 2d3b364..7e27a5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,11 +12,11 @@ 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. -Two full-codebase audits — 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are **all fixed** as of 2026-07-27, each with its own regression test. They were tracked in a `BUGS.md` that was deleted once the list emptied, so the ~276 `B-nn` markers left in comments across the code are pointers into git history (`git log --all --grep 'B-nn'` finds the commit that fixed one, and `git show f1a1145:BUGS.md`-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (`tests/integration/` is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted **by design** instead. A new finding gets the next B-nn, in its own commit with its own regression test. +Two full-codebase audits — 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are **all fixed** as of 2026-07-27, each with its own regression test. A third audit (2026-08-03, B-52 … B-72) lives in the tracked [BUGS.md](BUGS.md), which lists only what is still **open** (B-71, B-72 at the time of writing); the full history of every B-nn, open and closed, is in [docs/storico-bug.md](docs/storico-bug.md). Closed findings leave the file, so the ~276 `B-nn` markers left in comments across the code are pointers into git history (`git log --all --grep 'B-nn'` finds the commit that fixed one, and `git show f1a1145:BUGS.md`-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (`tests/integration/` is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted **by design** instead. A new finding gets the next B-nn, in its own commit 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. +**[docs/](docs/) is the specification, and it is authoritative.** Every verifiable rule there has a stable requirement ID (`RND-04`, `BET-02`, …) with `*Codice:*`/`*Test:*` pointers to what implements it; the index is [docs/come-funziona.md](docs/come-funziona.md) and the ID convention and change workflow are in [docs/processo-di-modifica.md](docs/processo-di-modifica.md). A behaviour change starts in the spec: edit or add the requirement, implement it, cite the ID in the test docstring and the commit message, and ship spec + code + test in one commit. If this file and the spec disagree, the spec wins and this file is the one to fix. Before writing code, read the relevant spec documents and the "Architecture" section below. -Human-facing guides are in [docs/](docs/), in Italian by explicit request (an exception to the English-only rule): [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md). README's Quick start and `docs/running-the-server.md` are Docker-only, matching this file — a bare `uvicorn --reload` workflow was removed from both (B-44). +Human-facing guides are in [docs/](docs/), in Italian by explicit request (an exception to the English-only rule): the specification starting at [come-funziona.md](docs/come-funziona.md), the operational guides [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md). README's Quick start and `docs/running-the-server.md` are Docker-only, matching this file — a bare `uvicorn --reload` workflow was removed from both (B-44). ## Commands @@ -97,7 +97,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW `GET /rounds/current`'s `jackpot_sats` is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual `bet_amount_sats` (each already net of its own bet fee) rather than `count × current bet amount` — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11). It counts **confirmed participants only** (B-65), matching what the draw picks from and what the payout can spend, with `pending_participant_count`/`pending_jackpot_sats`/`has_pending_bets` reporting the in-flight bets alongside — inclusive figures, not deltas, exactly like `pending_balance_sats` (see "Balance display"). `/`'s round card shows the confirmed numbers big and the difference as an amber "+N in attesa" suffix, so a player who just bet sees their own bet immediately without the advertised jackpot ever exceeding what will be paid. -**Round cooldown** (`round_cooldown_seconds`, not in the original flowchart): gap after a round closes before the next opens, so players can see the outcome. +**Round cooldown** (`round_cooldown_seconds`, not in the original design): gap after a round closes before the next opens, so players can see the outcome. **Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner. @@ -142,13 +142,13 @@ One connection serves everything — deposit credits, broadcasts, confirmations, - **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets` *while holding the per-user lock*, and could stall the confirmation poller permanently. - **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive. - **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. Two more headers are refused *without* ending the session, since neither implies a hostile server (B-64): one at a height we already hold a header for (a reorg at the tip, or one server disagreeing — the hash committed to for a height is never swapped under us, and `corroborate_header` is what catches us holding an orphan), and one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63. -- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum. No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing. A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep. +- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — with one exception today: `deposits/service.py:reinstate_reappeared_utxos` restores a reappeared UTXO on the active server's word alone. No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing. A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep. On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep. ## Architecture — the 5 phases -Diagrams: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifecycle.mmd](flowchart/round-lifecycle.mmd). +Diagrams: [come-funziona.md](docs/come-funziona.md) (the 5 phases), [ciclo-di-vita-del-round.md](docs/ciclo-di-vita-del-round.md) (the round/draw lifecycle). **REG** — on signup the server derives a P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from the encrypted master xprv. Permanent, and doubles as deposit address, winnings address and withdrawal change address. @@ -166,7 +166,7 @@ Diagrams: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifec **WITHDRAW** — the only way out to an external address: PSBT user-address → external + change back to the user, fee deducted from the withdrawn amount, same RBF pattern. A full-balance withdrawal moves `balance - DUST_LIMIT_SATS` so the change output (and with it the ability to fee-bump) always exists — `Withdrawal.amount_requested_sats` vs `amount_sent_sats` is what records the difference (B-62). -PLAY and WITHDRAW share a **per-user lock** (`tx/locks.py`): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set. That is the *only* mutual exclusion between them (B-70): a withdrawal is accepted while a bet is still unconfirmed, as long as confirmed, unspent UTXOs cover it — the lock plus `select_utxos` skipping anything already marked `spent_txid` is what prevents the two from picking the same input, so freezing the rest of the balance for a block on top of that would restrict the user without protecting anything. +PLAY and WITHDRAW share a **per-user lock** (`tx/locks.py`): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set. That is the *only* mutual exclusion between them (B-70): a withdrawal is accepted while a bet is still unconfirmed, as long as confirmed, unspent UTXOs cover it — the lock plus `place_bet`/`request_withdrawal` only ever offering `select_utxos` UTXOs with `spent_txid IS NULL` is what prevents the two from picking the same input, so freezing the rest of the balance for a block on top of that would restrict the user without protecting anything. **Three separate on-chain confirmations sit between the timer hitting zero and the payout landing** — a common point of confusion: 1. **Last bet's confirmation** — the round doesn't even flip to `"closing"` until every broadcast bet has 1 conf (`_tick`'s `pending_count` check). May already have happened before the deadline. @@ -197,7 +197,7 @@ Everything that spends money is written **before** it is broadcast and resolved - `building` is written first, UTXOs already marked `spent_txid`, and committed *before* the broadcast (`bets/service.py`, `withdrawals/service.py`, and `scheduler.py:_trigger_payout` — the same shape in four phases, so no DB session is ever held across a network call). A crash in that window leaves evidence, not coins spent on-chain with no record. - A refused broadcast releases the UTXOs, restores the balance, removes the participant (or marks the withdrawal `failed`), audit-logs, and raises `broadcast_failed` → **502**, since the network refused it, not the caller. -- `tx/reconcile.py` asks the chain about anything still `building`/`pending`: present → promote; positively unknown → `failed` with a `failure_reason`, inputs released, domain row rolled back, `pending_tx_abandoned` logged. Grace differs by state (120s `building`, 6h `pending`, so the bumper gets its attempts first). A *transport* failure never abandons anything — only a server that positively doesn't know the tx, currently inferred by substring-matching the error text (fragile — B-41). +- `tx/reconcile.py` asks the chain about anything still `building`/`pending`: present → promote; positively unknown → `failed` with a `failure_reason`, inputs released, domain row rolled back, `pending_tx_abandoned` logged. Grace differs by state (120s `building`, 6h `pending`, so the bumper gets its attempts first). A *transport* failure never abandons anything — only a server that positively doesn't know the tx, established by the tx's txid being absent from `get_history` of the address it spends from/pays back to (`app/tx/pending_address.py:own_address_for`) — B-41 replaced the old substring-matching on error text. A tx still in the server's mempool is never abandoned, so in practice that is what bounds the `closing` wait. `UtxoEvent.spent_txid` must always equal the tx's *current* txid, so `bump_fee` retargets it along with `RoundParticipant.bet_txid`, `Withdrawal.txid` and `Round.payout_txid` on every bump. `broadcast_at` is the *first* broadcast and is never rewritten (the reconciler's abandon clock measures from it); `last_broadcast_at` is what a bump updates and `should_bump` reads. Confirmation handlers key off immutable ids (`round_id`/`user_id`, `withdrawal_id`), never the txid, which changes under them. @@ -207,7 +207,7 @@ Everything that spends money is written **before** it is broadcast and resolved ## Frontends -Two static SPAs served directly by FastAPI (`main.py` mounts `app/static/` and adds routes for `/admin`, `/guida`, `/report-bug`) — no build step, no framework, no bundler, `Cache-Control: no-store`. +Two static SPAs served directly by FastAPI (`main.py` mounts `app/static/` and adds routes for `/admin`, `/guida`, `/report-bug`) — no build step, no framework, no bundler. `Cache-Control: no-store` is set on the four HTML routes only; the JS/CSS under the `StaticFiles` mount get ETag/Last-Modified and no Cache-Control. - **`/`** — end-user test UI: register/login, then a navbar dashboard with four panels (Deposito with a QR from `GET /qr/{address}`, Bet, Prelievo, Profilo — account info + self-service password change via `POST /users/me/change-password`), above a persistent round-status card and the chain-status bar with the language switcher. - **`/admin`** — gated by a token screen (not a login: just `X-Admin-Token` vs `ADMIN_TOKEN`), then six sections each backed by its own `/admin/*` endpoint: Parametri (`RoundConfig` + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log, Bug report (triage `open` → `read` → `resolved`, audit-logged `bug_report_status_changed`); plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction. @@ -224,7 +224,7 @@ Both talk to the same JSON API; there's no admin/user API split beyond `require_ - `/report-bug`'s `bugReport.englishNotice` string is itself translated into all 7 languages — it just always *says*, in whichever language the visitor reads, to write the actual bug description in English (so the admin panel, which is Italian-operator-facing and untranslated, doesn't end up with reports in 7 different languages). - `/admin` is intentionally untranslated (operator-facing, Italian), as is `/guida`. -**API error contract** (`app/api/errors.py`) — the API is single-language by design. Failures answer with a structured `detail`: `{"code", "message", "params"}`, where `message` is English for non-dashboard consumers and `code` is what the frontend maps to `error.` in `i18n.js` (falling back to `message` for an unknown code). `BetError`/`WithdrawalError` subclass `ApiError` and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (`internal_error`), so clients never special-case unexpected errors, and the exception text stays in `logs/app.log`. Adding a user-facing error: give it a code, add `error.` to all 7 languages, and pass values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling) instead of baking them into English text. +**API error contract** (`app/api/errors.py`) — the API is single-language by design. Failures answer with a structured `detail`: `{"code", "message", "params"}`, where `message` is English for non-dashboard consumers and `code` is what the frontend maps to `error.` in `i18n.js` (falling back to `message` for an unknown code). `BetError`/`WithdrawalError` subclass `ApiError` and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (`internal_error`), so clients rarely special-case unexpected errors (the exceptions — `require_admin`'s 403/404s, the SSE 503, FastAPI's own 401/404/405/422 — still carry a bare-string or list `detail`; see `docs/api.md`), and the exception text stays in `logs/app.log`. Adding a user-facing error: give it a code, add `error.` to all 7 languages, and pass values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling) instead of baking them into English text. ## Non-obvious domain decisions diff --git a/README.md b/README.md index 55963c8..aee27bc 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ domain). ## Documentation - [CLAUDE.md](CLAUDE.md) — architecture, commands, domain decisions, known gaps (for anyone/anything working on the code) -- [flowchart/](flowchart/) — the source-of-truth flow diagrams the implementation follows node-by-node: [platform-overview.mmd](flowchart/platform-overview.mmd) (the 5-phase flow) and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) (the round/draw lifecycle) +- [docs/come-funziona.md](docs/come-funziona.md) — the specification (Italian): how the platform works, with diagrams and traceable requirement IDs; it indexes every per-topic document (flows, architecture, data model, API, security, audit log, operations, decisions and limits, bug history). [docs/processo-di-modifica.md](docs/processo-di-modifica.md) describes how a change goes from the spec to the code - [docs/setup.md](docs/setup.md) — one-time setup (secrets, master key, migrations) - [docs/running-the-server.md](docs/running-the-server.md) — how to launch it with Docker+Caddy (dev vs. production TLS) - [docs/guida-utente.md](docs/guida-utente.md) — end-user guide to the test UI (Italian) diff --git a/app/rounds/draw.py b/app/rounds/draw.py index 35107f8..3795cf4 100644 --- a/app/rounds/draw.py +++ b/app/rounds/draw.py @@ -78,9 +78,9 @@ def header_meets_its_own_target(header_hex: str) -> bool: def draw_winner(participants: list[str], block_hash_hex: str) -> str: - """v1 draw algorithm (flowchart.mmd, node R): seed = block hash as an integer, - index = seed mod participant_count, winner = participants[index]. Anyone can - recompute and verify it from public data. Deliberately simple/replaceable.""" + """v1 draw algorithm (docs/ciclo-di-vita-del-round.md): seed = block hash as an + integer, index = seed mod participant_count, winner = participants[index]. Anyone + can recompute and verify it from public data. Deliberately simple/replaceable.""" if not participants: raise ValueError("no participants to draw from") seed = int(block_hash_hex, 16) diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index e7b51b9..12d2240 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -39,10 +39,10 @@ _DRAW_STALL_THRESHOLD_SECONDS = 360 # a few multiples of PLM's 120s block time class RoundScheduler: - """Background task implementing flowchart.mmd's DRAW subgraph: closes the - round on its timer (once any in-flight bets have confirmed), draws a winner - from the next confirmed block, and broadcasts the payout. The next round only - opens once this one is fully closed (rounds/service.get_active_round).""" + """Background task implementing the round lifecycle (docs/ciclo-di-vita-del-round.md): + closes the round on its timer (once any in-flight bets have confirmed), draws a + winner from the next confirmed block, and broadcasts the payout. The next round + only opens once this one is fully closed (rounds/service.get_active_round).""" def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener): self._session_factory = session_factory diff --git a/app/wallet/hd.py b/app/wallet/hd.py index bbffd48..23bf961 100644 --- a/app/wallet/hd.py +++ b/app/wallet/hd.py @@ -51,7 +51,7 @@ def derive_user_wif(derivation_index: int) -> str: def derive_pool_key() -> HDKey: - """The "indirizzo padre" from the flowchart: all bets are sent here, and + """The pool key ("indirizzo padre"): all bets are sent here, and payouts are signed with this key. Reserved on branch 1 of the account (branch 0 is user addresses), index 0 — not a spec requirement, an implementation choice to keep it in the same encrypted master key rather than a separate secret.""" diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..698c673 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,800 @@ +# API HTTP + +Specifica dell'interfaccia HTTP di PLM Lottery: convenzioni comuni, contratto +degli errori, ogni endpoint esposto e il catalogo completo dei codici di +errore. Descrive **la forma** delle richieste e delle risposte; il +comportamento di dominio che un endpoint innesca (come si costruisce una +scommessa, come si chiude un round…) è specificato nel documento del suo +argomento, a cui ogni sezione rimanda: + +| Argomento | Documento | +|---|---| +| Registrazione, depositi, saldo mostrato | [registrazione-e-depositi.md](registrazione-e-depositi.md) | +| Scommesse e prelievi | [scommesse-e-prelievi.md](scommesse-e-prelievi.md) | +| Round, estrazione, pagamento | [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) | +| Ciclo di vita delle transazioni, RBF | [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) | +| Chiavi, JWT, limiti di frequenza, header di sicurezza | [sicurezza.md](sicurezza.md) | +| Eventi del registro di audit | [audit-log.md](audit-log.md) | +| Tabelle e campi | [modello-dati.md](modello-dati.md) | +| Frontend, canale SSE lato architettura, i18n | [architettura.md](architettura.md) | + +Le due interfacce web (`/` e `/admin`) parlano con questa stessa API JSON: non +esiste una API separata per l'amministrazione, solo un'autenticazione diversa. + +## Panoramica degli endpoint + +| Metodo | Percorso | Autenticazione | Requisito | +|---|---|---|---| +| `POST` | `/auth/register` | nessuna | [API-18](#post-authregister) | +| `POST` | `/auth/login` | nessuna | [API-19](#post-authlogin) | +| `GET` | `/users/me` | Bearer | [API-20](#get-usersme) | +| `POST` | `/users/me/change-password` | Bearer | [API-21](#post-usersmechange-password) | +| `GET` | `/users/me/last-round-result` | Bearer | [API-22](#get-usersmelast-round-result) | +| `POST` | `/bets` | Bearer | [API-23](#post-bets) | +| `POST` | `/withdrawals` | Bearer | [API-24](#post-withdrawals) | +| `GET` | `/rounds/current` | facoltativa | [API-25](#get-roundscurrent) | +| `GET` | `/rounds/stream` | nessuna | [API-26](#get-roundsstream) | +| `GET` | `/qr/{address}` | nessuna | [API-27](#get-qraddress) | +| `POST` | `/bug-reports` | facoltativa | [API-28](#post-bug-reports) | +| `GET` | `/bug-reports/mine` | Bearer | [API-29](#get-bug-reportsmine) | +| `GET` | `/admin/config` | admin | [API-30](#get-adminconfig) | +| `PUT` | `/admin/config` | admin | [API-31](#put-adminconfig) | +| `POST` | `/admin/pause` | admin | [API-32](#post-adminpause-e-post-adminresume) | +| `POST` | `/admin/resume` | admin | [API-32](#post-adminpause-e-post-adminresume) | +| `GET` | `/admin/users` | admin | [API-33](#get-adminusers) | +| `GET` | `/admin/users/{user_id}/privkey` | admin | [API-34](#get-adminusersuser_idprivkey) | +| `POST` | `/admin/users/{user_id}/reset-password` | admin | [API-35](#post-adminusersuser_idreset-password) | +| `GET` | `/admin/rounds` | admin | [API-36](#get-adminrounds) | +| `GET` | `/admin/audit-log` | admin | [API-37](#get-adminaudit-log) | +| `GET` | `/admin/pending-transactions` | admin | [API-38](#get-adminpending-transactions) | +| `GET` | `/admin/bug-reports` | admin | [API-39](#get-adminbug-reports) | +| `POST` | `/admin/bug-reports/{report_id}/status` | admin | [API-40](#post-adminbug-reportsreport_idstatus) | +| `GET` | `/health` | nessuna | [API-41](#get-health) | +| `GET` | `/`, `/admin`, `/guida`, `/report-bug` | nessuna | [API-42](#pagine-e-file-statici) | +| `GET` | qualunque altro percorso (file statici) | nessuna | [API-43](#pagine-e-file-statici) | + +## Convenzioni generali + +- **[API-01]** L'API è servita dallo stesso processo e sulla stessa origine + delle interfacce web: in produzione `https:///…` attraverso + Caddy (che termina TLS e inoltra al container `app` sulla porta interna + 8123), senza prefisso di versione (`/v1`…) e senza CORS — non è installato + alcun middleware CORS, quindi un browser può chiamarla solo dalla stessa + origine. I percorsi sono quelli della tabella sopra, alla radice. + *Codice:* `app/main.py` (`include_router`), `Caddyfile`, `Dockerfile` + +- **[API-02]** Corpi di richiesta e di risposta sono JSON (`application/json`), + tranne tre eccezioni: `GET /qr/{address}` risponde `image/png`, + `GET /rounds/stream` risponde `text/event-stream`, e le pagine/i file statici + rispondono con il loro tipo. I campi dei corpi sono validati da modelli + Pydantic; i campi **non previsti vengono ignorati** in silenzio (comportamento + predefinito di Pydantic), non rifiutati. + *Codice:* `app/api/routes/` (modelli `*Request`/`*Response` di ogni file) + +- **[API-03]** Tutti gli importi viaggiano come **interi in satoshi** (1 PLM = + 100 000 000 sat), in campi il cui nome termina in `_sats`. L'API non espone + mai importi in PLM né valori decimali; la conversione per la visualizzazione + è compito del client. + *Codice:* `app/api/routes/` · `app/static/app.js:errorParams` + +- **[API-04]** Ogni istante nelle risposte è una stringa ISO 8601 **in UTC con + offset esplicito** `+00:00` (ad esempio `2026-09-28T10:15:00.123456+00:00`). + SQLite restituisce datetime privi di fuso: prima della serializzazione vengono + marcati come UTC, così `new Date()` nel browser non li interpreta come ora + locale. `server_time` di `GET /rounds/current` è generato direttamente con + fuso UTC e ha lo stesso formato. + *Codice:* `app/api/timeutil.py:isoformat_utc` · + *Test:* `tests/unit/test_api_timeutil.py`, `tests/unit/test_users.py` + +- **[API-05]** Esistono quattro modalità di autenticazione, fissate per + endpoint (colonna "Autenticazione" della panoramica): + + | Modalità | Come si presenta | Se manca o è invalida | + |---|---|---| + | **Bearer** (`get_current_user`) | header `Authorization: Bearer ` | `401` | + | **facoltativa** (`get_optional_user`) | lo stesso header, se c'è | la richiesta prosegue come anonima | + | **admin** (`require_admin`) | header `X-Admin-Token: ` | `403` | + | **nessuna** | — | — | + + Le regole di emissione e verifica del JWT (algoritmo, durata, claim `tv`, + revoca al cambio password) sono in [sicurezza.md](sicurezza.md); qui conta + cosa vede il client. + *Codice:* `app/auth/dependencies.py:get_current_user`, + `app/auth/dependencies.py:get_optional_user`, + `app/api/routes/admin.py:require_admin` + +- **[API-06]** Autenticazione **Bearer**: il token è quello restituito da + `/auth/register`, `/auth/login` o `/users/me/change-password`. Un token + illeggibile, scaduto o con firma errata, un utente non più esistente e un + token *superato* (emesso prima dell'ultimo cambio o reset della password) + rispondono tutti `401` con codice `session_expired`. L'**assenza** dell'header + (o uno schema diverso da `Bearer`) è invece intercettata da FastAPI prima del + codice applicativo: `401` con il corpo non strutturato + `{"detail": "Not authenticated"}` e header `WWW-Authenticate: Bearer`. + *Codice:* `app/auth/dependencies.py:get_current_user` · + *Test:* `tests/unit/test_users.py`, `tests/unit/test_bug_reports.py` + +- **[API-07]** Autenticazione **facoltativa**: senza header, con un header che + non inizia con `Bearer `, con un token invalido o superato, la richiesta è + trattata come anonima — **mai** un errore. Serve agli endpoint raggiungibili + anche da non loggati che personalizzano la risposta se l'utente è noto + (`GET /rounds/current`, `POST /bug-reports`). + *Codice:* `app/auth/dependencies.py:get_optional_user` · + *Test:* `tests/unit/test_rounds_route.py`, `tests/unit/test_bug_reports.py` + +- **[API-08]** Autenticazione **admin**: l'header `X-Admin-Token` è confrontato + a tempo costante (sui byte UTF-8, così un valore non ASCII non provoca un + `500`) con `ADMIN_TOKEN` del `.env`. Un `ADMIN_TOKEN` vuoto nega **tutto**, + anche un header vuoto. Il rifiuto è `403` con il corpo non strutturato + `{"detail": "invalid admin token"}`. Non c'è identità per singolo operatore + (vedi [sicurezza.md](sicurezza.md)). + *Codice:* `app/api/routes/admin.py:require_admin` · + *Test:* `tests/unit/test_admin.py` + +- **[API-09]** Intestazioni di cache: + - le quattro pagine (`/`, `/admin`, `/guida`, `/report-bug`) rispondono con + `Cache-Control: no-store`; + - il flusso SSE risponde con `Cache-Control: no-store` e + `X-Accel-Buffering: no`; + - il QR risponde con `Cache-Control: private, max-age=86400, immutable`; + - le risposte JSON e i file statici serviti dal mount (`/app.js`, + `/style.css`, `/i18n.js`, `/index.html`…) **non** hanno `Cache-Control`: + i file statici portano solo `ETag`/`Last-Modified` per la validazione + condizionale. + *Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`, + `app/main.py:user_guide`, `app/main.py:report_bug_page`, + `app/api/routes/rounds.py:round_stream`, `app/api/routes/qr.py:address_qr` + +- **[API-10]** La documentazione automatica di FastAPI (`/docs`, `/redoc`, + `/openapi.json`) è **disattivata** a meno che `ENABLE_API_DOCS` non sia + impostato a vero nel `.env`: elencherebbe a chiunque l'intera superficie + dell'API, endpoint admin compresi. Disattivata, quei percorsi rispondono + `404`. + *Codice:* `app/main.py` (costruttore `FastAPI`), `app/config.py:Settings` · + *Test:* `tests/unit/test_docs_disabled.py` + +- **[API-11]** Dove un controllo dipende dall'indirizzo IP del chiamante (limiti + di login e registrazione, tetto per IP del flusso SSE), l'IP è l'**ultimo** + elemento di `X-Forwarded-For` (quello scritto dal proxy fidato), o in sua + assenza l'indirizzo della connessione, o `"unknown"`. Il motivo e il ruolo di + Caddy sono in [sicurezza.md](sicurezza.md). + *Codice:* `app/api/client_ip.py:client_ip` · + *Test:* `tests/unit/test_client_ip.py` + +## Contratto degli errori + +- **[API-12]** Ogni errore **voluto** dal codice applicativo risponde con un + `detail` strutturato: + + ```json + { + "detail": { + "code": "insufficient_balance", + "message": "insufficient balance", + "params": {"required_sats": 1000000000} + } + } + ``` + + - `code` — identificatore stabile, in `snake_case`; è ciò che il client usa. + - `message` — testo **in inglese** per client che non conoscono i codici + (curl, test); l'API è monolingua per scelta. + - `params` — i valori che il messaggio interpola (importi come `*_sats`, + limiti, attese), sempre presente, eventualmente `{}`. + + Gli errori nascono come `ApiError` (o sue sottoclassi `BetError`, + `WithdrawalError`) nei servizi, oppure direttamente con `http_error` nelle + route. + *Codice:* `app/api/errors.py:ApiError`, `app/api/errors.py:http_error`, + `app/api/errors.py:from_api_error` + +- **[API-13]** Gli errori di `POST /bets` e `POST /withdrawals` sollevati dal + servizio rispondono `400`, **tranne** `broadcast_failed`, che risponde `502`: + è la rete ad aver rifiutato la transazione, non il chiamante. + *Codice:* `app/api/routes/bets.py:create_bet`, + `app/api/routes/withdrawals.py:create_withdrawal` · + *Test:* `tests/unit/test_bets.py` + +- **[API-14]** Qualunque eccezione non gestita risponde `500` con lo stesso + formato strutturato, codice `internal_error`, messaggio + `"internal server error"`, `params` vuoto. Il testo e lo stack + dell'eccezione finiscono solo nel log (`logs/app.log`), **mai** nella + risposta. + *Codice:* `app/main.py:log_unhandled_exception` · + *Test:* `tests/unit/test_rounds_route.py` + +- **[API-15]** Un corpo, un parametro di percorso o di query che non supera la + validazione risponde `422` nel formato **nativo** di FastAPI: `detail` è una + **lista** di oggetti `{type, loc, msg, input, ctx?}`, in inglese, uno per + campo invalido (anche il JSON malformato arriva così, con + `type: "json_invalid"`). Non c'è un `code`: il frontend riconosce la lista e + mostra il messaggio generico tradotto `error.invalid_request`. + *Codice:* FastAPI (nessun gestore personalizzato), + `app/static/app.js:apiErrorMessage` · + *Test:* `tests/unit/test_users.py`, `tests/unit/test_admin.py`, + `tests/unit/test_bug_reports.py` + +- **[API-16]** Alcune risposte d'errore **non** seguono il formato strutturato + e hanno `detail` come semplice stringa inglese: + + | Stato | `detail` | Dove | + |---|---|---| + | `401` | `"Not authenticated"` | header `Authorization` assente su un endpoint Bearer (FastAPI) | + | `403` | `"invalid admin token"` | ogni endpoint `/admin/*` | + | `404` | `"user not found"` | `GET /admin/users/{id}/privkey`, `POST /admin/users/{id}/reset-password` | + | `404` | `"bug report not found"` | `POST /admin/bug-reports/{id}/status` | + | `404` | `"Not Found"` | percorso inesistente (anche un metodo `GET` su un percorso che esiste solo in `POST`, perché lo intercetta il mount dei file statici) | + | `405` | `"Method Not Allowed"` | metodo diverso da `GET`/`HEAD` su un percorso non definito da un router | + | `503` | `"too many concurrent update streams"` | `GET /rounds/stream` oltre il tetto globale | + + Il frontend mostra la stringa così com'è. + *Codice:* `app/api/routes/admin.py:require_admin`, + `app/api/routes/admin.py:user_privkey`, + `app/api/routes/admin.py:reset_user_password`, + `app/api/routes/admin.py:update_bug_report_status`, + `app/api/routes/rounds.py:round_stream` · + *Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py` + +- **[API-17]** Il client traduce `code` con la chiave `error.` di + `app/static/i18n.js`, passando `params` all'interpolazione e aggiungendo per + ogni parametro numerico `_sats` un gemello `_plm` già formattato + in PLM; se la chiave non esiste mostra `message`. Ogni codice che l'API può + restituire deve avere la sua chiave `error.` in **tutte e sette** le + lingue (en, it, es, fr, de, ru, zh). Un `401` con codice `session_expired` + riporta inoltre il frontend alla schermata di login. + *Codice:* `app/static/app.js:apiErrorMessage`, + `app/static/app.js:errorParams`, `app/static/i18n.js:TRANSLATIONS` + +## Endpoint + +### Autenticazione + +#### POST /auth/register + +- **[API-18]** Crea un account e restituisce subito una sessione. + - **Autenticazione:** nessuna. + - **Corpo:** + + | Campo | Tipo | Vincoli | + |---|---|---| + | `username` | stringa | 3–32 caratteri, solo `A-Z a-z 0-9 _ . -` | + | `password` | stringa | 8–256 caratteri (`MIN_PASSWORD_LENGTH` = 8) | + + - **Risposta `201`:** `{"access_token": , "address": }` — + l'indirizzo di deposito permanente dell'utente. + - **Errori:** `422` (validazione del corpo); `429 rate_limited` (quota IP + esaurita, `params.retry_after_seconds`); `409 username_taken` (nome già + usato, **senza distinzione tra maiuscole e minuscole**, anche se + l'altro account si è registrato nello stesso istante); + `409 derivation_index_conflict` (dopo 5 tentativi non è stato possibile + assegnare un indice di derivazione libero). + - **Limite di frequenza:** al massimo 5 account creati per IP in una finestra + mobile di un'ora; la quota è verificata **prima** dell'hash Argon2 e + consumata solo da una registrazione riuscita (un nome già preso o un + errore di validazione non la consumano). Dettagli in + [sicurezza.md](sicurezza.md). + - **Effetti:** inserisce l'utente con il nome così come digitato, il + prossimo indice di derivazione e l'indirizzo derivato; avvia (senza + attenderla, *best effort*) la sottoscrizione Electrum dell'indirizzo. Non + scrive audit e non notifica l'SSE. Il flusso completo è in + [registrazione-e-depositi.md](registrazione-e-depositi.md). + *Codice:* `app/auth/routes.py:register`, `app/auth/routes.py:RegisterRequest` · + *Test:* `tests/unit/test_auth.py`, `tests/unit/test_users.py` + +#### POST /auth/login + +- **[API-19]** Apre una sessione. + - **Autenticazione:** nessuna. + - **Corpo:** `{"username": stringa, "password": stringa}`, senza vincoli di + lunghezza. Lo username è confrontato senza distinzione tra maiuscole e + minuscole. + - **Risposta `200`:** `{"access_token": , "address": }`. + - **Errori:** `422`; `429 rate_limited` (`params.retry_after_seconds`, + arrotondato per eccesso al secondo); `401 invalid_credentials` — + **identico** per username inesistente e password sbagliata. + - **Limite di frequenza:** due contatori di fallimenti con attesa + esponenziale, uno per username (dal 5° fallimento) e uno per IP (dal 20°). + Il blocco è verificato prima della password, quindi durante un blocco + anche la password giusta riceve `429`. Un login riuscito azzera solo il + contatore dello username. Valori e motivazioni in + [sicurezza.md](sicurezza.md). + - **Effetti:** nessuno oltre ai contatori in memoria; nessun audit. + *Codice:* `app/auth/routes.py:login`, `app/auth/rate_limit.py:AuthRateLimiters` · + *Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py` + +### Utente + +#### GET /users/me + +- **[API-20]** Profilo e saldo dell'utente autenticato. + - **Autenticazione:** Bearer. + - **Risposta `200`:** + + | Campo | Tipo | Significato | + |---|---|---| + | `id` | intero | identificativo dell'utente | + | `username` | stringa | come digitato alla registrazione | + | `address` | stringa | indirizzo permanente (deposito, vincite, resto) | + | `balance_sats` | intero | saldo **confermato e spendibile** (`User.cached_balance_sats`); è quello su cui si basano scommesse e prelievi | + | `pending_balance_sats` | intero | saldo **comprensivo** del resto ancora non confermato di scommesse/prelievi in volo — il valore da mostrare; è un totale, non una differenza | + | `has_pending` | booleano | vero se esiste almeno una transazione in volo dell'utente (il frontend mostra il saldo in ambra) | + | `created_at` | stringa ISO | data di registrazione | + + Come si calcolano i due saldi è in + [registrazione-e-depositi.md](registrazione-e-depositi.md). + - **Errori:** `401` (vedi [API-06](#convenzioni-generali)). + - **Effetti:** nessuno. + *Codice:* `app/api/routes/users.py:me`, + `app/wallet/balance.py:compute_pending_balance` · + *Test:* `tests/unit/test_users.py`, `tests/unit/test_balance.py` + +#### POST /users/me/change-password + +- **[API-21]** Cambio password self-service. + - **Autenticazione:** Bearer. + - **Corpo:** `{"current_password": stringa, "new_password": stringa}`. + - **Risposta `200`:** `{"access_token": }` — un **nuovo** token per la + sessione corrente. + - **Errori**, nell'ordine in cui sono verificati: `401` di autenticazione; + `401 current_password_incorrect`; `400 password_too_short` + (`params.minimum` = 8). La lunghezza minima è verificata nel gestore (quindi + `400`, non `422` come alla registrazione) e **non** c'è una lunghezza + massima. + - **Effetti:** sostituisce l'hash e incrementa `User.token_version`: ogni + token emesso prima, compreso quello usato per questa richiesta, da quel + momento riceve `401 session_expired`. Non scrive audit e non notifica + l'SSE. Nessun limite di frequenza. + *Codice:* `app/api/routes/users.py:change_password` · + *Test:* `tests/unit/test_users.py` + +#### GET /users/me/last-round-result + +- **[API-22]** Esito dell'ultimo round **chiuso** a cui l'utente ha + partecipato, letto dal database. È la rete di sicurezza per un dispositivo + che ha perso la finestra in cui `GET /rounds/current` mostrava il vincitore. + - **Autenticazione:** Bearer. + - **Risposta `200`:** + + | Campo | Tipo | Significato | + |---|---|---| + | `round_id` | intero o `null` | l'ultimo round in stato `closed` con una riga di partecipazione dell'utente; `null` se non ce n'è nessuno | + | `won` | booleano | vero se l'utente è il vincitore di quel round | + | `amount_sats` | intero o `null` | importo effettivamente pagato al vincitore (`Round.winner_amount_sats`, già al netto della commissione del pagamento), solo se `won` | + + Senza round chiusi giocati la risposta è + `{"round_id": null, "won": false, "amount_sats": null}`. + - **Errori:** `401`. + - **Effetti:** nessuno. + *Codice:* `app/api/routes/users.py:last_round_result` + +### Scommesse e prelievi + +#### POST /bets + +- **[API-23]** Piazza la scommessa dell'utente sul round in corso, al costo + fisso attuale. Regole, sequenza e rollback sono in + [scommesse-e-prelievi.md](scommesse-e-prelievi.md). + - **Autenticazione:** Bearer. + - **Corpo:** nessuno. + - **Risposta `201`:** + + | Campo | Tipo | Significato | + |---|---|---| + | `round_id` | intero | round a cui la scommessa è iscritta | + | `bet_txid` | stringa | txid della transazione trasmessa | + | `bet_amount_sats` | intero | importo arrivato al montepremi, cioè il costo **meno** la commissione di rete | + | `status` | stringa | stato del partecipante, `"broadcast"` | + + - **Errori:** `503 network_unavailable` (nessuna connessione Electrum, + verificato **prima** di ogni altro controllo); poi, tutti `400` salvo + indicazione: `no_round_open`, `round_closing`, `already_betting`, + `round_full` (`params.max_participants`), `insufficient_balance` + (`params.required_sats`), `too_many_inputs` (`params.max_inputs`), + `balance_leaves_no_change` (`params.required_extra_sats`), + `amount_below_network_fee`, `amount_below_dust_limit`, + `502 broadcast_failed`. Il significato di ciascuno è nel + [catalogo](#catalogo-dei-codici-di-errore). + - **Serializzazione:** la richiesta tiene il blocco per-utente condiviso con + `POST /withdrawals` per tutta la costruzione e la trasmissione. + - **Effetti:** può **aprire un round** se nessuno è attivo e le condizioni + lo permettono; segna le monete spese, crea partecipante e transazione in + sospeso, trasmette. Audit `bet_placed` in caso di successo, + `bet_broadcast_failed` se la rete rifiuta; notifica SSE in entrambi i casi + (e all'apertura del round). + *Codice:* `app/api/routes/bets.py:create_bet`, + `app/bets/service.py:place_bet` · + *Test:* `tests/unit/test_bets.py` + +#### POST /withdrawals + +- **[API-24]** Invia fondi a un indirizzo esterno. Regole e sequenza in + [scommesse-e-prelievi.md](scommesse-e-prelievi.md). + - **Autenticazione:** Bearer. + - **Corpo:** + + | Campo | Tipo | Vincoli | + |---|---|---| + | `external_address` | stringa | verificato nel servizio (bech32 valido con prefisso `plm1`) | + | `amount_sats` | intero | nessun vincolo di schema; il minimo è verificato nel servizio | + + - **Risposta `201`:** + + | Campo | Tipo | Significato | + |---|---|---| + | `txid` | stringa | txid della transazione trasmessa | + | `amount_requested_sats` | intero | importo chiesto | + | `amount_sent_sats` | intero | importo che arriva al destinatario: richiesto meno commissione, e meno quanto serve a lasciare un resto minimo se si preleva tutto | + | `status` | stringa | stato del prelievo, `"broadcast"` | + + - **Errori:** `422`; `503 network_unavailable` (verificato **prima** della + validazione dell'indirizzo); poi `400`: `invalid_address`, + `withdrawal_to_own_address`, `amount_below_minimum` + (`params.minimum_sats` = costo attuale della scommessa), + `balance_pending_confirmation` (`params.pending_sats`), + `insufficient_balance` (`params.required_sats`), `too_many_inputs` + (`params.max_inputs`), `amount_below_network_fee`, + `amount_below_dust_limit`; `502 broadcast_failed`. + - **Serializzazione:** stesso blocco per-utente di `POST /bets`. + - **Effetti:** segna le monete spese, crea prelievo e transazione in + sospeso, trasmette. Audit `withdrawal_sent`, oppure + `withdrawal_broadcast_failed` con il prelievo lasciato in stato `failed`; + notifica SSE in entrambi i casi. + *Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, + `app/withdrawals/service.py:request_withdrawal` · + *Test:* `tests/unit/test_withdrawals.py` + +### Round + +#### GET /rounds/current + +- **[API-25]** Stato del round attivo, pensato per essere interrogato a ciclo + dalle due interfacce (anche la pillola Electrum/altezza di `/admin` legge + `chain_tip_height` da qui). + - **Autenticazione:** facoltativa (serve solo per `user_played`). + - **Risposta `200`** — tutti i campi sono sempre presenti: + + | Campo | Tipo | Significato | + |---|---|---| + | `server_time` | stringa ISO | ora del server, per allineare i conti alla rovescia del client | + | `round_id` | intero o `null` | round attivo; `null` se non ce n'è | + | `status` | stringa o `null` | `open`, `closing`, `drawing` o `paying_out`; un round `closed` non compare mai | + | `opened_at` | stringa ISO o `null` | apertura del round | + | `closes_at` | stringa ISO o `null` | scadenza per le scommesse: `opened_at` + la durata **fissata all'apertura del round** (non quella attuale di configurazione) | + | `participant_count` | intero | partecipanti **confermati**, cioè quelli tra cui si estrae | + | `bet_amount_sats` | intero | costo della scommessa **attualmente configurato** (quello che pagherebbe una nuova scommessa) | + | `jackpot_sats` | intero | 70% (arrotondato per difetto) della somma degli importi effettivi delle scommesse confermate; limite superiore, perché la commissione del pagamento è ancora da sottrarre | + | `pending_participant_count` | intero | tutti i partecipanti, confermati **e** in volo (totale, non differenza) | + | `pending_jackpot_sats` | intero | 70% della somma di tutte le scommesse, confermate e in volo | + | `has_pending_bets` | booleano | vero se `pending_participant_count` > `participant_count` | + | `draw_animation_seconds` | intero | durata minima dell'animazione dell'estrazione, dalla configurazione | + | `winner_user_id` | intero o `null` | vincitore, valorizzato dall'estrazione in poi (`paying_out`) | + | `winner_amount_sats` | intero o `null` | importo al vincitore, valorizzato solo quando la transazione di pagamento è stata costruita | + | `draw_block_height` | intero o `null` | altezza del blocco usato per l'estrazione | + | `draw_block_hash` | stringa o `null` | hash di quel blocco (il seme dell'estrazione) | + | `draw_waiting_since` | stringa ISO o `null` | inizio dell'attesa del blocco; valorizzato **solo** mentre `status` è `drawing` | + | `chain_tip_height` | intero o `null` | ultima altezza nota della catena; `null` finché non è nota | + | `lottery_paused` | booleano | pausa di manutenzione attiva | + | `lottery_configured` | booleano | falso finché manca configurazione indispensabile (oggi: `fee_address`), e quindi nessun round può aprirsi | + | `user_played` | booleano | vero se il chiamante autenticato ha una riga di partecipazione in questo round, in qualunque stato; sempre falso per un anonimo | + + Senza round attivo sono valorizzati solo `server_time`, `bet_amount_sats`, + `draw_animation_seconds`, `chain_tip_height`, `lottery_paused` e + `lottery_configured`; gli altri valgono `null`, `0` o `false`. Il + significato di dominio di ogni fase è in + [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md). + - **Errori:** nessuno previsto (un token invalido non è un errore, + [API-07](#convenzioni-generali)). + - **Effetti:** **non** apre round (a differenza di `POST /bets`); crea la + riga di configurazione con i valori predefiniti se non esiste ancora. + *Codice:* `app/api/routes/rounds.py:current_round`, + `app/rounds/service.py:get_active_round`, + `app/rounds/service.py:round_deadline`, `app/rounds/service.py:winner_share`, + `app/rounds/service.py:rounds_can_open` · + *Test:* `tests/unit/test_rounds_route.py` + +#### GET /rounds/stream + +- **[API-26]** Canale Server-Sent Events: una notifica senza contenuto + ("qualcosa è cambiato, rileggi") ogni volta che cambia lo stato di round, + scommesse o saldi. È **aggiuntivo** al polling, che resta attivo; i punti del + codice che notificano e il ruolo del canale sono in + [architettura.md](architettura.md). + - **Autenticazione:** nessuna; la notifica non contiene dati. + - **Risposta `200`**, `text/event-stream`, con `Cache-Control: no-store` e + `X-Accel-Buffering: no`. Messaggi: + - `event: update` / `data: {}` — a ogni notifica; notifiche arrivate prima + che il client abbia letto la precedente si fondono in una sola (coda di + capacità 1 per client); + - `: keep-alive` (commento) — dopo 20 secondi senza notifiche. + - **Chiusura:** la connessione di un client scomparso senza chiusura pulita + è rilevata entro 5 secondi. Oltre **5** flussi contemporanei dallo stesso + IP, l'apertura di un nuovo flusso **chiude il più vecchio** di quell'IP. + - **Errori:** `503` con `{"detail": "too many concurrent update streams"}` + (non strutturato) quando i flussi aperti in totale sono già **500**; il + client `EventSource` riprova da solo e intanto resta il polling. + - **Effetti:** nessuno. + *Codice:* `app/api/routes/rounds.py:round_stream`, + `app/rounds/events.py:RoundEventBroadcaster.subscribe`, + `app/rounds/events.py:RoundEventBroadcaster.publish` · + *Test:* `tests/unit/test_round_events.py` + +### QR + +#### GET /qr/{address} + +- **[API-27]** Immagine PNG del QR code di un indirizzo PLM, usata dal pannello + Deposito con un semplice ``. + - **Autenticazione:** nessuna (un `` non può portare il token). + - **Parametro di percorso:** `address`, al massimo 100 caratteri, bech32 + valido con prefisso `plm1` (stessa verifica dei prelievi). + - **Risposta `200`:** `image/png`, con + `Cache-Control: private, max-age=86400, immutable`. + - **Errori:** `400 invalid_address`. + - **Effetti:** nessuno. Il disegno avviene fuori dal ciclo di eventi ed è + memorizzato per indirizzo (ultimi 512 indirizzi), così una richiesta + ripetuta costa solo una lettura in memoria. + *Codice:* `app/api/routes/qr.py:address_qr`, + `app/api/routes/qr.py:_render_png`, + `app/wallet/address.py:is_valid_plm_address` · + *Test:* `tests/unit/test_qr.py` + +### Segnalazioni di bug + +#### POST /bug-reports + +- **[API-28]** Invia una segnalazione, da loggati o no. + - **Autenticazione:** facoltativa; se il chiamante è autenticato la + segnalazione gli viene attribuita. + - **Corpo:** + + | Campo | Tipo | Vincoli | + |---|---|---| + | `description` | stringa | 1–2000 caratteri (lunghezza misurata sul testo ricevuto); spazi iniziali e finali rimossi; non può essere vuota dopo la rimozione | + | `contact` | stringa o `null` | facoltativo, al massimo 256 caratteri; spazi rimossi; una stringa vuota diventa `null` | + + - **Risposta `201`:** `{"id": intero}`. + - **Errori:** `422`. + - **Effetti:** inserisce la segnalazione in stato `open`. Nessun audit, + nessuna notifica SSE, nessun limite di frequenza. + *Codice:* `app/api/routes/bug_reports.py:create_bug_report`, + `app/api/routes/bug_reports.py:BugReportCreate` · + *Test:* `tests/unit/test_bug_reports.py` + +#### GET /bug-reports/mine + +- **[API-29]** Le segnalazioni inviate dall'utente mentre era autenticato, per + seguirne lo stato. Quelle anonime non vi compaiono mai. + - **Autenticazione:** Bearer. + - **Risposta `200`:** lista, dalla più recente, di + `{"id", "description", "status", "created_at"}`; `status` è `open`, + `read` o `resolved`. Il campo `contact` non è restituito. + - **Errori:** `401`. + *Codice:* `app/api/routes/bug_reports.py:list_my_bug_reports` · + *Test:* `tests/unit/test_bug_reports.py` + +### Amministrazione + +Tutti gli endpoint `/admin/*` richiedono `X-Admin-Token` +([API-08](#convenzioni-generali)) e rispondono `403` senza di esso. Gli eventi +di audit citati sono descritti in [audit-log.md](audit-log.md); il significato +dei parametri operativi in +[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) e +[modello-dati.md](modello-dati.md). + +#### GET /admin/config + +- **[API-30]** Legge la configurazione operativa (la riga unica di + `round_config`, creata con i valori predefiniti se manca). + - **Risposta `200`:** `{"fee_address", "bet_amount_sats", + "round_duration_seconds", "round_cooldown_seconds", "fee_rate_sat_vb", + "rbf_timeout_seconds", "draw_animation_seconds", "paused"}`. + *Codice:* `app/api/routes/admin.py:read_config` · + *Test:* `tests/unit/test_admin.py` + +#### PUT /admin/config + +- **[API-31]** Modifica a caldo uno o più parametri operativi. + - **Corpo:** tutti i campi facoltativi; quelli assenti o `null` non + cambiano. + + | Campo | Vincoli | + |---|---| + | `fee_address` | spazi rimossi; bech32 PLM valido (`plm1…`). Non può essere svuotato tramite l'API | + | `bet_amount_sats` | > 0 e ≤ 10 000 000 000 000 (100 000 PLM) | + | `round_duration_seconds` | 30 – 604 800 (7 giorni) | + | `round_cooldown_seconds` | 0 – 86 400 | + | `fee_rate_sat_vb` | 1 – 10 000 (`MAX_FEE_RATE_SAT_VB`) | + | `rbf_timeout_seconds` | 60 – 604 800 | + | `draw_animation_seconds` | 0 – 600 | + + `paused` **non** è accettato qui (è ignorato come campo sconosciuto): la + pausa ha i suoi endpoint registrati nell'audit. + - **Risposta `200`:** la configurazione completa, come + [API-30](#get-adminconfig). + - **Errori:** `422` per qualunque vincolo violato (nessun campo viene + applicato). + - **Effetti:** scrive `config_updated` con i valori prima/dopo dei soli + campi davvero cambiati; nessuna scrittura se nulla cambia. Durata e pausa + tra round si applicano dal round successivo. Nessuna notifica SSE. + *Codice:* `app/api/routes/admin.py:update_config`, + `app/api/routes/admin.py:RoundConfigUpdate` · + *Test:* `tests/unit/test_admin.py` + +#### POST /admin/pause e POST /admin/resume + +- **[API-32]** Attivano e disattivano la pausa di manutenzione: impedisce + l'apertura del **prossimo** round, il round in corso prosegue fino al + pagamento. + - **Corpo:** nessuno. **Risposta `200`:** la configurazione completa. + - **Effetti:** impostano `paused` a vero/falso e scrivono `lottery_paused` / + `lottery_resumed` a **ogni** chiamata, anche se lo stato era già quello. + Nessuna notifica SSE: `/` vede `lottery_paused` al polling successivo. + *Codice:* `app/api/routes/admin.py:pause_lottery`, + `app/api/routes/admin.py:resume_lottery` · + *Test:* `tests/unit/test_admin.py` + +#### GET /admin/users + +- **[API-33]** Elenco di **tutti** gli utenti, per `id` crescente, senza + paginazione né limite. + - **Risposta `200`:** lista di `{"id", "username", "address", + "balance_sats", "created_at"}`; `balance_sats` è il saldo confermato. + *Codice:* `app/api/routes/admin.py:list_users` · + *Test:* `tests/unit/test_admin.py` + +#### GET /admin/users/{user_id}/privkey + +- **[API-34]** Esporta la chiave privata WIF dell'indirizzo di un utente, per + interventi manuali. È una scelta voluta (vedi + [decisioni-e-limiti.md](decisioni-e-limiti.md)). + - **Parametro:** `user_id` intero. + - **Risposta `200`:** `{"address", "wif"}`. + - **Errori:** `404` `"user not found"` (non strutturato); `422` se `user_id` + non è un intero. + - **Effetti:** scrive `admin_privkey_accessed` a ogni accesso. + *Codice:* `app/api/routes/admin.py:user_privkey` · + *Test:* `tests/unit/test_admin.py` + +#### POST /admin/users/{user_id}/reset-password + +- **[API-35]** Imposta una nuova password casuale per un utente bloccato. + - **Risposta `200`:** `{"username", "new_password"}` — la password (16 + caratteri URL-safe) è mostrata **solo** in questa risposta. + - **Errori:** `404` `"user not found"`; `422`. + - **Effetti:** sostituisce l'hash, incrementa `token_version` (ogni sessione + aperta dell'utente riceve `401 session_expired`), scrive + `admin_password_reset`. + *Codice:* `app/api/routes/admin.py:reset_user_password` · + *Test:* `tests/unit/test_admin.py` + +#### GET /admin/rounds + +- **[API-36]** Storico dei round, dal più recente. + - **Query:** `limit` intero 1–500, predefinito 50 (fuori intervallo: `422`). + - **Risposta `200`:** lista di `{"id", "status", "opened_at", "closed_at", + "draw_block_height", "draw_block_hash", "winner_user_id", + "winner_username", "pool_amount_sats", "winner_amount_sats", + "fee_amount_sats", "payout_txid"}`; i campi non ancora valorizzati sono + `null`. + *Codice:* `app/api/routes/admin.py:list_rounds` · + *Test:* `tests/unit/test_admin.py` + +#### GET /admin/audit-log + +- **[API-37]** Registro di audit, dalla voce più recente. + - **Query:** `limit` intero 1–500, predefinito 200. + - **Risposta `200`:** lista di `{"id", "event_type", "payload" (oggetto), + "user_id", "round_id", "created_at"}`. + *Codice:* `app/api/routes/admin.py:list_audit_log` · + *Test:* `tests/unit/test_admin.py` + +#### GET /admin/pending-transactions + +- **[API-38]** Transazioni tracciate (scommesse, pagamenti, prelievi), dalla + più recente. + - **Query:** `limit` intero 1–500, predefinito 50; `status` facoltativo, + confrontato alla lettera (un valore inesistente restituisce una lista + vuota, non un errore). + - **Risposta `200`:** lista di `{"id", "kind", "status", "round_id", + "withdrawal_id", "user_id", "current_txid", "fee_rate_sat_vb", + "attempt_count", "broadcast_at", "replaced_by_txid"}`. Non sono esposti + `failure_reason`, `last_broadcast_at` né la transazione grezza. Stati e + significato in + [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md). + *Codice:* `app/api/routes/admin.py:list_pending_transactions` · + *Test:* `tests/unit/test_admin.py` + +#### GET /admin/bug-reports + +- **[API-39]** Tutte le segnalazioni, dalla più recente. + - **Query:** `limit` intero 1–500, predefinito 200. + - **Risposta `200`:** lista di `{"id", "description", "contact", + "user_id", "username", "status", "created_at"}`; `user_id` e `username` + sono `null` per le segnalazioni anonime. + *Codice:* `app/api/routes/admin.py:list_bug_reports` · + *Test:* `tests/unit/test_bug_reports.py` + +#### POST /admin/bug-reports/{report_id}/status + +- **[API-40]** Cambia lo stato di una segnalazione. + - **Corpo:** `{"status": "open" | "read" | "resolved"}`; qualunque + transizione è ammessa, anche all'indietro. + - **Risposta `200`:** la segnalazione aggiornata, come + [API-39](#get-adminbug-reports). + - **Errori:** `422` (stato sconosciuto); `404` `"bug report not found"`. + - **Effetti:** scrive `bug_report_status_changed` (con stato precedente e + nuovo) solo se lo stato cambia davvero. + *Codice:* `app/api/routes/admin.py:update_bug_report_status` · + *Test:* `tests/unit/test_bug_reports.py` + +### Servizio + +#### GET /health + +- **[API-41]** Risponde sempre `200 {"status": "ok"}` finché il processo + serve richieste. Non verifica né il database né la connessione Electrum: + per lo stato della rete si usa `chain_tip_height` di `GET /rounds/current`. + *Codice:* `app/main.py:health` + +#### Pagine e file statici + +- **[API-42]** `GET /`, `/admin`, `/guida` e `/report-bug` restituiscono + rispettivamente `index.html`, `admin.html`, `guida.html` e + `report-bug.html` di `app/static/`, con `Cache-Control: no-store`, senza + autenticazione (il pannello admin chiede il token lato client). Sono esclusi + dallo schema OpenAPI. + *Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`, + `app/main.py:user_guide`, `app/main.py:report_bug_page` + +- **[API-43]** Ogni altro percorso non gestito da un router è servito dal + mount dei file statici su `/` (`app/static/`, con `html=True`): i file + esistenti rispondono `200`, gli altri `404 {"detail": "Not Found"}`, un + metodo diverso da `GET`/`HEAD` `405`. Il mount è registrato **dopo** tutti i + router, quindi non può oscurare un endpoint dell'API. + *Codice:* `app/main.py` (`app.mount`) + +## Catalogo dei codici di errore + +- **[API-44]** L'insieme dei codici che l'API può restituire nel formato + strutturato è esattamente quello della tabella seguente; un codice nuovo va + aggiunto qui, con la sua chiave `error.` in tutte e sette le lingue di + `app/static/i18n.js`. + *Codice:* `app/api/errors.py:ApiError`, `app/static/i18n.js:TRANSLATIONS` + +| Codice | Stato | Sollevato da | `params` | Significato | `error.` (7 lingue) | +|---|---|---|---|---|---| +| `session_expired` | 401 | `app/auth/dependencies.py:get_current_user` | — | token illeggibile, scaduto, utente inesistente o token superato da un cambio/reset password | sì | +| `invalid_credentials` | 401 | `app/auth/routes.py:login` | — | username inesistente o password sbagliata (risposta identica) | sì | +| `rate_limited` | 429 | `app/auth/routes.py:register`, `app/auth/routes.py:login` | `retry_after_seconds` | quota di registrazione o blocco per tentativi di login falliti | sì | +| `username_taken` | 409 | `app/auth/routes.py:register` | — | nome già usato (senza distinzione di maiuscole) | sì | +| `derivation_index_conflict` | 409 | `app/auth/routes.py:register` | — | nessun indice di derivazione libero dopo 5 tentativi | sì | +| `current_password_incorrect` | 401 | `app/api/routes/users.py:change_password` | — | password attuale errata | sì | +| `password_too_short` | 400 | `app/api/routes/users.py:change_password` | `minimum` | nuova password sotto `MIN_PASSWORD_LENGTH` | sì | +| `network_unavailable` | 503 | `app/api/routes/bets.py:create_bet`, `app/api/routes/withdrawals.py:create_withdrawal` | — | nessuna connessione Electrum attiva | sì | +| `no_round_open` | 400 | `app/bets/service.py:place_bet` | — | nessun round attivo e nessuno apribile (pausa, `fee_address` mancante, pausa tra round in corso) | sì | +| `round_closing` | 400 | `app/bets/service.py:place_bet` | — | il round attivo non accetta più scommesse (scadenza passata o stato diverso da `open`), anche se scoperto dopo la costruzione | sì | +| `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` | `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ì | +| `amount_below_dust_limit` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` | — | al netto della commissione l'importo sarebbe polvere | sì | +| `broadcast_failed` | 502 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal` | — (il motivo del rifiuto è nel `message`) | la rete ha rifiutato la trasmissione; tutto è stato annullato | sì | +| `invalid_address` | 400 | `app/withdrawals/service.py:request_withdrawal`, `app/api/routes/qr.py:address_qr` | — | non è un indirizzo bech32 PLM valido | sì | +| `withdrawal_to_own_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale all'indirizzo di deposito dell'utente | sì | +| `amount_below_minimum` | 400 | `app/withdrawals/service.py:request_withdrawal` | `minimum_sats` | prelievo sotto il costo attuale della scommessa | sì | +| `internal_error` | 500 | `app/main.py:log_unhandled_exception` | — | eccezione non gestita | sì | + +Due codici di `app/wallet/psbt_builder.py` — `winner_share_below_network_fee` +e `commission_below_dust_limit` — riguardano solo la costruzione del +pagamento, che avviene nello scheduler e non attraverso l'API: non arrivano mai +a un client (finiscono nel registro di audit, vedi +[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md)) e non hanno +traduzione. + +Chiavi `error.*` presenti in `i18n.js` che il server **non** produce: `invalid_request` +(usata dal frontend per le risposte `422`, [API-15](#contratto-degli-errori)), +`invalid_amount` (validazione lato client del modulo di prelievo) e +`guide_unavailable` (attualmente non usata da nessun file). diff --git a/docs/architettura.md b/docs/architettura.md new file mode 100644 index 0000000..dfc6029 --- /dev/null +++ b/docs/architettura.md @@ -0,0 +1,798 @@ +# Architettura + +Come è fatto il software dall'interno: quali processi girano, come è +organizzato il codice, cosa succede all'avvio, quali attività lavorano in +background, dove sta la configurazione, come le pagine web restano +aggiornate e come è strutturata la suite di test. + +Questo documento non descrive i flussi di dominio (scommesse, round, +depositi…): per quelli rimanda ai documenti che li possiedono, elencati in +[come-funziona.md](come-funziona.md). Le tabelle del database sono in +[modello-dati.md](modello-dati.md), gli endpoint in [api.md](api.md), le +difese di sicurezza in [sicurezza.md](sicurezza.md). + +## Panoramica del deployment + +```mermaid +flowchart LR + B["Browser
(/, /admin, /report-bug)"] -- "HTTPS 443
(80 → redirect)" --> C["Container caddy
reverse proxy + TLS"] + C -- "HTTP app:8123
(rete interna compose)" --> A["Container app
1 processo uvicorn
FastAPI + 6 task asyncio"] + A -- "aiosqlite" --> D[("./data/db/plm_lottery.db
SQLite, WAL")] + A -- "legge" --> K["./data/keys/master.xprv.enc"] + A -- "scrive" --> L["./data/logs/app.log"] + A -- "Electrum (TCP/SSL)
una connessione attiva" --> E["Server Electrum PLM
primario + riserve"] +``` + +- **[ARC-01]** Il server gira sempre in Docker, con lo stesso + `docker-compose.yml` in sviluppo e in produzione: due container, `app` + (questo codice) e `caddy` (reverse proxy con TLS automatico). Cambia solo + `SITE_ADDRESS`: non impostato vale `localhost` (certificato autofirmato + dalla CA interna di Caddy), impostato a un dominio produce un certificato + Let's Encrypt. Non esiste un modo supportato di lanciare `uvicorn` fuori da + Docker. + *Codice:* `docker-compose.yml` · `Caddyfile` · + *Test:* `tests/unit/test_docs_no_bare_uvicorn.py` +- **[ARC-02]** Il container `app` esegue **un solo processo** uvicorn (nessun + `--workers`), in ascolto su `0.0.0.0:8123`. Tutto lo stato in memoria + descritto in [Assunzioni di processo singolo](#assunzioni-di-processo-singolo) + dipende da questo. + *Codice:* `Dockerfile` (`CMD`) +- **[ARC-03]** All'avvio il container `app`, nell'ordine: rifiuta di partire + se il file indicato da `MASTER_KEY_PATH` non esiste (stampando il comando + per generarlo), poi esegue `alembic upgrade head`, poi avvia uvicorn con + `exec`. Le migrazioni non si lanciano mai a mano. + *Codice:* `Dockerfile` (`CMD`) · `migrations/env.py` +- **[ARC-04]** `app` è solo `expose`d sulla rete interna di compose (porta + 8123), mai pubblicato sull'host: l'unico ingresso è Caddy, che pubblica 80 e + 443. Su questo si basa la lettura dell'IP del client (vedi + [sicurezza.md](sicurezza.md)). Caddy non comprime `/rounds/stream`, perché + la compressione accumulerebbe gli eventi SSE invece di consegnarli subito. + *Codice:* `docker-compose.yml` · `Caddyfile` · + *Test:* `tests/unit/test_caddyfile_security_headers.py` +- **[ARC-05]** I dati persistenti stanno in `./data/` sull'host, montata nel + container: `./data/db` → `/app/db_data`, `./data/keys` → `/app/key_data`, + `./data/logs` → `/app/logs`. Il compose sovrascrive `DATABASE_URL` e + `MASTER_KEY_PATH` perché puntino a quei percorsi; tutte le altre variabili + arrivano da `.env` (`env_file`). Entrambi i container hanno + `restart: unless-stopped`. + *Codice:* `docker-compose.yml` + +### Database + +- **[ARC-06]** Il database è SQLite, usato tramite SQLAlchemy 2 in modalità + asincrona (`create_async_engine` su `aiosqlite`). Una sola `engine` e una + sola fabbrica di sessioni (`AsyncSessionLocal`, con + `expire_on_commit=False`) servono sia le richieste HTTP (dipendenza + `get_session`) sia le attività in background, che aprono ciascuna le + proprie sessioni. + *Codice:* `app/db/base.py` · `app/db/session.py:get_session` +- **[ARC-07]** A ogni nuova connessione SQLite vengono impostati + `journal_mode=WAL`, `synchronous=NORMAL` e `busy_timeout=5000` ms: in WAL + lettori e scrittori non si bloccano a vicenda, e uno scrittore che trova il + database occupato aspetta fino a 5 secondi invece di fallire subito. Con un + dialetto diverso da SQLite le pragma non vengono applicate. + *Codice:* `app/db/base.py:_register_sqlite_pragmas` · + *Test:* `tests/unit/test_db_base.py` +- **[ARC-08]** Lo schema è definito in `app/db/models.py` ed evolve solo + tramite migrazioni Alembic in `migrations/versions/` (generate con + `alembic revision --autogenerate`); `migrations/env.py` prende l'URL del + database da `settings.database_url`. I campi delle tabelle sono descritti + in [modello-dati.md](modello-dati.md). + *Codice:* `migrations/env.py` · `app/db/models.py` + +### Log + +- **[ARC-09]** Tutti i log dell'applicazione e di uvicorn (`uvicorn.error`, + `uvicorn.access`) finiscono in `logs/app.log` (nel container + `/app/logs/app.log`, cioè `./data/logs/app.log` sull'host), a rotazione da + 10 MB con 5 file di storico, livello `INFO`. La configurazione avviene + all'import di `app/main.py`, prima di qualunque altro modulo applicativo. + *Codice:* `app/logging_config.py:setup_logging` + +## Mappa del codice + +Ogni file sotto `app/`, raggruppato per pacchetto. I file `__init__.py` +sono vuoti e non sono elencati. + +### Radice e infrastruttura + +| File | Responsabilità | +|---|---| +| `app/main.py` | Punto d'ingresso: `lifespan` (validazione segreti, avvio e arresto dei sei task), inclusione dei router, gestore degli errori non previsti, pagine HTML, `/health`, mount dei file statici | +| `app/config.py` | `Settings` (variabili d'ambiente / `.env`), istanza `settings`, `validate_runtime_secrets`, `ConfigError` | +| `app/logging_config.py` | `setup_logging`: file di log a rotazione | +| `app/db/base.py` | `engine`, `AsyncSessionLocal`, pragma SQLite, `Base` dichiarativa | +| `app/db/session.py` | `get_session`, dipendenza FastAPI che apre una sessione per richiesta | +| `app/db/models.py` | Tutte le tabelle (`User`, `UtxoEvent`, `Round`, `RoundParticipant`, `RoundConfig`, `PendingTransaction`, `Withdrawal`, `BugReport`, `AuditLog`) e l'indice del round attivo unico — vedi [modello-dati.md](modello-dati.md) | +| `app/audit/log.py` | `write_audit_log`: aggiunge una riga al registro di audit nella sessione del chiamante (il commit resta al chiamante) — catalogo eventi in [audit-log.md](audit-log.md) | + +### API HTTP + +| File | Responsabilità | +|---|---| +| `app/api/errors.py` | `ApiError` (codice + messaggio inglese + parametri), `http_error`, `from_api_error`: il formato strutturato di `detail` — vedi [api.md](api.md) | +| `app/api/client_ip.py` | `client_ip`: IP reale del chiamante (ultimo elemento di `X-Forwarded-For`), usato da tutti i controlli basati sull'IP | +| `app/api/timeutil.py` | `isoformat_utc`: serializza le date come UTC esplicito (SQLite le restituisce senza fuso) | +| `app/api/routes/admin.py` | `/admin/*`: `require_admin` (token condiviso), configurazione, pausa/ripresa, utenti, export WIF, reset password, round, transazioni pendenti, audit log, bug report | +| `app/api/routes/bets.py` | `POST /bets`: controllo connessione, lock per utente, chiamata a `place_bet` | +| `app/api/routes/withdrawals.py` | `POST /withdrawals`: stessa struttura, chiama `request_withdrawal` | +| `app/api/routes/rounds.py` | `GET /rounds/current` e il canale SSE `GET /rounds/stream` | +| `app/api/routes/users.py` | `GET /users/me`, cambio password, `GET /users/me/last-round-result` | +| `app/api/routes/qr.py` | `GET /qr/{address}`: PNG del QR, indirizzo validato, rendering memorizzato e fuori dall'event loop | +| `app/api/routes/bug_reports.py` | `POST /bug-reports`, `GET /bug-reports/mine` | + +### Autenticazione + +| File | Responsabilità | +|---|---| +| `app/auth/routes.py` | `/auth/register`, `/auth/login`; notifica al listener il nuovo indirizzo da osservare | +| `app/auth/security.py` | Argon2 (versioni sincrone e `*_async` su threadpool), creazione e decodifica JWT, `MIN_PASSWORD_LENGTH` | +| `app/auth/dependencies.py` | `get_current_user`, `get_optional_user` (controllo del claim `tv`) | +| `app/auth/rate_limit.py` | `RateLimiter`, `RollingQuota`, `AuthRateLimiters` (tenuti su `app.state`) — vedi [sicurezza.md](sicurezza.md) | + +### Wallet e chiavi + +| File | Responsabilità | +|---|---| +| `app/wallet/plm_network.py` | Parametri della rete PLM per embit (`PLM_MAINNET`), coin type 746, `ACCOUNT_PATH` | +| `app/wallet/keystore.py` | Cifratura/decifratura Fernet della master xprv | +| `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i`) e del pool (`1/0`), export WIF; la chiave d'account decifrata resta in cache nel processo | +| `app/wallet/address.py` | `is_valid_plm_address`: checksum bech32 + HRP `plm` | +| `app/wallet/balance.py` | `recompute_balance` (saldo confermato), `compute_pending_balance` (saldo mostrato) — vedi [registrazione-e-depositi.md](registrazione-e-depositi.md) | +| `app/wallet/psbt_builder.py` | Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti `MAX_TX_INPUTS`, `MAX_PAYOUT_TX_INPUTS`, `MAX_PARTICIPANTS_PER_ROUND`, `DUST_LIMIT_SATS` | + +### Rete Electrum + +| File | Responsabilità | +|---|---| +| `app/electrum/client.py` | `ElectrumClient` (JSON-RPC, timeout per richiesta), `ElectrumEndpoint`, `parse_endpoints` | +| `app/electrum/listener.py` | `ElectrumListener`: l'unica connessione, rotazione, keepalive, validazione header, corroborazione, sottoscrizioni e accredito — vedi [connessione-electrum.md](connessione-electrum.md) | +| `app/electrum/scripthash.py` | `address_to_scripthash`: scripthash Electrum di un indirizzo | + +### Dominio + +| File | Responsabilità | +|---|---| +| `app/deposits/service.py` | Accredito dei depositi, UTXO spesi fuori piattaforma e loro ripristino | +| `app/deposits/reconcile.py` | `DepositReconciler`: ricontrollo periodico di tutti gli indirizzi | +| `app/bets/service.py` | `place_bet`, `BetError`, rilascio di una scommessa fallita | +| `app/bets/confirmation.py` | Gestore di conferma per il tipo `bet` | +| `app/withdrawals/service.py` | `request_withdrawal`, `WithdrawalError`, rilascio di un prelievo fallito | +| `app/withdrawals/confirmation.py` | Gestore di conferma per il tipo `withdrawal` | +| `app/rounds/service.py` | Round attivo, scadenza, accettazione scommesse, `rounds_can_open`, apertura del round, quota del vincitore | +| `app/rounds/scheduler.py` | `RoundScheduler`: chiusura, estrazione, pagamento e suoi tentativi | +| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore | +| `app/rounds/config.py` | `get_round_config`: la riga unica di `round_config`, creata se manca | +| `app/rounds/confirmation.py` | Gestore di conferma per il tipo `payout` | +| `app/rounds/events.py` | `RoundEventBroadcaster` e il singleton `broadcaster` (SSE) | + +Flussi descritti in [scommesse-e-prelievi.md](scommesse-e-prelievi.md), +[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) e +[registrazione-e-depositi.md](registrazione-e-depositi.md). + +### Transazioni + +| File | Responsabilità | +|---|---| +| `app/tx/confirmation.py` | `ConfirmationPoller`, `poll_once`, registro dei gestori (`register_handler`) | +| `app/tx/broadcast.py` | `RbfBumper`, `should_bump`, `bump_fee` | +| `app/tx/reconcile.py` | `PendingTransactionReconciler`, `reconcile_once`: promozione o abbandono delle righe `building`/`pending` | +| `app/tx/pending_address.py` | `own_address_for`: indirizzo proprietario degli input di una transazione (utente o pool) | +| `app/tx/locks.py` | `UserLocks`: lock asincrono per utente | + +Dettagli in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md). + +### Frontend statici + +| File | Responsabilità | +|---|---| +| `app/static/index.html` | Pagina `/` per i giocatori | +| `app/static/app.js` | Logica di `/`: autenticazione, pannelli, polling, SSE, rivelazione del risultato | +| `app/static/style.css` | Stile di `/` (e di `/report-bug`) | +| `app/static/i18n.js` | Tabella `TRANSLATIONS` in 7 lingue, `t()`, `tOrNull()`, `setLanguage()`, `applyStaticTranslations()` | +| `app/static/report-bug.html` | Pagina `/report-bug`, con il proprio script inline | +| `app/static/admin.html` | Pannello `/admin` | +| `app/static/admin.js` | Logica del pannello: token, sezioni, polling dello stato catena, SSE | +| `app/static/admin.css` | Stile del pannello | +| `app/static/guida.html` | Pagina `/guida`, per ora un segnaposto | + +### Dipendenze tra pacchetti + +Il grafo riporta gli import tra i pacchetti di `app/` (a livello di pacchetto, +non di singolo modulo). `db` e `config` sono importati quasi ovunque e sono +omessi dalle frecce per leggibilità. + +```mermaid +flowchart TD + main["main"] --> routes["api.routes"] + main --> auth + main --> tx + main --> rounds + main --> electrum + main --> deposits + main --> bets + main --> withdrawals + + routes --> auth + routes --> bets + routes --> withdrawals + routes --> rounds + routes --> wallet + routes --> audit + routes --> apiutil["api (errors, client_ip, timeutil)"] + auth --> apiutil + auth --> wallet + + bets --> apiutil + bets --> rounds + bets --> wallet + bets --> audit + bets -- "register_handler" --> tx + withdrawals --> apiutil + withdrawals --> rounds + withdrawals --> wallet + withdrawals --> audit + withdrawals -- "register_handler" --> tx + + rounds --> electrum + rounds --> wallet + rounds --> audit + rounds -- "register_handler" --> tx + + electrum --> deposits + electrum -- "draw (header math), events" --> rounds + deposits --> electrum + deposits --> wallet + deposits --> audit + deposits -- "events" --> rounds + + tx --> wallet + tx --> electrum + tx --> audit + tx -- "events, config" --> rounds +``` + +- **[ARC-10]** Il poller delle conferme (`app/tx/confirmation.py`) non importa + i moduli di dominio: sono `app/bets/confirmation.py`, + `app/rounds/confirmation.py` e `app/withdrawals/confirmation.py` a + registrarsi con `register_handler(kind, handler)` al momento dell'import, + per i tipi `bet`, `payout` e `withdrawal`. + *Codice:* `app/tx/confirmation.py:register_handler` · + `app/bets/confirmation.py` · `app/rounds/confirmation.py` · + `app/withdrawals/confirmation.py` · + *Test:* `tests/unit/test_confirmation.py` +- **[ARC-11]** Gli errori di dominio destinati all'utente sono sottoclassi di + `ApiError` (`BetError`, `WithdrawalError`) sollevate direttamente dai + servizi, con il codice che il frontend traduce; le route li convertono in + `HTTPException` scegliendo lo stato HTTP. Per questo `bets` e + `withdrawals` dipendono da `app/api/errors.py`. + *Codice:* `app/api/errors.py:ApiError` · `app/bets/service.py:BetError` · + `app/withdrawals/service.py:WithdrawalError` + +## Avvio del processo + +`app/main.py` fa due cose distinte: **all'import** costruisce l'applicazione +FastAPI; **all'avvio del server** (il `lifespan`) prepara lo stato condiviso +e lancia le attività in background. + +```mermaid +sequenceDiagram + participant U as uvicorn + participant M as app/main.py (import) + participant L as lifespan + U->>M: import app.main + M->>M: setup_logging() + M->>M: import dei gestori di conferma (bet, payout, withdrawal) + M->>M: FastAPI(...), include_router ×8, exception handler, route HTML, mount / + U->>L: startup + L->>L: validate_runtime_secrets() + L->>L: parse_endpoints(...) + L->>L: app.state.electrum_listener, app.state.user_locks + L->>L: create_task ×6 + Note over L: il server serve richieste + U->>L: shutdown + L->>L: cancel() dei 6 task, chiusura del client Electrum +``` + +### All'import + +- **[ARC-12]** `setup_logging()` è chiamata come prima istruzione applicativa + di `app/main.py`, prima di importare qualunque altro modulo `app.*`, così + anche i log emessi durante gli import finiscono nel file. + *Codice:* `app/main.py` · `app/logging_config.py:setup_logging` +- **[ARC-13]** `app/main.py` importa `app.bets.confirmation`, + `app.rounds.confirmation` e `app.withdrawals.confirmation` **solo per il + loro effetto collaterale** (la registrazione del gestore, vedi + [ARC-10]). Questi import non vanno rimossi: senza gestore, il poller + segnerebbe comunque la `PendingTransaction` come `confirmed`, ma senza + aggiornare il partecipante, il round o il prelievo corrispondente. + *Codice:* `app/main.py` · `app/tx/confirmation.py:poll_once` +- **[ARC-14]** La documentazione automatica (`/docs`, `/redoc`, + `/openapi.json`) è disattivata a meno che `ENABLE_API_DOCS` sia vero. + *Codice:* `app/main.py` · *Test:* `tests/unit/test_docs_disabled.py` +- **[ARC-15]** I router vengono inclusi in quest'ordine: `auth`, `users`, + `bets`, `withdrawals`, `admin`, `bug_reports`, `qr`, `rounds`. Gli endpoint + sono in [api.md](api.md). + *Codice:* `app/main.py` +- **[ARC-16]** Qualunque eccezione non gestita produce una risposta 500 con + lo stesso formato degli errori voluti + (`{"detail": {"code": "internal_error", "message": "internal server error", "params": {}}}`); + il dettaglio dell'eccezione va solo nel log, mai nella risposta. + *Codice:* `app/main.py:log_unhandled_exception` · + *Test:* `tests/unit/test_rounds_route.py` +- **[ARC-17]** `GET /health` risponde `{"status": "ok"}` senza toccare + database né rete. + *Codice:* `app/main.py:health` +- **[ARC-18]** Le quattro pagine HTML hanno route esplicite che servono il + file con `Cache-Control: no-store`: `/` → `index.html`, `/admin` → + `admin.html`, `/guida` → `guida.html`, `/report-bug` → `report-bug.html`. + Per ultimo, l'intera cartella `app/static/` è montata su `/` con + `StaticFiles(html=True)`: JS, CSS e ogni altro file vi sono serviti con il + loro nome (`/app.js`, `/i18n.js`, `/admin.css`…). I percorsi sono relativi + alla directory di lavoro, che nel container è `/app`. + *Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` · + `app/main.py:user_guide` · `app/main.py:report_bug_page` + +### Nel lifespan + +- **[ARC-19]** Come primo passo, `validate_runtime_secrets()` fa rifiutare + l'avvio (solleva `ConfigError`) se `JWT_SECRET` è più corto di 32 + caratteri o `XPRV_ENCRYPTION_KEY` è vuota, riportando tutti i problemi + insieme. `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello resta + semplicemente chiuso. Il controllo sta nel lifespan e non in un validatore + di `Settings`, così import e test non hanno bisogno di segreti veri. + *Codice:* `app/config.py:validate_runtime_secrets` · + *Test:* `tests/unit/test_config.py` +- **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST`, + `ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`; un + elenco di riserva malformato blocca l'avvio. L'ordine di rotazione viene + scritto nel log. + *Codice:* `app/electrum/client.py:parse_endpoints` · + *Test:* `tests/unit/test_electrum_client.py` +- **[ARC-21]** Lo stato condiviso con le route vive su `app.state`: + `electrum_listener` (l'`ElectrumListener`) e `user_locks` (un + `UserLocks`), creati nel lifespan; `auth_rate_limiters` viene invece creato + alla prima richiesta di registrazione o login che ne ha bisogno. + *Codice:* `app/main.py:lifespan` · `app/auth/routes.py:_rate_limiters` +- **[ARC-22]** Il lifespan crea esattamente sei task asyncio, nell'ordine + `ElectrumListener`, `RoundScheduler`, `ConfirmationPoller`, `RbfBumper`, + `PendingTransactionReconciler`, `DepositReconciler`. Allo spegnimento + ciascun task riceve `cancel()` (senza attenderne la fine) e il client + Electrum, se presente, viene chiuso. + *Codice:* `app/main.py:lifespan` · *Test:* `tests/unit/test_code_comments.py` + +## Attività in background + +| Task | Costruito con | Cadenza | Ruolo | +|---|---|---|---| +| `ElectrumListener` | `_make_electrum_client`, `AsyncSessionLocal`, endpoint | ciclo di riconnessione; ping ogni 60s | L'unica connessione Electrum: header, sottoscrizioni, accredito depositi | +| `RoundScheduler` | `AsyncSessionLocal`, `listener` | 5s | Apre/chiude i round, estrae, avvia e ritenta i pagamenti | +| `ConfirmationPoller` | `AsyncSessionLocal`, `lambda: listener.client` | 10s | `pending` → `confirmed` e gestore per tipo | +| `RbfBumper` | `AsyncSessionLocal`, `lambda: listener.client` | 30s | Aumenta la commissione di ciò che è `pending` oltre `rbf_timeout_seconds` | +| `PendingTransactionReconciler` | `AsyncSessionLocal`, `lambda: listener.client` | subito, poi 120s | Promuove o abbandona le righe `building`/`pending` | +| `DepositReconciler` | `AsyncSessionLocal`, `listener` | 300s, attende prima del primo giro | `refresh_user` su ogni indirizzo | + +- **[ARC-23]** **Accesso al client Electrum.** Nessun task tiene un + riferimento fisso al client: `ConfirmationPoller`, `RbfBumper` e + `PendingTransactionReconciler` ricevono `lambda: listener.client` e lo + rileggono a ogni giro; `RoundScheduler` e `DepositReconciler` ricevono il + listener intero e leggono `listener.client` (il secondo anche per chiamare + `refresh_user`). Così una riconnessione sostituisce il client sotto tutti. + Un client non nullo implica che l'altezza della catena sia già nota (vedi + [connessione-electrum.md](connessione-electrum.md)). + *Codice:* `app/main.py:lifespan` +- **[ARC-24]** **Client assente.** Quando `listener.client` è `None` ogni task + salta il giro e riprova alla cadenza successiva; `DepositReconciler` + interrompe anche una scansione in corso. Le route che devono trasmettere + (`POST /bets`, `POST /withdrawals`) rispondono invece `503` + `network_unavailable`. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · + `app/tx/confirmation.py:ConfirmationPoller.run` · + `app/tx/broadcast.py:RbfBumper.run` · + `app/tx/reconcile.py:PendingTransactionReconciler.run` · + `app/deposits/reconcile.py:DepositReconciler._sweep_once` · + `app/api/routes/bets.py:create_bet` · + `app/api/routes/withdrawals.py:create_withdrawal` · + *Test:* `tests/unit/test_deposit_reconcile.py` +- **[ARC-25]** **Errori.** Ogni ciclo intercetta qualunque `Exception` di un + giro, la scrive nel log con lo stack e riprende alla cadenza successiva; + solo `CancelledError` (lo spegnimento) esce dal ciclo. All'interno di un + giro, poller, bumper, riconciliatore e `DepositReconciler` isolano anche il + singolo elemento: una transazione o un utente che fallisce non ferma gli + altri. Non esiste un supervisore: un task che terminasse non verrebbe + riavviato (l'unico caso previsto è il listener senza alcun endpoint, che + scrive un errore ed esce; `parse_endpoints` include però sempre il server + primario). + *Codice:* `app/rounds/scheduler.py:RoundScheduler.run` · + `app/tx/confirmation.py:poll_once` · `app/tx/broadcast.py:RbfBumper._tick` · + `app/tx/reconcile.py:reconcile_once` · + `app/deposits/reconcile.py:DepositReconciler.run` · + `app/electrum/listener.py:ElectrumListener.run` · + *Test:* `tests/unit/test_confirmation.py` · `tests/unit/test_reconcile.py` · + `tests/unit/test_deposit_reconcile.py` +- **[ARC-26]** **`ElectrumListener`.** Dopo ogni sessione terminata passa + subito al server successivo; l'attesa crescente (1s raddoppiando fino a 30s) + scatta solo quando tutti i server hanno fallito nello stesso giro. + Rotazione, keepalive, validazione e corroborazione sono in + [connessione-electrum.md](connessione-electrum.md). + *Codice:* `app/electrum/listener.py:ElectrumListener.run` · + *Test:* `tests/unit/test_electrum_listener.py` +- **[ARC-27]** **`RoundScheduler`.** A ogni giro (5s) chiama + `open_new_round_if_needed` e poi avanza il round attivo. L'estrazione + (`_close_and_draw`, con l'attesa del blocco successivo) viene eseguita + **dentro** il giro: finché dura, lo scheduler non fa altro. Dopo un riavvio + un round rimasto in `drawing` non viene ripreso (limite accettato, vedi + [decisioni-e-limiti.md](decisioni-e-limiti.md)). Logica del round in + [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · + `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` +- **[ARC-28]** **`ConfirmationPoller`** (10s), **`RbfBumper`** (30s) e + **`PendingTransactionReconciler`** (al primo giro subito, poi ogni 120s) + lavorano sulla tabella `pending_transactions`; il riconciliatore parte + subito perché un crash durante un invio lascia proprio una riga `building` + da risolvere al riavvio. Regole in + [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md). + *Codice:* `app/tx/confirmation.py:ConfirmationPoller` · + `app/tx/broadcast.py:RbfBumper` · + `app/tx/reconcile.py:PendingTransactionReconciler` · + *Test:* `tests/unit/test_confirmation.py` · `tests/unit/test_broadcast.py` · + `tests/unit/test_reconcile.py` +- **[ARC-29]** **`DepositReconciler`** attende 300s, poi scorre tutti gli + utenti e chiama `listener.refresh_user` su ciascuno: lo stesso codice usato + dalle notifiche, così i due percorsi non possono divergere. + *Codice:* `app/deposits/reconcile.py:DepositReconciler._sweep_once` · + *Test:* `tests/unit/test_deposit_reconcile.py` + +## Configurazione + +La configurazione è divisa in due posti con regole diverse. + +- **[ARC-30]** **Segreti e collegamenti infrastrutturali** stanno nelle + variabili d'ambiente (file `.env`, letto da `Settings`; variabili sconosciute + ignorate). `settings` è costruito una volta all'import di `app/config.py`: + una modifica richiede il riavvio del container. I campi sono: + + | Campo (`Settings`) | Variabile | Predefinito | Uso | + |---|---|---|---| + | `database_url` | `DATABASE_URL` | `sqlite+aiosqlite:///./plm_lottery.db` | URL SQLAlchemy (in Docker sovrascritto dal compose) | + | `electrum_host` | `ELECTRUM_HOST` | `santantonio.sytes.net` | Server Electrum primario | + | `electrum_port` | `ELECTRUM_PORT` | `50002` | Porta del primario | + | `electrum_use_ssl` | `ELECTRUM_USE_SSL` | `True` | TLS verso il primario | + | `electrum_fallback_servers` | `ELECTRUM_FALLBACK_SERVERS` | `""` | Riserve, `host:porta[:notls]` separati da virgola | + | `xprv_encryption_key` | `XPRV_ENCRYPTION_KEY` | `""` | Chiave Fernet della master xprv (obbligatoria, [ARC-19]) | + | `master_key_path` | `MASTER_KEY_PATH` | `./master.xprv.enc` | File della master xprv cifrata (in Docker sovrascritto dal compose) | + | `jwt_secret` | `JWT_SECRET` | `""` | Chiave HS256 dei token (≥ 32 caratteri, [ARC-19]) | + | `jwt_algorithm` | `JWT_ALGORITHM` | `HS256` | Algoritmo JWT | + | `jwt_expire_minutes` | `JWT_EXPIRE_MINUTES` | `1440` (24h) | Durata del token | + | `admin_token` | `ADMIN_TOKEN` | `""` | Token del pannello `/admin`; vuoto = pannello chiuso | + | `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) | + + *Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py` +- **[ARC-31]** **Parametri operativi e di business** (costo della scommessa, + durata e pausa del round, animazione, commissione di rete, timeout RBF, + indirizzo commissioni, pausa di manutenzione) stanno nell'unica riga della + tabella `round_config`, modificabile a caldo da `/admin` senza riavvio. I + valori iniziali sono i default delle colonne di `RoundConfig`, non + `app/config.py`. Se la riga manca, viene creata al primo accesso con + `fee_address` vuoto. Campi in [modello-dati.md](modello-dati.md), effetti + in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md). + *Codice:* `app/rounds/config.py:get_round_config` · + `app/db/models.py:RoundConfig` +- **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili: + la ripartizione 70/30, le conferme richieste (1), i limiti di input e di + partecipanti, le cadenze dei task. Si cambiano solo con una modifica al + codice (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)). + *Codice:* `app/rounds/service.py:winner_share` · + `app/wallet/psbt_builder.py` · `app/tx/confirmation.py` + +## Aggiornamenti in tempo reale (SSE) + +Le pagine si aggiornano da sole in due modi sovrapposti: il polling +periodico e, in più, un canale **Server-Sent Events** che le avvisa appena +qualcosa cambia. + +```mermaid +flowchart LR + subgraph Processo + P1["round aperto / cambio di stato"] --> BR + P2["scommessa o prelievo inviato / annullato"] --> BR + P3["transazione confermata / abbandonata"] --> BR + P4["deposito accreditato / UTXO spesa o ripristinata"] --> BR + P5["nuovo header"] --> BR + BR["broadcaster.publish()"] --> Q1["coda client 1
(maxsize 1)"] + BR --> Q2["coda client N"] + end + Q1 -- "event: update" --> S1["EventSource /"] + Q2 -- "event: update" --> S2["EventSource /admin"] + S1 -. "rifà le GET REST" .-> API["/rounds/current, /users/me, …"] +``` + +- **[ARC-33]** Il canale è pub/sub **in-process**: `broadcaster` è un unico + `RoundEventBroadcaster` per processo, con una `asyncio.Queue(maxsize=1)` + per client. `publish()` mette una notifica in ogni coda non piena; se la + coda è già piena la nuova notifica viene scartata, perché quella in attesa + basta già a far ricaricare tutto (notifiche ridondanti si fondono). + *Codice:* `app/rounds/events.py:RoundEventBroadcaster` · + *Test:* `tests/unit/test_round_events.py` +- **[ARC-34]** Il messaggio non ha contenuto e lo stream non ha + autenticazione: è solo un segnale "è cambiato qualcosa, ricarica". Ogni + dato, compresa la personalizzazione (come `user_played`), arriva dagli + endpoint REST autenticati. Il segnale è globale: ogni client viene avvisato + di ogni evento, anche di quelli che non lo riguardano. + *Codice:* `app/api/routes/rounds.py:round_stream` +- **[ARC-35]** Limiti di sottoscrizione: al massimo `MAX_SUBSCRIBERS` (500) + stream in tutto, oltre i quali `GET /rounds/stream` risponde `503` e il + browser ricade sul polling; e al massimo `MAX_SUBSCRIBERS_PER_IP` (5) per + IP client, oltre i quali la connessione **più vecchia** di quello stesso IP + viene chiusa (le si invia il segnale `EVICTED`) invece di rifiutare la + nuova. L'IP è quello di `client_ip` (vedi [sicurezza.md](sicurezza.md)). + *Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe` · + `app/api/routes/rounds.py:round_stream` · + *Test:* `tests/unit/test_round_events.py` +- **[ARC-36]** Lo stream invia `event: update` con `data: {}` per ogni + notifica. Da fermo controlla `request.is_disconnected()` ogni 5 secondi e + invia un commento `: keep-alive` ogni 20 secondi (4 controlli), così né un + client sparito senza chiudere né il timeout di inattività di un proxy lo + lasciano appeso. La risposta porta `Cache-Control: no-store` e + `X-Accel-Buffering: no`. Alla chiusura il client viene sempre rimosso. + *Codice:* `app/api/routes/rounds.py:round_stream` +- **[ARC-37]** `publish()` viene chiamato in questi punti, e ogni nuovo + cambiamento di stato visibile deve aggiungersi a questo elenco: + - apertura di un round — `app/rounds/service.py:open_new_round_if_needed`; + - ogni transizione di stato del round (`closing`, `closed` senza + partecipanti, `drawing`, `paying_out`) — + `app/rounds/scheduler.py:RoundScheduler._tick`, + `app/rounds/scheduler.py:RoundScheduler._close_and_draw`; + - scommessa inviata e scommessa annullata — + `app/bets/service.py:place_bet`, `app/bets/service.py:_release_failed_bet`; + - prelievo inviato e prelievo annullato — + `app/withdrawals/service.py:request_withdrawal`, + `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`; + - 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`, + `app/deposits/service.py:reinstate_reappeared_utxos`; + - nuovo header ricevuto — `app/electrum/listener.py:ElectrumListener._consume_headers`. + + *Test:* `tests/unit/test_bets.py` (verifica la notifica di una scommessa) + +## Lock per utente + +- **[ARC-38]** `UserLocks` tiene un `asyncio.Lock` per `user_id` (creato alla + prima richiesta di quell'utente e mai rimosso). `POST /bets` e + `POST /withdrawals` eseguono l'intera costruzione e trasmissione dentro + `app.state.user_locks.acquire(user.id)`: per lo stesso utente una scommessa + e un prelievo non sono mai in costruzione insieme, perché spendono dallo + stesso insieme di UTXO. È l'unica esclusione reciproca tra i due flussi; + cosa implica è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md). Il + controllo "client connesso" avviene prima di acquisire il lock. + *Codice:* `app/tx/locks.py:UserLocks.acquire` · + `app/api/routes/bets.py:create_bet` · + `app/api/routes/withdrawals.py:create_withdrawal` + +## Frontend + +- **[ARC-39]** Le interfacce sono pagine statiche in `app/static/`, servite + direttamente da FastAPI ([ARC-18]): HTML, JavaScript e CSS scritti a mano, + senza passo di build, framework né bundler. Parlano tutte con la stessa API + JSON; l'unica separazione tra utente e amministratore è `require_admin` sul + lato server. + *Codice:* `app/main.py` · `app/static/` + +### `/` — giocatori + +- **[ARC-40]** **Polling.** Da non autenticati, la pagina interroga + `GET /rounds/current` ogni 15s solo per la barra di stato della catena. Da + autenticati: `refreshRound` ogni 15s, ogni 3s mentre il round è in + `closing`/`drawing`/`paying_out`; `checkLastRoundResult` + (`GET /users/me/last-round-result`) ogni 20s; il conto alla rovescia + locale ogni secondo. Quando la scheda torna visibile i dati si ricaricano + subito (i timer delle schede in background vengono rallentati dal + browser). + *Codice:* `app/static/app.js:startChainOnlyPolling` · + `app/static/app.js:scheduleNextRoundPoll` · + `app/static/app.js:showDashboard` +- **[ARC-41]** **Richieste.** Ogni chiamata API di `/` ha un timeout di 15s; + dopo 2 fallimenti consecutivi del polling la pagina mostra "connessione + persa". Una risposta `401` con codice `session_expired` riporta al login. + *Codice:* `app/static/app.js:call` · `app/static/app.js:noteFetchOutcome` +- **[ARC-42]** **SSE.** La pagina apre un solo `EventSource('/rounds/stream')` + e reagisce sia agli eventi `update` sia a `open` (che scatta alla prima + connessione e a ogni riconnessione automatica): da autenticati ricarica + round, profilo e ultimo risultato, altrimenti solo lo stato della catena. + Il polling resta attivo comunque: se lo stream cade o è bloccato la pagina + funziona come prima, solo senza l'avviso immediato. + *Codice:* `app/static/app.js:connectRoundEvents` · + `app/static/app.js:onRoundServerEvent` +- **[ARC-43]** **Chiavi di `localStorage`** usate da `/`: + + | Chiave | Contenuto | + |---|---| + | `plm_token` | JWT della sessione | + | `plm_username` | Nome utente mostrato | + | `plm_address` | Indirizzo di deposito | + | `plm_persisted_result` | Risultato del round rivelato (`round_id`, `won`, `amount_sats`), per ridisegnarlo dopo un ricaricamento | + | `plm_lang` | Lingua scelta (condivisa con `/report-bug`) | + + Il logout svuota `localStorage` tranne `plm_lang`. La rimozione di + `plm_token` in una scheda riporta al login anche le altre schede aperte + (evento `storage`). La logica della rivelazione del risultato è in + [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md). + *Codice:* `app/static/app.js:logout` · `app/static/app.js:persistResult` · + `app/static/i18n.js:setLanguage` + +### `/admin` — operatore + +- **[ARC-44]** Il pannello chiede il token operatore, lo verifica con + `GET /admin/config` e lo conserva in `sessionStorage` (`plm_admin_token`, + quindi si perde chiudendo la scheda); ogni chiamata lo invia + nell'intestazione `X-Admin-Token`. Non è collegato da `/` né lo collega. + *Codice:* `app/static/admin.js:callAdmin` +- **[ARC-45]** Il pannello interroga `GET /rounds/current` ogni 15s per la + barra di stato (stato del round e altezza della catena). Le sezioni Utenti, + Round, Transazioni pendenti, Audit log e Bug report si caricano quando le + si apre e si ricaricano a ogni evento SSE (`update` o `open`) mentre sono + aperte; non hanno un polling periodico proprio. La sezione Parametri non + viene ricaricata dagli eventi. + *Codice:* `app/static/admin.js:startChainStatusPolling` · + `app/static/admin.js:connectAdminEvents` · + `app/static/admin.js:onAdminServerEvent` + +### `/report-bug` e `/guida` + +- **[ARC-46]** `/report-bug` è una pagina autonoma, raggiungibile con o senza + login: se in `localStorage` c'è `plm_token` lo usa per allegare l'autore e + per mostrare i propri report (`GET /bug-reports/mine`). Non usa SSE né + polling. Il flusso di triage è descritto in [api.md](api.md) e in + [guida-admin.md](guida-admin.md). + *Codice:* `app/static/report-bug.html` +- **[ARC-47]** `/guida` è un segnaposto statico in italiano ("la guida + completa sarà pubblicata qui a breve"). + *Codice:* `app/static/guida.html` + +## Internazionalizzazione + +Riguarda `/` e `/report-bug`. `/admin` e `/guida` sono volutamente solo in +italiano. + +- **[ARC-48]** Le lingue supportate sono sette: `en`, `it`, `es`, `fr`, `de`, + `ru`, `zh`. Tutte le stringhe stanno in un'unica tabella `TRANSLATIONS` in + `app/static/i18n.js`, caricato prima dello script della pagina; non ci sono + file separati né richieste di rete. + *Codice:* `app/static/i18n.js` +- **[ARC-49]** La lingua iniziale è, nell'ordine: il valore di + `localStorage.plm_lang` se supportato, le prime due lettere di + `navigator.language` se supportate, altrimenti `en`. `setLanguage` salva la + scelta in `plm_lang`, ritraduce la pagina e chiama `onLanguageChange` se la + pagina la definisce. Su `/` il selettore sta nella barra di stato della + catena, non nella barra di navigazione, perché quest'ultima è nascosta fino + al login. + *Codice:* `app/static/i18n.js:detectDefaultLang` · + `app/static/i18n.js:setLanguage` · `app/static/index.html` +- **[ARC-50]** Il markup statico si traduce con attributi: `data-i18n` + (testo), `data-i18n-html`, `data-i18n-placeholder`, `data-i18n-title`, + `data-i18n-aria-label`, `data-i18n-alt`, applicati da + `applyStaticTranslations` al caricamento e a ogni cambio di lingua. Ciò che + nasce da dati del server usa `t(chiave, parametri)` e viene ridisegnato da + `onLanguageChange`. Un elemento usa **uno solo** dei due meccanismi, mai + entrambi (per esempio `#bet-btn` non ha `data-i18n`, perché la sua + etichetta contiene il costo configurabile). + *Codice:* `app/static/i18n.js:applyStaticTranslations` · + `app/static/app.js:onLanguageChange` · + `app/static/app.js:renderBetButton` +- **[ARC-51]** Ogni lingua ha **esattamente lo stesso insieme di chiavi**. + `t()` cerca la chiave nella lingua corrente, poi in `en`, e se manca anche + lì restituisce la chiave stessa; `tOrNull()` restituisce invece `null`, + così il chiamante può usare un testo di riserva. I parametri si + sostituiscono nei segnaposto `{nome}`. + *Codice:* `app/static/i18n.js:t` · `app/static/i18n.js:tOrNull` +- **[ARC-52]** L'API risponde in una sola lingua (inglese) con un `detail` + strutturato `{code, message, params}` (vedi [api.md](api.md)); la + traduzione spetta al client. Per un `detail` oggetto il frontend mostra + `error.` nella lingua corrente, con i parametri; a ogni parametro + `*_sats` numerico affianca un `*_plm` già formattato; se il codice non ha + traduzione mostra `message`. Un `detail` stringa viene mostrato così com'è; + un `detail` lista (errore di validazione 422 di FastAPI) diventa + `error.invalid_request`. Aggiungere un errore rivolto all'utente significa + dargli un codice e aggiungere `error.` in tutte e sette le lingue. + *Codice:* `app/static/app.js:apiErrorMessage` · + `app/static/app.js:errorParams` · `app/api/errors.py:ApiError` +- **[ARC-53]** Su `/report-bug` l'avviso `bugReport.englishNotice` è tradotto + in tutte le lingue, ma dice sempre di scrivere la segnalazione in inglese, + perché il pannello di triage non è tradotto. + *Codice:* `app/static/report-bug.html` · `app/static/i18n.js` + +## Test + +- **[ARC-54]** La suite è tutta in `tests/unit/` (351 test raccolti); + `tests/integration/` contiene solo `__init__.py`. Si esegue con + `python -m pytest` dal venv. `pyproject.toml` imposta `testpaths = ["tests"]` + e `asyncio_mode = "auto"`, quindi i test asincroni non richiedono + `@pytest.mark.asyncio`. Dipendenze di sviluppo: `pytest`, `pytest-asyncio`, + `httpx`. + *Codice:* `pyproject.toml` · *Test:* `tests/unit/test_docs_current.py` +- **[ARC-55]** Non esiste un `conftest.py`: ogni file definisce le proprie + fixture. Lo schema tipico di una fixture `session_factory` è: master key + generata in una directory temporanea con una chiave Fernet casuale (e + cache `hd._account_key` azzerata), engine SQLite in memoria con + `Base.metadata.create_all` (le migrazioni Alembic non vengono eseguite), + una riga `RoundConfig` con un `fee_address` valido dove serve aprire round. + I test HTTP costruiscono un'app FastAPI minima con i soli router + necessari, un `ElectrumListener` senza connessione su `app.state`, e la + interrogano con `httpx.AsyncClient` su `ASGITransport`: il `lifespan` di + `app/main.py` non viene eseguito. + *Test:* `tests/unit/test_bets.py` · `tests/unit/test_rounds_route.py` +- **[ARC-56]** La rete non viene mai contattata: al posto del client e del + listener Electrum ogni file usa classi finte locali, ad esempio + `FakeElectrumClient` (`test_bets.py`, `test_withdrawals.py`, + `test_balance.py`), `RejectingElectrumClient` (`test_bets.py`), + `FakeClient` e `ExplodingClient` (`test_confirmation.py`), `FakeClient` + (`test_broadcast.py`), `UnknownTxClient`, `KnownTxClient`, `BrokenClient` + (`test_reconcile.py`), `FakeListener`, `FakePayoutClient`, + `FakePayoutListener`, `CorroboratingListener`, `LateTipListener`, + `StallingListener` (`test_scheduler.py`), `FakeListener` + (`test_deposit_reconcile.py`), `FakeWriter` (`test_electrum_client.py`). + *Test:* i file citati +- **[ARC-57]** Alcuni test verificano file che non sono codice Python, + perché nessun altro test li eserciterebbe: + - `test_docs_current.py`: il numero di test dichiarato in `CLAUDE.md` e + `README.md` coincide con quello raccolto; `CLAUDE.md` non contiene + affermazioni superate note; la mappa del codice di `CLAUDE.md` cita + `rate_limit.py`, `client_ip.py` e `bug_reports`; i link relativi del + README e le ancore verso `CLAUDE.md` esistono; + - `test_docs_no_bare_uvicorn.py`: README e `docs/running-the-server.md` + non documentano un `uvicorn --reload` fuori da Docker; + - `test_code_comments.py`: alcuni commenti nel codice restano veri (per + esempio il numero di task avviati dal lifespan, sei, citato in + `app/db/base.py`); + - `test_caddyfile_security_headers.py`: il `Caddyfile` mantiene le + intestazioni di sicurezza e la sovrascrittura di `X-Forwarded-For`; + - `test_withdrawals.py::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive`: + [scommesse-e-prelievi.md](scommesse-e-prelievi.md) descrive correttamente + la convivenza di scommessa e prelievo. + + Chi modifica questi file (compresi i documenti in `docs/`) deve tenerne + conto. + *Test:* `tests/unit/test_docs_current.py` · + `tests/unit/test_docs_no_bare_uvicorn.py` · + `tests/unit/test_code_comments.py` · + `tests/unit/test_caddyfile_security_headers.py` · + `tests/unit/test_withdrawals.py` +- **[ARC-58]** Ciò che la suite **non** copre: nessun test contro una + connessione Electrum reale; il `lifespan` e l'avvio dei sei task; la route + SSE `round_stream` (è testato solo il broadcaster); `UserLocks` e la sua + acquisizione nelle route di scommessa e prelievo; il codice JavaScript dei + frontend; la parità delle chiavi tra le lingue di `i18n.js`. Le verifiche + dal vivo sono state manuali (`scripts/electrum_smoke_test.py` e transazioni + reali su mainnet; prelievo e RBF mai trasmessi dal vivo, vedi + [decisioni-e-limiti.md](decisioni-e-limiti.md)). + +## Assunzioni di processo singolo + +- **[ARC-59]** Diversi componenti tengono stato nella memoria del processo e + funzionano correttamente solo con un unico processo uvicorn ([ARC-02]): + - il canale SSE (`broadcaster`): un evento pubblicato in un processo non + raggiungerebbe i client collegati a un altro; + - i lock per utente (`UserLocks`): due processi potrebbero costruire + insieme una scommessa e un prelievo dello stesso utente; + - i limitatori di login e registrazione (`AuthRateLimiters` su + `app.state`): ogni processo conterebbe separatamente; + - l'`ElectrumListener` e i sei task: ogni processo aprirebbe la propria + connessione e il proprio scheduler, con due scheduler che avanzano lo + stesso round; + - le cache locali (chiave d'account decifrata in `app/wallet/hd.py`, QR + memorizzati in `app/api/routes/qr.py`), che sarebbero solo duplicate. + + Un deployment multi-processo richiederebbe un canale condiviso (per + esempio Redis pub/sub) e un lock su database o Redis. Fa eccezione + l'invariante "al massimo un round attivo", garantita da un indice del + database e quindi valida anche con più processi (vedi + [modello-dati.md](modello-dati.md)). La scelta è discussa in + [decisioni-e-limiti.md](decisioni-e-limiti.md). + *Codice:* `app/rounds/events.py:RoundEventBroadcaster` · + `app/tx/locks.py:UserLocks` · `app/auth/rate_limit.py:AuthRateLimiters` · + `app/main.py:lifespan` diff --git a/docs/audit-log.md b/docs/audit-log.md new file mode 100644 index 0000000..6f03b8f --- /dev/null +++ b/docs/audit-log.md @@ -0,0 +1,443 @@ +# Registro di audit + +Il registro di audit è la traccia, nel database, di ciò che ha mosso denaro o +cambiato la configurazione: scommesse, prelievi, depositi accreditati, +estrazioni, pagamenti, interventi dell'operatore. È il primo posto da guardare +per ricostruire cosa è successo dopo un problema, ed è l'unica forma di +responsabilità che esiste per le azioni dal pannello admin, che non ha +un'identità per singolo operatore (vedi [sicurezza.md](sicurezza.md)). + +Questo documento descrive come il registro viene scritto e letto, e cataloga +ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in +[modello-dati.md](modello-dati.md); le procedure che partono da un evento +(per esempio un `payout_failed`) sono in [operativita.md](operativita.md). + +## Come si scrive + +- **[AUD-01]** Ogni riga di audit nasce da un'unica funzione, + `write_audit_log(session, event_type, payload, user_id=None, round_id=None)`. + La funzione **non fa commit**: aggiunge la riga alla sessione del chiamante, + che la salva insieme al resto delle sue modifiche. Nessun altro punto del + codice crea righe `AuditLog`. + *Codice:* `app/audit/log.py:write_audit_log` + +- **[AUD-02]** Per gli eventi che accompagnano un cambiamento di stato, la riga + di audit è salvata **nella stessa transazione** del cambiamento che descrive: + o si salvano entrambi, o nessuno dei due. Vale per tutti gli eventi del + catalogo tranne quelli elencati in [AUD-03]. + *Codice:* i chiamanti elencati nel catalogo, ognuno dei quali chiama + `write_audit_log` e poi `session.commit()` sulla stessa sessione. + +- **[AUD-03]** Quattro eventi sono **solo osservativi**: non accompagnano un + cambiamento di stato e vengono salvati in una transazione propria, aperta + apposta. Sono `draw_baseline_tip_unknown`, `draw_header_corroboration_failed`, + `draw_stalled` e `payout_failed`. Per `payout_failed` la scrittura è anche + *best effort*: se fallisce, l'errore viene registrato nel log applicativo + (`could not record the payout failure of round …`) e ignorato. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`, + `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip`, + `app/rounds/scheduler.py:RoundScheduler._log_payout_failure` + +- **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna + `payload_json`; `created_at` è impostato dal database all'inserimento, in + UTC. `user_id` e `round_id` sono chiavi esterne opzionali, valorizzate come + indicato evento per evento nel catalogo. + *Codice:* `app/audit/log.py:write_audit_log`, `app/db/models.py:AuditLog` + +- **[AUD-05]** Il registro è **in sola aggiunta**: nessuna parte del codice + modifica o cancella una riga di `audit_log`, e non c'è alcuna rotazione o + scadenza. La tabella cresce per sempre. + *Codice:* `app/audit/log.py:write_audit_log` (unico punto di scrittura) + +- **[AUD-06]** `event_type` è una colonna `String(32)`. Il nome più lungo oggi + in uso, `draw_header_corroboration_failed`, occupa esattamente 32 caratteri: + un nuovo tipo di evento deve restare entro questo limite. SQLite non lo fa + rispettare, un altro database lo farebbe. + *Codice:* `app/db/models.py:AuditLog` + +## Come si legge + +- **[AUD-07]** `GET /admin/audit-log` (protetto da `X-Admin-Token`) restituisce + le righe dalla più recente (ordine per `id` decrescente), con il parametro + `limit` (predefinito 200, minimo 1, massimo 500). Ogni elemento contiene + `id`, `event_type`, `payload` (già decodificato da JSON), `user_id`, + `round_id` e `created_at` in ISO 8601 UTC. **Non esistono filtri** per tipo + di evento, utente o round, né paginazione: le righe più vecchie delle ultime + 500 si leggono solo direttamente dal database. + *Codice:* `app/api/routes/admin.py:list_audit_log` · + *Test:* `tests/unit/test_admin.py` + +- **[AUD-08]** La sezione "Audit log" di `/admin` chiama l'endpoint senza + parametri (quindi le ultime 200 righe) al login e ogni volta che si apre la + sezione, e mostra id, tipo, payload JSON grezzo, utente, round e data. Non ha + filtri né ricerca. + *Codice:* `app/static/admin.js:loadAuditLog` + +- **[AUD-09]** Una sola logica del programma rilegge il registro: + `_retry_payout_if_due` usa la riga `payout_failed` più recente del round come + orologio per decidere se ritentare il pagamento. Se è più recente di 60 + secondi (`_PAYOUT_RETRY_INTERVAL_SECONDS`) il tentativo viene saltato; se non + esiste, il tentativo parte subito. Nessun'altra decisione dipende dal + registro. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` · + *Test:* `tests/unit/test_scheduler.py` + +## Catalogo degli eventi + +Legenda delle colonne `user_id` / `round_id`: **sì** = sempre valorizzato, +**no** = sempre vuoto, **dipende** = come spiegato nel testo. Gli importi sono +sempre in satoshi. + +```mermaid +flowchart LR + subgraph Operatore["Pannello admin"] + config_updated + lottery_paused + lottery_resumed + admin_privkey_accessed + admin_password_reset + bug_report_status_changed + end + subgraph Fondi["Denaro degli utenti"] + deposit_credited + utxo_spent_externally + utxo_external_spend_reinstated + bet_placed + bet_broadcast_failed + withdrawal_sent + withdrawal_broadcast_failed + end + subgraph Round["Round"] + round_closed + winner_drawn + draw_baseline_tip_unknown + draw_header_corroboration_failed + draw_stalled + payout_failed + payout_sent + end + subgraph Ricon["Riconciliazione"] + pending_tx_recovered + pending_tx_abandoned + end +``` + +### Pannello admin + +- **[AUD-10]** `config_updated` — un `PUT /admin/config` ha cambiato almeno un + parametro. Payload: un oggetto con una chiave per ogni campo **effettivamente + cambiato**, ciascuna `{"from": , "to": }` + (i campi sono quelli di `_CONFIG_FIELDS`: `fee_address`, `bet_amount_sats`, + `round_duration_seconds`, `round_cooldown_seconds`, `fee_rate_sat_vb`, + `rbf_timeout_seconds`, `draw_animation_seconds`). Un campo inviato con lo + stesso valore che aveva non compare; se nulla cambia, non si scrive nessuna + riga. `user_id` no, `round_id` no. Stessa transazione della modifica. + *Codice:* `app/api/routes/admin.py:update_config` · + *Test:* `tests/unit/test_admin.py` + +- **[AUD-11]** `lottery_paused` — `POST /admin/pause`. Payload vuoto `{}`. + `user_id` no, `round_id` no. Viene scritto a ogni chiamata, anche se la + lotteria era già in pausa. Stessa transazione di `paused = true`. + *Codice:* `app/api/routes/admin.py:pause_lottery` · + *Test:* nessun test verifica la riga di audit. + +- **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload vuoto `{}`. + `user_id` no, `round_id` no. Scritto a ogni chiamata, anche se la lotteria + non era in pausa. Stessa transazione di `paused = false`. + *Codice:* `app/api/routes/admin.py:resume_lottery` · + *Test:* nessun test verifica la riga di audit. + +- **[AUD-13]** `admin_privkey_accessed` — `GET /admin/users/{id}/privkey` ha + esportato la chiave privata (WIF) di un utente. Payload `{"user_id"}`. + `user_id` sì (l'utente la cui chiave è stata esportata), `round_id` no. La + riga è salvata **prima** che la risposta con la chiave venga restituita: se + il salvataggio fallisce, la chiave non esce. Per un utente inesistente + (404) non si scrive nulla. Questa registrazione non va mai rimossa (vedi + [decisioni-e-limiti.md](decisioni-e-limiti.md)). + *Codice:* `app/api/routes/admin.py:user_privkey` · + *Test:* nessun test verifica la riga di audit (`tests/unit/test_admin.py` + copre solo l'esportazione). + +- **[AUD-14]** `admin_password_reset` — `POST /admin/users/{id}/reset-password` + ha impostato una nuova password casuale. Payload `{"user_id"}`; la nuova + password **non** compare. `user_id` sì, `round_id` no. Stessa transazione del + nuovo hash e dell'incremento di `token_version` (che invalida le sessioni + esistenti). + *Codice:* `app/api/routes/admin.py:reset_user_password` · + *Test:* nessun test verifica la riga di audit. + +- **[AUD-15]** `bug_report_status_changed` — `POST /admin/bug-reports/{id}/status` + ha cambiato lo stato di una segnalazione. Payload + `{"report_id", "from", "to"}` con gli stati `open`/`read`/`resolved`. + `user_id` dipende: è l'autore della segnalazione, vuoto se era anonima; + `round_id` no. Se lo stato richiesto è quello già presente non si scrive + nulla. Stessa transazione del cambio di stato. + *Codice:* `app/api/routes/admin.py:update_bug_report_status` · + *Test:* `tests/unit/test_bug_reports.py` + +### Denaro degli utenti + +- **[AUD-16]** `deposit_credited` — un nuovo UTXO confermato sull'indirizzo di + un utente è stato accreditato, dopo la corroborazione degli altri server + (vedi [registrazione-e-depositi.md](registrazione-e-depositi.md)). Una riga + per UTXO. Payload `{"txid", "vout", "amount_sats"}`. `user_id` sì, + `round_id` no. Stessa transazione dell'inserimento in `utxo_events` e del + ricalcolo del saldo. Attenzione: non riguarda solo i depositi dall'esterno. + Anche **le vincite** (l'uscita del pagamento verso il vincitore) e **i resti** + di scommesse e prelievi arrivano all'indirizzo dell'utente e vengono + accreditati per la stessa strada, quindi producono anche loro un + `deposit_credited`. + *Codice:* `app/deposits/service.py:credit_confirmed_utxos` · + *Test:* nessun test verifica la riga di audit. + +- **[AUD-17]** `utxo_spent_externally` — un UTXO che il database considerava + non speso non è più tra quelli non spesi, secondo il server attivo e la + maggioranza degli altri: è stato speso fuori dalla piattaforma (per esempio + con la chiave esportata). Una riga per UTXO. Payload + `{"txid", "vout", "amount_sats"}`. `user_id` sì, `round_id` no. Stessa + transazione della marcatura (`spent_txid = "external-spend"`) e del ricalcolo + del saldo. + *Codice:* `app/deposits/service.py:mark_utxos_spent_externally` · + *Test:* `tests/unit/test_deposits.py` + +- **[AUD-18]** `utxo_external_spend_reinstated` — un UTXO segnato come speso + all'esterno è ricomparso tra i non spesi e la marcatura è stata annullata. + Payload `{"txid", "vout", "amount_sats"}`. `user_id` sì, `round_id` no. + Stessa transazione dell'annullamento e del ricalcolo del saldo. + *Codice:* `app/deposits/service.py:reinstate_reappeared_utxos` · + *Test:* `tests/unit/test_deposits.py` + +- **[AUD-19]** `bet_placed` — la rete ha accettato la transazione di una + scommessa. Payload `{"txid", "amount_sats"}`: `txid` è quello del primo + invio (un successivo RBF non aggiorna la riga), `amount_sats` è l'importo + che arriva al montepremi, cioè la puntata **al netto** della commissione di + rete. `user_id` sì, `round_id` sì. Stessa transazione che porta il + partecipante a `broadcast` e la transazione pendente a `pending`. + *Codice:* `app/bets/service.py:place_bet` · + *Test:* `tests/unit/test_bets.py` + +- **[AUD-20]** `bet_broadcast_failed` — la rete ha rifiutato la transazione di + una scommessa; gli UTXO sono stati liberati e il partecipante rimosso. + Payload `{"txid", "reason"}`, con `reason` il testo dell'errore troncato a + 200 caratteri. `user_id` sì, `round_id` **no** (a differenza di + `bet_placed`). Stessa transazione del ripristino. + *Codice:* `app/bets/service.py:_release_failed_bet` · + *Test:* `tests/unit/test_bets.py` + +- **[AUD-21]** `withdrawal_sent` — la rete ha accettato la transazione di un + prelievo. Payload `{"txid", "amount_sent_sats", "external_address"}`: + `amount_sent_sats` è quanto arriva davvero all'indirizzo esterno (al netto + della commissione e dell'eventuale limite di polvere, vedi + [scommesse-e-prelievi.md](scommesse-e-prelievi.md)). L'id del prelievo non è + nel payload. `user_id` sì, `round_id` no. Stessa transazione che porta il + prelievo a `broadcast`. + *Codice:* `app/withdrawals/service.py:request_withdrawal` · + *Test:* nessun test verifica la riga di audit. + +- **[AUD-22]** `withdrawal_broadcast_failed` — la rete ha rifiutato la + transazione di un prelievo; gli UTXO sono stati liberati e il prelievo + segnato `failed`. Payload `{"txid", "reason"}` (`reason` troncato a 200 + caratteri). `user_id` sì, `round_id` no. Stessa transazione del ripristino. + *Codice:* `app/withdrawals/service.py:_release_failed_withdrawal` · + *Test:* nessun test verifica la riga di audit. + +### Round + +- **[AUD-23]** `round_closed` — un round è stato chiuso **senza partecipanti + confermati**. Payload `{"participants": 0}`. `user_id` no, `round_id` sì. + Stessa transazione del passaggio a `closed`. È l'unico caso in cui la + chiusura di un round viene registrata: un round chiuso dopo la conferma del + pagamento non produce alcuna riga (vedi [AUD-32]). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` + +- **[AUD-24]** `winner_drawn` — il vincitore è stato estratto. Payload + `{"winner_address", "pool_amount_sats", "block_height", "block_hash", + "participants"}`: indirizzo del vincitore, montepremi totale (somma delle + puntate nette), altezza e hash del blocco usato come seme, numero di + partecipanti. `user_id` sì (il vincitore), `round_id` sì. Stessa transazione + che salva i dati dell'estrazione sul round e lo porta a `paying_out`. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` + +- **[AUD-25]** `draw_baseline_tip_unknown` — al momento della chiusura + l'altezza della catena non era nota, quindi il primo blocco ricevuto è stato + preso come base e l'estrazione aspetta il blocco successivo. Payload + `{"baseline_height"}`. `user_id` no, `round_id` sì. Transazione propria + ([AUD-03]). Dovrebbe essere irraggiungibile (lo scheduler non lavora senza + un'altezza nota), vedi [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` · + *Test:* `tests/unit/test_scheduler.py` + +- **[AUD-26]** `draw_header_corroboration_failed` — il blocco candidato per + l'estrazione non è stato confermato dalla maggioranza degli altri server + Electrum (o nessuno ha risposto); l'estrazione aspetta un blocco ulteriore. + Una riga per ogni blocco candidato rifiutato. Payload + `{"height", "reported_hash"}`. `user_id` no, `round_id` sì. Transazione + propria ([AUD-03]). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` · + *Test:* `tests/unit/test_scheduler.py` + +- **[AUD-27]** `draw_stalled` — l'estrazione aspetta un nuovo blocco da più di + 360 secondi (`_DRAW_STALL_THRESHOLD_SECONDS`), contati da + `drawing_started_at`; la riga si ripete ogni 360 secondi finché l'attesa + continua. Payload `{"tip_at_close", "current_tip_height", + "elapsed_seconds"}`. Nota: `tip_at_close` è la base *attuale* dell'attesa, che + dopo un `draw_header_corroboration_failed` diventa l'altezza del candidato + rifiutato, non più quella della chiusura. `user_id` no, `round_id` sì. + Transazione propria ([AUD-03]). Non viene scritta mentre è in corso una + corroborazione, solo mentre si aspetta un blocco. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` · + *Test:* `tests/unit/test_scheduler.py` + +- **[AUD-28]** `payout_failed` — un tentativo di pagamento non è partito. + Payload `{"round_id", "reason"}`. `user_id` dipende: il vincitore, tranne + quando il motivo è l'assenza di connessione (vuoto); `round_id` sì. + Transazione propria e *best effort* ([AUD-03]); è l'orologio del nuovo + tentativo ([AUD-09]). Valori possibili di `reason`: + + | `reason` | Causa | + |---|---| + | `electrum client not connected` | Nessuna connessione Electrum al momento del tentativo | + | `no fee_address configured` | `fee_address` vuoto nella configurazione | + | `winner user not found` | L'utente vincitore non esiste più nel database | + | `insufficient pool UTXOs` | Gli UTXO confermati e non riservati dell'indirizzo del montepremi non coprono l'importo | + | `too_many_inputs` | Servirebbero più di `MAX_PAYOUT_TX_INPUTS` (500) input | + | `winner_share_below_network_fee` | La quota del vincitore non copre la commissione di rete | + | `commission_below_dust_limit` | La quota della piattaforma è sotto il limite di polvere | + | `payout build failed` | Qualunque altro errore nella costruzione (per esempio un `fee_address` malformato) | + | `broadcast rejected` | La rete ha rifiutato la transazione; la riga `building` resta al riconciliatore | + + Un tentativo saltato perché esiste già un pagamento `building` o `pending` + per il round **non** scrive `payout_failed`. Cosa fare per ogni motivo è + descritto in [operativita.md](operativita.md). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, + `app/rounds/scheduler.py:RoundScheduler._log_payout_failure` · + *Test:* `tests/unit/test_scheduler.py` + +- **[AUD-29]** `payout_sent` — la rete ha accettato la transazione di + pagamento. Payload `{"txid", "winner_sats", "commission_sats"}`: quanto + riceve il vincitore (già al netto della commissione di rete) e quanto va a + `fee_address`. `user_id` sì (il vincitore), `round_id` sì. Stessa transazione + che porta la transazione pendente da `building` a `pending`. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · + *Test:* `tests/unit/test_scheduler.py` + +### Riconciliazione + +- **[AUD-30]** `pending_tx_recovered` — una transazione rimasta `building` + (tipicamente per un riavvio a metà invio) risulta invece presente in catena + ed è stata promossa a `pending`. Payload + `{"pending_transaction_id", "kind", "txid"}`. `user_id` e `round_id` + dipendono dal tipo: sono quelli della riga `pending_transactions` (per un + pagamento `user_id` è vuoto, per un prelievo `round_id` è vuoto). Stessa + transazione della promozione. Vedi + [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md). + *Codice:* `app/tx/reconcile.py:_promote` · + *Test:* nessun test verifica la riga di audit. + +- **[AUD-31]** `pending_tx_abandoned` — una transazione `building` o `pending` + non compare nella cronologia del proprio indirizzo ed è stata dichiarata + persa: stato `failed`, UTXO liberati, partecipante rimosso o prelievo + `failed`, `payout_txid` del round azzerato. Payload + `{"pending_transaction_id", "kind", "txid", "reason", "utxos_released"}`, con + `reason` oggi sempre `"not found on chain"` e `utxos_released` il numero di + UTXO liberati (sempre 0 per un pagamento, perché l'indirizzo del montepremi + non è tracciato in `utxo_events`). `user_id` e `round_id` come in [AUD-30]. + Stessa transazione dell'abbandono. + *Codice:* `app/tx/reconcile.py:_abandon` · + *Test:* `tests/unit/test_reconcile.py` + +## Cosa non viene registrato + +- **[AUD-32]** Questi fatti **non** producono una riga di audit; restano + ricostruibili solo dallo stato delle tabelle o dal log applicativo: + - apertura di un round; + - passaggi di stato del round a `closing` e `drawing`, e la chiusura dopo la + conferma del pagamento (`_on_payout_confirmed`); + - conferma di una scommessa, di un prelievo o di un pagamento + (`ConfirmationPoller`); + - ogni aumento di commissione RBF e ogni RBF impossibile (solo log); + - registrazione, login, login falliti e blocchi per troppi tentativi; + - cambio password fatto dall'utente (`POST /users/me/change-password`); + - invio di una segnalazione di bug; + - accesso in lettura al pannello admin (elenchi utenti, round, audit). + *Codice:* `app/rounds/service.py:open_new_round_if_needed`, + `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` + +## Log applicativo + +Il registro di audit è nel database e si vede da `/admin`; il log applicativo +è un file di testo e contiene molto di più (errori, riconnessioni, attese), ma +va letto sul server. + +- **[AUD-33]** All'importazione di `app.main`, `setup_logging` aggiunge al + logger radice (livello `INFO`) un `RotatingFileHandler` su `logs/app.log`, + relativo alla radice del progetto: 10 MB per file, 5 file di rotazione + (`app.log.1` … `app.log.5`), formato + `data ora LIVELLO [logger] messaggio`. Lo stesso handler è aggiunto a + `uvicorn.error` e `uvicorn.access`, che non propagano al radice. Nel + container la radice del progetto è `/app`, e `/app/logs` è montato su + `./data/logs` dell'host. + *Codice:* `app/logging_config.py:setup_logging`, `docker-compose.yml` + +- **[AUD-34]** Il logger radice ha **solo** l'handler su file: i messaggi dei + moduli dell'applicazione (scheduler, listener, riconciliatori) finiscono in + `data/logs/app.log` e non sull'output del container. `docker compose logs app` + mostra solo ciò che uvicorn scrive con la propria configurazione (avvio, + richieste) e l'output di avvio del container (controllo della chiave, + migrazioni Alembic). + *Codice:* `app/logging_config.py:setup_logging` + +- **[AUD-35]** Un'eccezione non gestita in una richiesta HTTP viene registrata + per intero nel log (`Unhandled error on `) e al client + arriva solo un errore generico `internal_error` con stato 500, senza il testo + dell'eccezione. + *Codice:* `app/main.py:log_unhandled_exception` + +- **[AUD-36]** Le richieste di polling delle due interfacce (per esempio + `/rounds/current` ogni 15 secondi per ogni pagina aperta, più un aggiornamento + a ogni evento SSE) vengono tutte registrate da `uvicorn.access`. Con 60 MB di spazio complessivo, la rotazione può cancellare + in poche ore o giorni i messaggi più vecchi: il log non è un archivio, il + registro di audit sì. + *Codice:* `app/logging_config.py:setup_logging` + +### Messaggi di log che contano + +Messaggi non accompagnati da una riga di audit ma utili in esercizio. Il +nome tra parentesi quadre nel log è il modulo (`app.rounds.scheduler`, ecc.). + +| Livello | Modulo | Messaggio (inizio) | Significato | +|---|---|---|---| +| WARNING | `app.electrum.listener` | `all N Electrum server(s) unreachable, retrying in Ns` | Tutti i server provati hanno fallito; attesa da 1 a 30s prima del giro successivo | +| ERROR (con traccia) | `app.electrum.listener` | `Electrum session on … failed` | Errore nell'aprire la sessione: connessione, primo header, primo header rifiutato | +| INFO | `app.electrum.listener` | `Electrum connected to …` / `Electrum connection to … ended, reconnecting` | Connessione aperta / chiusa normalmente | +| WARNING | `app.electrum.listener` | `Electrum session on … ending: …` | Motivo della fine di una sessione già aperta (ping scaduto, socket chiuso, header successivo rifiutato con `HeaderValidationError`) | +| WARNING | `app.electrum.listener` | `ignoring Electrum header at height …` / `ignoring a competing header …` | Header scartato senza chiudere la sessione (senza hex, più basso, concorrente) | +| WARNING | `app.electrum.listener` | `could not corroborate … with any of N other configured server(s)` | Nessun altro server ha risposto: la corroborazione fallisce | +| WARNING | `app.electrum.listener` | `N new UTXO(s) for user_id=… not corroborated by the other servers` | Deposito non ancora accreditato; si riprova al prossimo aggiornamento | +| WARNING | `app.electrum.listener` | `N UTXO(s) spent outside the platform for user_id=…` | Accompagna `utxo_spent_externally` | +| WARNING | `app.electrum.listener` | `could not subscribe a newly-registered user's address` / `resubscribing all users failed partway through` | Sottoscrizione persa; la recupera `DepositReconciler` entro 300s | +| ERROR (con traccia) | `app.electrum.listener` | `failed to resubscribe user_id=…` | Un singolo utente non risottoscritto alla riconnessione | +| WARNING | `app.deposits.service` | `listunspent for user_id=… returned no entries at all …` | Risposta vuota per un indirizzo con fondi: ignorata come incompleta | +| ERROR (con traccia) | `app.deposits.reconcile` | `deposit reconciliation failed for user_id=…` | Il controllo periodico di un utente è fallito | +| ERROR (con traccia) | `app.rounds.scheduler` | `round scheduler tick failed` | Eccezione in un ciclo dello scheduler. Se avviene durante `drawing`, il round resta bloccato (vedi [operativita.md](operativita.md)) | +| INFO | `app.rounds.scheduler` | `round N: N bet(s) still in flight at close time, waiting` | Chiusura rinviata per scommesse ancora in volo | +| INFO | `app.rounds.scheduler` | `round N: still waiting for a block past height …` | Estrazione in attesa, ogni 60s | +| ERROR | `app.rounds.scheduler` | `round N: block … header … could not be corroborated …` | Accompagna `draw_header_corroboration_failed` | +| WARNING | `app.rounds.scheduler` | `round N: chain tip was unknown at closing time …` | Accompagna `draw_baseline_tip_unknown` | +| ERROR | `app.rounds.scheduler` | `round N payout blocked: …` / `payout deferred: not connected` / `payout failed: …` / `payout build failed` / `payout broadcast failed` | Accompagnano i vari `payout_failed` | +| INFO | `app.rounds.scheduler` | `round N payout already in flight (pending_transaction N), skipping` | Pagamento già in volo. Mentre aspetta la conferma, ricompare a ogni ciclo (ogni 5s) | +| ERROR (con traccia) | `app.rounds.scheduler` | `could not record the payout failure of round N` | Il `payout_failed` stesso non è stato salvato | +| ERROR | `app.rounds.service` | `invariant violated: N rounds are active at once` | Più di un round attivo: non dovrebbe mai accadere | +| ERROR | `app.rounds.service` | `could not open a round after 3 attempts` | Conflitti ripetuti all'apertura di un round | +| ERROR (con traccia) | `app.tx.broadcast` | `could not bump pending_transaction N` | RBF impossibile (resto insufficiente o commissione già al massimo). Si ripete ogni 30s finché la transazione conferma o viene abbandonata | +| INFO | `app.tx.broadcast` | `bumped pending_transaction N: -> ` | RBF riuscito: l'unica traccia di un aumento di commissione | +| WARNING | `app.tx.confirmation` | `could not fetch history for pending_transaction …` | Controllo di conferma non riuscito, si riprova tra 10s | +| WARNING | `app.tx.reconcile` | `could not check pending_transaction … against the chain` | Il riconciliatore non ha potuto decidere; non abbandona nulla | +| WARNING | `app.tx.reconcile` | `abandoned pending_transaction …` | Accompagna `pending_tx_abandoned` | +| ERROR | `app.tx.reconcile` | `round N payout tx … vanished — round needs operator attention` | Pagamento abbandonato; il nuovo tentativo automatico parte da solo | +| ERROR | `app.auth.security` | `stored password hash is unparseable …` | Hash di password corrotto nel database: quell'utente non può accedere | +| ERROR (con traccia) | `app.main` | `Unhandled error on …` | Errore 500 su una richiesta ([AUD-35]) | diff --git a/docs/ciclo-di-vita-del-round.md b/docs/ciclo-di-vita-del-round.md new file mode 100644 index 0000000..aad2489 --- /dev/null +++ b/docs/ciclo-di-vita-del-round.md @@ -0,0 +1,344 @@ +# Ciclo di vita del round + +Fase 4 del [funzionamento generale](come-funziona.md): come un round si apre, +si chiude, estrae un vincitore e lo paga. Tutto è guidato da `RoundScheduler`, +che si risveglia ogni 5 secondi. + +I requisiti usano il prefisso `RND`, secondo la convenzione di +[processo-di-modifica.md](processo-di-modifica.md). Le colonne della tabella +`rounds` sono descritte in [modello-dati.md](modello-dati.md), gli eventi di +audit in [audit-log.md](audit-log.md), le attività in background in +[architettura.md](architettura.md). + +- **[RND-01]** Lo scheduler esegue un passo ogni 5 secondi + (`_TICK_INTERVAL_SECONDS`). Un passo non fa nulla se non c'è connessione a + Electrum: senza rete nessun round si apre, si chiude o si paga. Un errore in + un passo viene registrato nei log e non ferma i passi successivi. + *Codice:* `app/rounds/scheduler.py:RoundScheduler.run`, `app/rounds/scheduler.py:RoundScheduler._tick` · + *Test:* `tests/unit/test_scheduler.py` + +## Gli stati + +```mermaid +stateDiagram-v2 + [*] --> open: nessun round attivo,
pausa tra i round trascorsa,
lotteria non in manutenzione,
indirizzo commissioni impostato + open --> closing: scade il tempo
(al primo passo dopo la scadenza) + closing --> closing: scommesse ancora in volo + closing --> closed: nessun partecipante confermato + closing --> drawing: tutte le scommesse confermate + drawing --> drawing: blocco non corroborato,
si attende il successivo + drawing --> paying_out: nuovo blocco corroborato,
vincitore scelto + paying_out --> paying_out: pagamento fallito o abbandonato,
nuovo tentativo dopo 60s + paying_out --> closed: pagamento confermato + closed --> [*] +``` + +| Stato | Significato | +|---|---| +| `open` | Accetta scommesse fino alla scadenza | +| `closing` | Scadenza passata: nessuna nuova scommessa, si aspettano quelle già inviate | +| `drawing` | Partecipanti fissati, si attende il blocco che sceglie il vincitore | +| `paying_out` | Vincitore noto, pagamento in costruzione, inviato o in attesa di conferma | +| `closed` | Concluso (con o senza vincitore) | + +- **[RND-02]** Gli stati sono esattamente quelli della tabella e le sole + transizioni sono quelle del diagramma; `closed` è definitivo. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw`, `app/rounds/confirmation.py:_on_payout_confirmed` · + *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_confirmation.py` +- **[RND-03]** **Al massimo un round attivo alla volta** (cioè in uno stato + diverso da `closed`). Non è una convenzione ma un vincolo del database: un + indice univoco (`ix_rounds_single_active`) fa fallire un secondo inserimento + concorrente, e il codice di apertura in quel caso adotta il round già creato + (fino a 3 tentativi, `_OPEN_ROUND_ATTEMPTS`). Se mai risultassero due round + attivi, si usa il più recente e lo si segnala nei log come violazione. + *Codice:* `app/db/models.py:Round`, `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:get_active_round` · + *Test:* `tests/unit/test_rounds_service.py` + +## 0 · Apertura + +L'apertura viene tentata a ogni passo dello scheduler e anche da ogni +richiesta di scommessa (vedi +[scommesse-e-prelievi.md](scommesse-e-prelievi.md#quando-è-accettata)). + +- **[RND-04]** Un nuovo round si apre solo quando **tutte** queste condizioni + sono vere: + - nessun round è attivo (il precedente è `closed`); + - è trascorsa la **pausa tra i round** del round chiuso più recente, contata + dal suo `closed_at`: per un round pagato è il momento della conferma del + pagamento, per un round chiuso senza partecipanti è il momento della + chiusura. Serve a lasciare ai giocatori il tempo di vedere il risultato; + - la lotteria **non è in manutenzione** (`POST /admin/pause`); + - l'**indirizzo delle commissioni** (`fee_address`) è impostato (un valore + di soli spazi conta come vuoto). Senza, il pagamento non si potrebbe + costruire, quindi il round non viene nemmeno aperto invece di bloccarsi + più avanti con i soldi già nel montepremi. + + *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:rounds_can_open` · + *Test:* `tests/unit/test_rounds_service.py` +- **[RND-05]** All'apertura il round **fotografa** la durata + (`round_duration_seconds`, 600s predefiniti) e la pausa + (`round_cooldown_seconds`, 30s predefiniti) correnti. Scadenza del round e + pausa successiva usano sempre i valori fotografati: una modifica da + `/admin` vale dal round successivo, mai per quello in corso. + *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:round_deadline` · + *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py` +- **[RND-06]** La pausa di manutenzione e l'indirizzo mancante bloccano solo + l'apertura del *prossimo* round: un round già in corso prosegue fino al + pagamento, anche se l'indirizzo viene cancellato nel frattempo. + `GET /rounds/current` espone i due casi separatamente (`lottery_paused`, + `lottery_configured`) perché `/` mostri avvisi diversi. Le procedure sono in + [guida-admin.md](guida-admin.md). + *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/api/routes/rounds.py:current_round` · + *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_rounds_route.py` +- **[RND-07]** L'apertura di un round invia una notifica SSE. + *Codice:* `app/rounds/service.py:open_new_round_if_needed` + +## 1 · Round aperto + +Il round accetta scommesse fino a `apertura + durata`. Le regole di +accettazione sono in +[scommesse-e-prelievi.md](scommesse-e-prelievi.md#quando-è-accettata). + +- **[RND-08]** La scadenza (`closes_at`) è `opened_at` più la durata + fotografata all'apertura; è la stessa usata per rifiutare le scommesse e per + chiudere il round. + *Codice:* `app/rounds/service.py:round_deadline`, `app/rounds/service.py:round_accepts_bets` · + *Test:* `tests/unit/test_rounds_service.py` +- **[RND-09]** `GET /rounds/current` pubblica il montepremi in due forme: + - `jackpot_sats` e `participant_count`: calcolati sulle sole scommesse + **confermate** — esattamente ciò che l'estrazione considererà e che il + pagamento potrà spendere. `jackpot_sats` è già il 70% del vincitore + (arrotondato per difetto) della somma degli importi netti, non l'intero + montepremi; resta un limite superiore, perché la commissione del pagamento + sarà dedotta da lì; + - `pending_jackpot_sats` e `pending_participant_count`: gli stessi valori + includendo le scommesse ancora in volo (`building`, `broadcast`), come + totali e non come differenze; `has_pending_bets` dice se differiscono. + + *Codice:* `app/api/routes/rounds.py:current_round`, `app/rounds/service.py:winner_share` · + *Test:* `tests/unit/test_rounds_route.py` +- **[RND-10]** `user_played` è vero solo se chi chiama è autenticato e ha una + scommessa nel round attivo; senza login la risposta non è personalizzata. + *Codice:* `app/api/routes/rounds.py:current_round` · + *Test:* `tests/unit/test_rounds_route.py` + +L'interfaccia mostra in grande i valori confermati e la differenza come +suffisso ambra "+N in attesa": chi ha appena scommesso vede subito la propria +giocata, ma il montepremi pubblicizzato non supera mai quello che verrà pagato. + +## 2 · Chiusura + +- **[RND-11]** Al primo passo dello scheduler dopo la scadenza (quindi entro + circa 5 secondi) il round passa a `closing`, qualunque cosa stia accadendo: + si annota `closed_at` e si invia una notifica SSE. Le scommesse sono però + rifiutate già dall'istante della scadenza ([BET-04](scommesse-e-prelievi.md#quando-è-accettata)). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · + *Test:* `tests/unit/test_scheduler.py` +- **[RND-12]** Poi il round **attende che ogni scommessa già inviata sia + confermata** (il "semaforo giallo"): finché esiste un partecipante in stato + `building` o `broadcast` il round resta `closing` e il controllo si ripete + al passo successivo. Il controllo viene ripetuto anche nella stessa sessione + del database in cui si fissa l'elenco dei partecipanti, così una scommessa + salvata tra i due controlli non può restare esclusa. Una scommessa partita + un attimo prima della scadenza resta quindi valida e non va persa. Ciò che + impedisce all'attesa di durare per sempre è il riconciliatore, che prima o + poi abbandona una scommessa che non conferma mai + ([TX-17](transazioni-e-riconciliazione.md#riconciliazione)). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` +- **[RND-13]** Quando non resta nulla in volo e **nessun partecipante è + confermato**, il round passa a `closed` senza vincitore, si scrive + `round_closed` (con `participants: 0`) e si invia una notifica SSE. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` +- **[RND-14]** Con **almeno un partecipante confermato**, si fissa l'elenco dei + partecipanti confermati, ordinato per momento di invio della scommessa, si + somma il montepremi dai loro importi netti, e il round passa a `drawing` + annotando `drawing_started_at`; si invia una notifica SSE. L'altezza della + catena nota in quell'istante diventa il riferimento dell'estrazione. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` + +## 3 · Estrazione + +L'estrazione usa come fonte di casualità l'**hash di un blocco confermato +dopo la chiusura**, un valore che nessuno conosceva mentre le scommesse erano +aperte. + +```mermaid +flowchart TB + A["Altezza nota alla chiusura
(se sconosciuta: la prima che si
viene a sapere, draw_baseline_tip_unknown)"] --> B{"La punta della catena
è più alta?
(controllo ogni 5s)"} + B -- "No" --> B + B -- "Sì" --> C{"La maggioranza degli
altri server conferma
l'hash a quell'altezza?"} + C -- "No" --> D["Registrato in audit
(draw_header_corroboration_failed),
quell'altezza diventa il nuovo riferimento"] --> B + C -- "Sì" --> E["seme = hash come numero intero
indice = seme mod numero partecipanti"] + E --> F["Vincitore = partecipante all'indice"] +``` + +- **[RND-15]** L'estrazione attende che la punta della catena superi + l'altezza di riferimento, controllando ogni 5 secondi; il blocco candidato è + la punta nota in quel momento (di norma il primo blocco dopo la chiusura). + Per tutta l'attesa lo scheduler resta dentro l'estrazione e non esegue + altri passi. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` · + *Test:* `tests/unit/test_scheduler.py` +- **[RND-16]** **Blocco corroborato**: l'header ricevuto dal server Electrum + attivo è l'unica fonte di casualità, quindi un server malevolo che lo + falsificasse sceglierebbe il vincitore. Per questo, prima di usarlo, lo si fa + confermare dagli altri server + ([ELC-15](connessione-electrum.md#5-il-quorum)). Se la conferma manca, si + scrive `draw_header_corroboration_failed` (altezza e hash riportato), il + blocco non viene mai usato e si attende un blocco ulteriore. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`, `app/electrum/listener.py:ElectrumListener.corroborate_header` · + *Test:* `tests/unit/test_scheduler.py` +- **[RND-17]** **Algoritmo**: seme = hash del blocco letto come numero + esadecimale, indice = seme modulo numero dei partecipanti, vincitore = il + partecipante in quella posizione dell'elenco ordinato per momento di invio. + **Probabilità uguale per tutti**: ogni partecipante ha una e una sola + posizione nell'elenco, indipendentemente dall'importo. + *Codice:* `app/rounds/draw.py:draw_winner`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_draw.py` +- **[RND-18]** **Verificabile**: il round salva altezza e hash del blocco + (`draw_block_height`, `draw_block_hash`), il seme (`seed_int`), il + vincitore e il montepremi (`pool_amount_sats`), passa a `paying_out`, + scrive `winner_drawn` (indirizzo del vincitore, montepremi, altezza, hash, + numero di partecipanti) e invia una notifica SSE. Chiunque, con l'hash del + blocco e l'elenco dei partecipanti, può rifare il calcolo. Il primo + tentativo di pagamento parte subito dopo. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` +- **[RND-19]** **Altezza iniziale sconosciuta**: se alla chiusura l'altezza + della catena non era nota (valore 0), non si parte da zero (qualunque blocco + già esistente sarebbe "successivo", e quindi prevedibile): si attende il + primo header, se ne adotta l'altezza come riferimento, si scrive + `draw_baseline_tip_unknown` e si aspetta un blocco strettamente successivo. + In condizioni normali non accade, perché la connessione è resa disponibile + solo con l'altezza già nota ([ELC-07](connessione-electrum.md#3-caduta-rilevata)). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` · + *Test:* `tests/unit/test_scheduler.py` +- **[RND-20]** **Attesa lunga ma visibile**: l'attesa non ha un limite, + perché dipende davvero dall'arrivo di un blocco. Ogni 60 secondi se ne + registra l'avanzamento nei log; dopo 6 minuti (`_DRAW_STALL_THRESHOLD_SECONDS` + = 360s) viene scritto un evento `draw_stalled`, ripetuto ogni 6 minuti + finché l'attesa continua. Durante `drawing`, `GET /rounds/current` espone + `draw_waiting_since` (l'orario di inizio dell'attesa); negli altri stati il + campo è assente. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`, `app/api/routes/rounds.py:current_round` · + *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_rounds_route.py` + +L'algoritmo è volutamente semplice ed è pensato per essere **sostituito** +(vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)). + +## 4 · Pagamento + +Con il vincitore noto il round è in `paying_out` e si costruisce il +pagamento, firmato con la chiave del montepremi. + +- **[RND-21]** **Ripartizione**: il vincitore riceve il 70% del montepremi + arrotondato per difetto, l'indirizzo delle commissioni il resto (30% più + l'eventuale resto dell'arrotondamento). La ripartizione è fissata nel + codice, non modificabile da `/admin`. + *Codice:* `app/rounds/service.py:winner_share`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · + *Test:* `tests/unit/test_payout_builder.py` +- **[RND-22]** Il pagamento spende le monete **confermate** dell'indirizzo del + montepremi, lette dal server Electrum attivo ed escluse quelle già + impegnate da un altro pagamento non risolto ([RND-26]), scelte dalla più + grande fino a coprire il montepremi e al massimo 500 + (`MAX_PAYOUT_TX_INPUTS`). Non abbastanza monete dà il motivo + `insufficient pool UTXOs`, troppe `too_many_inputs`. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/wallet/psbt_builder.py:build_payout_transaction` · + *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_payout_builder.py` +- **[RND-23]** La transazione ha un'uscita verso l'indirizzo del vincitore, una + verso l'indirizzo delle commissioni e, se supera 294 sat, una di resto verso + il montepremi (un resto inferiore va alla commissione di rete). La + **commissione di rete** (stimata su tre uscite, a `fee_rate_sat_vb`) è + pagata dalla quota del vincitore, così il 30% resta intatto. Se la quota del + vincitore meno la commissione, o la quota delle commissioni, scende sotto + 294 sat, il pagamento non si costruisce (`winner_share_below_network_fee`, + `commission_below_dust_limit`). + *Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` · + *Test:* `tests/unit/test_payout_builder.py` +- **[RND-24]** **Prima si scrive, poi si invia**: prima dell'invio il round + registra importo del vincitore (`winner_amount_sats`), commissioni + (`fee_amount_sats`) e txid (`payout_txid`), e si salva una transazione in + corso di tipo `payout` in stato `building`. Se l'invio riesce, la + transazione passa a `pending` e si scrive `payout_sent`. Se fallisce, la + riga resta `building` e si scrive `payout_failed`: sarà il riconciliatore a + constatare che la rete non la conosce, abbandonarla e liberare il round per + un nuovo tentativo ([TX-18](transazioni-e-riconciliazione.md#pagamenti-del-round-riprovare-senza-pagare-due-volte)). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · + *Test:* `tests/unit/test_scheduler.py` +- **[RND-25]** Ogni tentativo che non arriva all'invio riuscito scrive un + evento `payout_failed` con il motivo: `electrum client not connected`, + `no fee_address configured`, `winner user not found`, + `insufficient pool UTXOs`, il codice dell'errore di costruzione + (`too_many_inputs`, `winner_share_below_network_fee`, + `commission_below_dust_limit`), `payout build failed` per ogni altro errore + di costruzione, `broadcast rejected`. A ogni passo lo scheduler riesamina un + round in `paying_out` e riprova **al massimo una volta ogni 60 secondi** + (`_PAYOUT_RETRY_INTERVAL_SECONDS`), misurati dall'ultimo `payout_failed` di + quel round; senza alcun `payout_failed` (ad esempio dopo un riavvio) riprova + subito. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`, `app/rounds/scheduler.py:RoundScheduler._log_payout_failure` · + *Test:* `tests/unit/test_scheduler.py` +- **[RND-26]** Due protezioni impediscono di pagare due volte: non si + costruisce un nuovo pagamento se per il round ne esiste già uno `building` o + `pending` (il tentativo termina senza fare nulla), e le monete del + montepremi impegnate da un qualunque pagamento non risolto, anche di un + round precedente, sono escluse dalla selezione. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:_reserved_payout_outpoints` · + *Test:* `tests/unit/test_scheduler.py` +- **[RND-27]** Se il pagamento non conferma in tempo si applica l'RBF, pagato + dal resto del montepremi ([TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)). + *Codice:* `app/tx/broadcast.py:bump_fee` +- **[RND-28]** Quando il pagamento è confermato il round passa a `closed` e + `closed_at` viene riscritto con l'orario della conferma: da lì parte la + pausa prima del round successivo. Il vincitore si vede accreditare la + vincita come un normale deposito + ([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito)). + *Codice:* `app/rounds/confirmation.py:_on_payout_confirmed` · + *Test:* `tests/unit/test_confirmation.py` + +## Tempi reali + +Tra lo scadere del timer e l'arrivo della vincita ci sono **tre conferme +distinte**, ognuna in un blocco diverso: + +1. l'ultima scommessa in volo (può essere già avvenuta prima della scadenza); +2. il blocco dell'estrazione, necessariamente successivo al punto 1; +3. il pagamento, costruito solo dopo il punto 2. + +Con blocchi da 120 secondi sono circa **2–4 minuti** nel caso migliore e +**4–6** nel peggiore, indipendentemente da `draw_animation_seconds`. + +## Cosa vede il giocatore + +Due livelli indipendenti: + +- **[RND-29]** A **tutti** i visitatori, per l'intera durata di `closing`, + `drawing` e `paying_out`, un riquadro con la fase in corso (ad esempio + "Pagamento al vincitore in corso…"). + *Codice:* `app/static/app.js:refreshRound` +- **[RND-30]** **In più**, solo a chi ha giocato quel round + (`user_played`), il riquadro personale "Hai vinto!" / "Non hai vinto". Il + risultato compare quando il vincitore è noto (quindi da `paying_out`), non + prima di `draw_animation_seconds` (20s predefiniti) dall'orario di chiusura + del server `closes_at` — così ricaricare la pagina non fa ripartire il conto + alla rovescia — e, per il vincitore, solo quando è noto l'importo vinto. + *Codice:* `app/static/app.js:refreshRound` +- **[RND-31]** Una volta rivelato, il risultato resta salvato nel browser + (`localStorage.plm_persisted_result`) anche dopo che il round è `closed` + (quando `GET /rounds/current` non lo restituisce più), fino all'apertura di + un round nuovo. Per un dispositivo che si è perso del tutto la finestra, + `GET /users/me/last-round-result` restituisce dal database l'ultimo round + `closed` a cui l'utente ha partecipato, se ha vinto e l'importo vinto. + *Codice:* `app/static/app.js:checkLastRoundResult`, `app/api/routes/users.py:last_round_result` + +## Limite noto + +- **[RND-32]** Se il server si riavvia mentre un round è in `drawing`, + l'attesa del blocco **non riprende da sola**: lo scheduler riprende i round + in `closing` e `paying_out`, ma su un round in `drawing` non fa nulla. È + l'ultimo prerequisito mancante per un funzionamento completamente non + presidiato (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` diff --git a/docs/come-funziona.md b/docs/come-funziona.md new file mode 100644 index 0000000..2afce1f --- /dev/null +++ b/docs/come-funziona.md @@ -0,0 +1,151 @@ +# Come funziona PLM Lottery + +Panoramica del funzionamento della piattaforma. È il punto di partenza: ogni +sezione rimanda al documento che la approfondisce. + +| Documento | Cosa spiega | +|---|---| +| [registrazione-e-depositi.md](registrazione-e-depositi.md) | Come nasce un account, come arrivano i fondi, come si calcola il saldo mostrato | +| [scommesse-e-prelievi.md](scommesse-e-prelievi.md) | Come si piazza una scommessa e come si ritirano i fondi | +| [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) | Apertura, chiusura, estrazione del vincitore, pagamento | +| [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) | Come ogni movimento di denaro viene registrato, inviato, accelerato (RBF) e riconciliato con la catena | +| [connessione-electrum.md](connessione-electrum.md) | Come il server parla con la rete PLM e perché non si fida di un solo server | +| [architettura.md](architettura.md) | Struttura del codice, processi in background, configurazione, frontend, traduzioni, test | +| [modello-dati.md](modello-dati.md) | Tabelle del database, campi, stati, vincoli, migrazioni | +| [api.md](api.md) | Tutti gli endpoint HTTP, formato delle risposte, catalogo dei codici di errore | +| [sicurezza.md](sicurezza.md) | Modello di fiducia, chiavi e segreti, autenticazione, limiti sui tentativi, header HTTP | +| [audit-log.md](audit-log.md) | Catalogo degli eventi del registro di audit | +| [operativita.md](operativita.md) | Backup, riavvii, procedure per gli incidenti | +| [decisioni-e-limiti.md](decisioni-e-limiti.md) | Decisioni di progetto con le loro motivazioni, limiti noti accettati | +| [storico-bug.md](storico-bug.md) | Tutti i bug B-nn trovati e corretti, con commit e test | + +Questi documenti sono la **specifica** del software: ogni regola ha un +identificativo stabile (ad esempio `RND-11`) con il riferimento al codice che +la implementa. Come si usano per modificare il software è spiegato in +[processo-di-modifica.md](processo-di-modifica.md). + +Per installazione e avvio vedi [setup.md](setup.md) e +[running-the-server.md](running-the-server.md); per l'uso delle interfacce +[guida-utente.md](guida-utente.md) e [guida-admin.md](guida-admin.md). + +## In una frase + +Gli utenti depositano PLM su un indirizzo personale custodito dal server, +comprano un biglietto a costo fisso per il round in corso, e allo scadere del +round l'hash del primo blocco successivo sceglie un vincitore, che riceve il +70% del montepremi; il 30% va alla piattaforma. + +## Scelte di fondo + +- **Custodiale.** Le chiavi sono generate e tenute dal server (una master xprv + cifrata a riposo). L'utente non controlla nulla finché non preleva. +- **Un indirizzo per utente, per sempre.** Lo stesso indirizzo serve per i + depositi, riceve le vincite e riceve il resto di ogni scommessa e prelievo. +- **Nessun nodo completo.** Tutto l'accesso alla rete passa dal protocollo + Electrum (vedi [connessione-electrum.md](connessione-electrum.md)). +- **1 conferma** per ogni tipo di transazione: depositi, scommesse, pagamenti, + prelievi. +- **Parametri operativi nel database**, modificabili a caldo da `/admin` + (costo della scommessa, durata del round, commissioni…). Nel file `.env` + restano solo segreti e collegamenti infrastrutturali. + +## Le cinque fasi + +```mermaid +flowchart LR + subgraph REG["1 · Registrazione"] + direction TB + A1["Username + password"] --> A2["Il server deriva un indirizzo
dedicato e permanente"] + end + + subgraph DEP["2 · Deposito"] + direction TB + B1["L'utente invia PLM
al proprio indirizzo"] --> B2{"1 conferma e
corroborato dagli
altri server?"} + B2 -- "No" --> B1b["Si riprova al prossimo
aggiornamento"] --> B2 + B2 -- "Sì" --> B3["Saldo accreditato"] + end + + subgraph PLAY["3 · Scommessa"] + direction TB + C1{"Saldo sufficiente,
nessuna scommessa attiva,
round aperto e non pieno?"} + C1 -- "Sì" --> C2["Transazione: indirizzo utente →
montepremi, resto all'utente"] + C2 --> C3{"Confermata?"} + C3 -- "No, troppo tempo" --> C4["Commissione aumentata
(RBF) e reinvio"] --> C3 + C3 -- "Sì" --> C5["Partecipante del round"] + end + + subgraph DRAW["4 · Round ed estrazione"] + direction TB + D1["Scadenza del round"] --> D2["Si attendono le scommesse
già in volo"] + D2 --> D3["Il primo blocco successivo
sceglie il vincitore"] + D3 --> D4["70% al vincitore,
30% alla piattaforma"] + end + + subgraph WITHDRAW["5 · Prelievo"] + direction TB + E1["Indirizzo esterno + importo"] --> E2["Transazione: indirizzo utente →
esterno, resto all'utente"] + E2 --> E3{"Confermata?"} + E3 -- "No, troppo tempo" --> E4["RBF e reinvio"] --> E3 + E3 -- "Sì" --> E5["Saldo aggiornato"] + end + + A2 --> B1 + B3 --> C1 + B3 --> E1 + C5 --> D1 + D4 -.->|"round successivo"| C1 +``` + +1. **Registrazione** — [registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione) +2. **Deposito** — [registrazione-e-depositi.md](registrazione-e-depositi.md#deposito) +3. **Scommessa** — [scommesse-e-prelievi.md](scommesse-e-prelievi.md#scommessa) +4. **Round ed estrazione** — [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) +5. **Prelievo** — [scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo) + +## Componenti + +Un solo processo Python (FastAPI) dietro Caddy, con un database SQLite. Dentro +il processo girano, oltre all'API HTTP, sei attività in background avviate +all'avvio del server: + +| Attività | Ogni | Cosa fa | +|---|---|---| +| `ElectrumListener` | connessione continua, ping ogni 60s | L'unica connessione alla rete: riceve i nuovi blocchi, osserva gli indirizzi di tutti gli utenti, accredita i depositi | +| `RoundScheduler` | 5s | Apre e chiude i round, esegue l'estrazione, avvia e riprova i pagamenti | +| `ConfirmationPoller` | 10s | Porta le transazioni da "in attesa" a "confermate" | +| `RbfBumper` | 30s | Aumenta la commissione di ciò che non conferma entro il tempo limite | +| `PendingTransactionReconciler` | all'avvio, poi 120s | Confronta con la catena ogni transazione rimasta a metà | +| `DepositReconciler` | 300s | Ricontrolla tutti gli indirizzi, nel caso una notifica sia andata persa | + +La cadenza di queste attività è ciò che determina quanto in fretta il sistema +si ripara da solo dopo un problema. + +Le due interfacce web sono pagine statiche servite dallo stesso server, senza +build né framework: + +- **`/`** — l'interfaccia per i giocatori (registrazione, deposito, scommessa, + prelievo, profilo, stato del round), tradotta in 7 lingue. +- **`/admin`** — il pannello operatore, protetto da un token condiviso, + in italiano. + +Entrambe interrogano periodicamente l'API e in più ascoltano un canale +**SSE** (`GET /rounds/stream`): un semplice segnale "è cambiato qualcosa, +ricarica" inviato a ogni evento rilevante (round aperto o cambiato di stato, +scommessa o prelievo inviato, transazione confermata, deposito accreditato, +nuovo blocco). Se il canale cade, le pagine continuano a funzionare con il +solo polling. + +## Parametri principali + +| Parametro | Valore predefinito | Modificabile | +|---|---|---| +| Costo della scommessa | 10 PLM | da `/admin` | +| Ripartizione del montepremi | 70% vincitore / 30% piattaforma | no, è nel codice | +| Durata del round | 600s | da `/admin`, vale dal round successivo | +| Pausa tra un round e l'altro | 30s | da `/admin`, vale dal round successivo | +| Commissione di rete | 1 sat/vB | da `/admin` | +| Tempo prima di un RBF | 900s | da `/admin` | +| Prelievo minimo | pari al costo della scommessa | segue il costo | +| Deposito minimo | nessuno | — | +| Partecipanti massimi per round | 400 | no, è nel codice | +| Conferme richieste | 1 | no, è nel codice | diff --git a/docs/connessione-electrum.md b/docs/connessione-electrum.md new file mode 100644 index 0000000..7f7acdc --- /dev/null +++ b/docs/connessione-electrum.md @@ -0,0 +1,186 @@ +# Connessione alla rete PLM (Electrum) + +Il server non gestisce un nodo completo: parla con la rete PLM esclusivamente +tramite il **protocollo Electrum**, con **una sola connessione** alla volta. +Da quella connessione passa tutto — accredito dei depositi, invio delle +transazioni, conferme, altezza della catena su cui attende l'estrazione — per +cui è sia il principale punto di rottura sia, se il server dall'altra parte +fosse ostile, il principale rischio per l'integrità. + +I requisiti usano il prefisso `ELC`, secondo la convenzione di +[processo-di-modifica.md](processo-di-modifica.md). La configurazione +(`.env`) è descritta in [setup.md](setup.md), i rischi accettati in +[sicurezza.md](sicurezza.md) e [decisioni-e-limiti.md](decisioni-e-limiti.md). + +Le difese sono cinque. + +## 1. Rotazione tra server + +`ELECTRUM_HOST`/`ELECTRUM_PORT` è il server principale; +`ELECTRUM_FALLBACK_SERVERS` è un elenco di riserva nel formato +`host:porta[:notls]`, separato da virgole. + +- **[ELC-01]** L'elenco dei server è il principale (con TLS secondo + `ELECTRUM_USE_SSL`) seguito da quelli di riserva. Ogni voce di riserva è + `host:porta` (TLS) oppure `host:porta:`, con `` tra `ssl`/`tls` + (TLS) e `notls`/`plain` (in chiaro); le voci vuote sono ignorate e i + duplicati eliminati mantenendo la prima posizione. Una voce malformata + blocca l'avvio subito, non durante il guasto in cui servirebbe. + *Codice:* `app/electrum/client.py:parse_endpoints` · + *Test:* `tests/unit/test_electrum_client.py` +- **[ELC-02]** Dopo ogni sessione fallita o caduta si prova **subito** il + server successivo nell'elenco, a rotazione; l'attesa crescente (da 1s + raddoppiando fino a 30s) scatta solo dopo che ogni server ha fallito una + volta di seguito. Una sessione che si era stabilita azzera l'attesa. Senza + alcun server configurato il listener si ferma con un errore nei log. + *Codice:* `app/electrum/listener.py:ElectrumListener.run` · + *Test:* `tests/unit/test_electrum_listener.py` +- **[ELC-03]** Con TLS la connessione è cifrata ma il certificato del server + **non viene verificato** (i server Electrum usano spesso certificati + autofirmati): la fiducia non viene dal TLS ma dal quorum + ([§ 5](#5-il-quorum)). Ogni connessione si apre con la stretta di mano + `server.version`. + *Codice:* `app/electrum/client.py:ElectrumClient.connect` + +## 2. Richieste con limite di tempo + +- **[ELC-04]** Ogni richiesta ha un limite di 15 secondi + (`_REQUEST_TIMEOUT_SECONDS`); allo scadere la connessione viene chiusa, la + sessione termina e si passa al server successivo. Quando la connessione cade, + tutte le richieste in attesa falliscono subito invece di restare sospese. + Senza questo limite una risposta mai arrivata poteva bloccare una scommessa + (con il blocco per-utente acquisito) o fermare per sempre il controllo delle + conferme. + *Codice:* `app/electrum/client.py:ElectrumClient.request`, `app/electrum/client.py:ElectrumClient._read_loop` · + *Test:* `tests/unit/test_electrum_client.py` + +## 3. Caduta rilevata + +- **[ELC-05]** La connessione viene pingata (`server.ping`) ogni 60 secondi + (`_PING_INTERVAL_SECONDS`); un ping fallito chiude la sessione. + *Codice:* `app/electrum/listener.py:ElectrumListener._keepalive` +- **[ELC-06]** La chiusura della connessione è un evento osservato: la + sessione termina non appena la connessione si chiude, il ping fallisce o + l'elaborazione degli header o delle notifiche degli indirizzi si interrompe + con un errore. Il listener non resta quindi in attesa di notifiche che non + arriveranno mai mentre sembra ancora connesso; a sessione finita il client + non è più disponibile. + *Codice:* `app/electrum/listener.py:ElectrumListener._run_once`, `app/electrum/client.py:ElectrumClient.wait_closed` · + *Test:* `tests/unit/test_electrum_client.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 + del contenuto binario) e non si conosce ancora alcuna altezza, la sessione + viene rifiutata e si passa al server successivo. Chi trova il client assente + (ad esempio durante una riconnessione) salta semplicemente quel giro, e + scommesse e prelievi rispondono `network_unavailable`. + *Codice:* `app/electrum/listener.py:ElectrumListener._run_once` · + *Test:* `tests/unit/test_electrum_listener.py` + +## 4. Header validati, non creduti + +Ogni nuovo header di blocco — quello ricevuto all'iscrizione e ogni successivo +annunciato dal server — viene controllato prima di essere accettato. + +Tre casi vengono semplicemente **ignorati**, con un avviso nei log e senza +chiudere la sessione, perché non indicano un server ostile: + +- **[ELC-08]** un header per un'altezza **inferiore** a quella nota: l'altezza + non torna mai indietro; +- **[ELC-09]** un header per l'altezza **già nota** (lo stesso blocco + riannunciato, una riorganizzazione in punta, o un server in disaccordo): + l'hash già adottato per quell'altezza non viene mai sostituito; se fosse + quello sbagliato, è la corroborazione dell'estrazione a scartarlo; +- **[ELC-10]** un header privo del contenuto binario: non c'è nulla da + validare né da usare per l'estrazione. + +*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · +*Test:* `tests/unit/test_electrum_listener.py` + +Due controlli invece, se falliscono, **chiudono la sessione** e fanno passare +a un altro server: quell'header è l'unica fonte di casualità dell'estrazione, +e uno falsificato sceglierebbe il vincitore. + +- **[ELC-11]** L'header deve essere lungo 80 byte e il suo hash deve + rispettare la difficoltà che l'header stesso dichiara (campo `bits`). + *Codice:* `app/rounds/draw.py:header_meets_its_own_target`, `app/electrum/listener.py:ElectrumListener._apply_header` · + *Test:* `tests/unit/test_draw.py`, `tests/unit/test_electrum_listener.py` +- **[ELC-12]** Un avanzamento di **un solo** blocco deve agganciarsi all'hash + del blocco corrente. Per un salto di più blocchi questo controllo non è + possibile senza la catena intera, e vale solo quello di difficoltà. + *Codice:* `app/rounds/draw.py:header_prev_hash`, `app/electrum/listener.py:ElectrumListener._apply_header` · + *Test:* `tests/unit/test_draw.py`, `tests/unit/test_electrum_listener.py` +- **[ELC-13]** Un header accettato aggiorna insieme altezza e contenuto della + punta (mai uno senza l'altro). Ogni notifica di nuovi header invia una + notifica SSE, perché è proprio ciò che l'estrazione attende. + *Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/listener.py:ElectrumListener._consume_headers` · + *Test:* `tests/unit/test_electrum_listener.py` + +Il controllo di difficoltà respinge un header mai minato, ma non uno minato +davvero a una difficoltà dichiarata irrealisticamente bassa: per questo il +blocco dell'estrazione viene anche corroborato ([ELC-15]). + +## 5. Il quorum + +Ogni decisione che sposta denaro viene confermata dagli **altri** server +configurati, mai da quello attivo (che è proprio quello che un attaccante +controllerebbe). + +- **[ELC-14]** La corroborazione interroga in parallelo tutti i server + configurati tranne quello attivo, ciascuno con una connessione nuova e un + limite di 10 secondi (`_CORROBORATION_TIMEOUT_SECONDS`) per collegarsi e 10 + per rispondere. Passa se concorda la **maggioranza stretta dei server che + hanno risposto**; un server che non risponde o dà errore non conta. + - **Nessun server di riserva configurato** → la verifica passa. È il rischio + accettato di un `ELECTRUM_FALLBACK_SERVERS` vuoto: in produzione conviene + configurarne più d'uno (vedi [setup.md](setup.md)). + - **Nessuno risponde** → la verifica **fallisce**: una rete irraggiungibile + non prova nulla. + + *Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` · + *Test:* `tests/unit/test_electrum_listener.py` + +| ID | Prima di… | Si verifica che… | +|---|---|---| +| **[ELC-15]** | usare un blocco per l'estrazione | l'header che l'altro server restituisce per quell'altezza (`blockchain.block.header`) abbia lo stesso hash | +| **[ELC-16]** | togliere dal saldo una moneta sparita | l'altro server non elenchi più la moneta tra quelle non spese | +| **[ELC-17]** | accreditare un deposito | l'altro server elenchi la moneta tra quelle non spese, con lo stesso importo e confermata (altezza maggiore di 0) | + +*Codice:* `app/electrum/listener.py:ElectrumListener.corroborate_header`, +`app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent`, +`app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` · +*Test:* `tests/unit/test_electrum_listener.py` + +I saldi si muovono in entrambe le direzioni, quindi entrambe richiedono lo +stesso quorum. Le conseguenze di una verifica non riuscita: + +- un accredito non verificato è solo **rimandato**, non perso: la moneta viene + riproposta al controllo successivo + ([DEP-04](registrazione-e-depositi.md#deposito)); +- una moneta sparita non verificata resta nel saldo + ([DEP-07](registrazione-e-depositi.md#deposito)); +- un blocco non verificato fa attendere il blocco seguente, e lo si registra + in audit (`draw_header_corroboration_failed`, + [RND-16](ciclo-di-vita-del-round.md#3--estrazione)). + +Il reintegro di una moneta ricomparsa +([DEP-08](registrazione-e-depositi.md#deposito)) e la lettura delle monete del +montepremi per il pagamento si basano invece sul solo server attivo. + +## Riconnessione + +- **[ELC-18]** Alla riconnessione il listener si reiscrive agli indirizzi di + tutti gli utenti (letti dal database) e ne aggiorna le monete, in + un'attività separata, al massimo 20 alla volta (`_RESUBSCRIBE_CONCURRENCY`): + in questo modo l'altezza della catena — e con essa un'eventuale estrazione + in corso — continua ad aggiornarsi durante la reiscrizione. Un utente la cui + reiscrizione fallisce viene registrato nei log e saltato; lo riprende il + giro periodico di `DepositReconciler` + ([DEP-10](registrazione-e-depositi.md#dep-10)). Se la sessione termina, + la reiscrizione in corso viene interrotta. + *Codice:* `app/electrum/listener.py:ElectrumListener._subscribe_all_users`, `app/electrum/listener.py:ElectrumListener._run_once` · + *Test:* `tests/unit/test_electrum_listener.py` + +Lo stato della connessione e l'altezza corrente sono visibili nella barra di +stato della catena di `/` (da `chain_tip_height` di `GET /rounds/current`) e +nell'indicatore Electrum di `/admin`. diff --git a/docs/decisioni-e-limiti.md b/docs/decisioni-e-limiti.md new file mode 100644 index 0000000..1dadf5b --- /dev/null +++ b/docs/decisioni-e-limiti.md @@ -0,0 +1,944 @@ +# Decisioni di progetto e limiti noti + +Questo documento raccoglie due cose che non si ricavano leggendo un singolo +file del codice: + +- **le decisioni di progetto (`DEC`)**: scelte deliberate, con il motivo, le + alternative scartate dove sono note e le conseguenze. Chi vuole cambiarne + una deve saperlo prima di toccare il codice, perché altri requisiti ne + dipendono; +- **i limiti noti (`LIM`)**: difetti di copertura o di robustezza accettati + consapevolmente, con l'impatto, ciò che oggi li attenua e cosa servirebbe + per chiuderli. + +Un limite noto **non è un bug**: un bug è un comportamento che contraddice la +specifica e prende un numero `B-nn` ([storico-bug.md](storico-bug.md)). Se si +decide di chiudere un limite, si aggiorna prima la voce `LIM` qui (o la si +barra come rimossa) e poi il requisito del documento di dominio, secondo +[processo-di-modifica.md](processo-di-modifica.md). + +Ogni voce rimanda al codice che la incarna. I dettagli di comportamento stanno +nei documenti di dominio, a cui si rimanda invece di ripeterli. + +--- + +## Parte 1 — Decisioni di progetto (DEC) + +Formato di ogni voce: **decisione** in grassetto, poi *Motivazione*, +*Alternative scartate* (dove note), *Conseguenze*, e i riferimenti. + +### Custodia, chiavi e indirizzi + +- **[DEC-01]** **La piattaforma è custodiale: tutte le chiavi sono generate e + tenute dal server.** Una sola master xprv, cifrata con Fernet a riposo; la + chiave di cifratura sta in una variabile d'ambiente (`XPRV_ENCRYPTION_KEY`), + mai nel database né in git. L'utente non controlla nulla finché non preleva. + - *Motivazione:* un'esperienza da «conto» (registrazione con username e + password, scommessa con un clic) senza portafoglio lato utente, firme o + PSBT da gestire nel browser. + - *Alternative scartate:* portafoglio non custodiale con firma lato client. + - *Conseguenze:* la compromissione del server (file cifrato + variabile + d'ambiente) espone tutti i fondi; da qui l'esportazione WIF accettata + ([DEC-39]), l'assenza di recupero password ([DEC-37]) e il modello di + minaccia di [sicurezza.md](sicurezza.md). + + *Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv` · `app/wallet/hd.py:generate_master_key` · + *Test:* `tests/unit/test_hd.py` + +- **[DEC-02]** **Un indirizzo per utente, permanente, per tutti gli usi.** + Alla registrazione il server deriva un indirizzo P2WPKH BIP84 + (`m/84'/746'/0'/0/`, un indice per utente, assegnato come massimo + esistente + 1). Lo stesso indirizzo è indirizzo di deposito, riceve le + vincite e riceve il resto di ogni scommessa e prelievo. Non esiste un + «indirizzo vincitore» separato. + - *Motivazione:* un solo indirizzo da osservare per utente, un solo saldo da + ricostruire (`utxo_events`), e le vincite accreditate dal normale percorso + dei depositi senza logica dedicata. + - *Conseguenze:* nessuna privacy fra depositi, vincite e resti; un prelievo + verso il proprio indirizzo è rifiutato (renderebbe indistinguibili + destinatario e resto); il pagamento di un round si considera accreditato + quando il listener vede la moneta sull'indirizzo del vincitore + (vedi [registrazione-e-depositi.md](registrazione-e-depositi.md)). + + *Codice:* `app/auth/routes.py:register` · `app/wallet/hd.py:derive_user_address` · `app/rounds/confirmation.py:_on_payout_confirmed` · + *Test:* `tests/unit/test_hd.py`, `tests/unit/test_users.py` + +- **[DEC-03]** **Il montepremi è un unico indirizzo, derivato dalla stessa + master key sul ramo `1/0` dell'account** (il ramo `0` è riservato agli + utenti). Tutte le scommesse di tutti i round arrivano lì e il pagamento è + firmato con quella chiave. + - *Motivazione:* nessun segreto aggiuntivo da custodire. + - *Conseguenze:* i round condividono lo stesso insieme di monete; i resti di + polvere e le monete avanzate si accumulano sull'indirizzo senza + consolidamento automatico ([LIM-17]). + + *Codice:* `app/wallet/hd.py:derive_pool_key` · `app/wallet/hd.py:derive_pool_address` · + *Test:* `tests/unit/test_pending_address.py` + +- **[DEC-04]** **Accesso alla rete solo via protocollo Electrum**, senza nodo + completo né P2P. Una sola connessione attiva serve tutto (accrediti, invii, + conferme, altezza della catena). + - *Motivazione:* nessun nodo da installare e sincronizzare. + - *Conseguenze:* la connessione è il principale punto singolo di guasto e il + principale rischio di integrità; da qui le difese di [DEC-07] e + [DEC-26] e i limiti [LIM-11], [LIM-12], [LIM-14]. Dettagli in + [connessione-electrum.md](connessione-electrum.md). + + *Codice:* `app/electrum/client.py:ElectrumClient` · `app/electrum/listener.py:ElectrumListener` · `app/main.py:lifespan` + +- **[DEC-05]** **Solo indirizzi bech32 PLM (`plm1…`) come destinazione.** Un + prelievo verso un indirizzo legacy (`P…`) o di un'altra catena (`bc1…`) è + rifiutato; lo stesso controllo vale per `fee_address` e per `/qr/{address}`. + - *Motivazione:* un indirizzo di un'altra catena si interpreta comunque come + witness program valido, quindi la transazione si costruirebbe e i fondi + sarebbero irrecuperabili. + + *Codice:* `app/wallet/address.py:is_valid_plm_address` · `app/withdrawals/service.py:request_withdrawal` · `app/api/routes/admin.py:RoundConfigUpdate` · + *Test:* `tests/unit/test_withdrawals.py::test_withdrawal_rejects_non_plm_address` + +### Conferme e fiducia nella rete + +- **[DEC-06]** **Una conferma per ogni tipo di transazione** (deposito, + scommessa, pagamento, prelievo), senza logica di rollback in caso di reorg. + Non si introducono soglie diverse (3, 6…) senza una decisione esplicita. + - *Motivazione:* blocchi da 120s e importi da lotteria; un round + attende già tre blocchi in sequenza (vedi + [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md)). + - *Conseguenze:* rischio di reorg a 1 conferma accettato ([LIM-10]). + + *Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates` · + *Test:* `tests/unit/test_confirmation.py::test_unconfirmed_tx_is_left_pending`, `tests/unit/test_deposits.py::test_unconfirmed_entry_is_ignored` + +- **[DEC-07]** **Ogni decisione che sposta denaro in base a una risposta di + rete è corroborata da un quorum degli altri server configurati**: il blocco + che semina l'estrazione, una moneta data per spesa all'esterno, un nuovo + deposito da accreditare. Si interrogano solo gli *altri* server, mai quello + attivo (che è ciò che un attaccante controllerebbe); serve la maggioranza + stretta di chi risponde. Senza server di riserva configurati la + corroborazione passa sempre; se nessuno risponde, fallisce. + - *Motivazione:* un solo server ostile non deve poter scegliere il + vincitore (B-28), azzerare un saldo (B-29) o gonfiarlo (B-59). I saldi si + muovono in entrambe le direzioni, quindi entrambe richiedono il quorum. + - *Conseguenze:* una corroborazione fallita **ritarda**, non perde: il + deposito è riproposto al refresh successivo, l'estrazione attende un blocco + ulteriore. Lista vuota di server di riserva = fiducia nel solo server + primario ([LIM-11]). Non tutte le letture sono corroborate ([LIM-12]). + + *Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` · `app/electrum/listener.py:ElectrumListener.corroborate_header` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` · + *Test:* `tests/unit/test_electrum_listener.py` + +### Configurazione e ambiente + +- **[DEC-08]** **I parametri operativi stanno nel database, i segreti + nell'ambiente.** Ogni parametro di business (costo della scommessa, durata e + pausa del round, animazione, fee rate, timeout RBF, `fee_address`, pausa) è + una colonna dell'unica riga di `round_config`, modificabile a caldo da + `/admin`; i valori iniziali sono i default delle colonne di `RoundConfig`, + non `app/config.py`. In `.env` restano solo segreti e collegamenti + (master key, JWT, host Electrum, token admin, URL del database). + - *Motivazione:* regolare la lotteria senza ridistribuire né riavviare; + i segreti richiedono comunque un riavvio e non vanno scambiati a caldo. + - *Conseguenze:* ogni modifica è validata con limiti espliciti ([DEC-09]) e + registrata in audit come `config_updated` con prima/dopo. + + *Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` · `app/config.py:Settings` · `app/api/routes/admin.py:update_config` · + *Test:* `tests/unit/test_admin.py::test_admin_reads_and_updates_config`, `tests/unit/test_admin.py::test_config_update_is_audit_logged` + +- **[DEC-09]** **Le modifiche di configurazione sono validate, non fidate.** + Ogni campo numerico ha limiti (ad esempio `fee_rate_sat_vb` fra 1 e + `MAX_FEE_RATE_SAT_VB`, `round_duration_seconds` almeno 30) e `fee_address` + deve essere un indirizzo PLM valido. `paused` non è modificabile da + `PUT /admin/config`. + - *Motivazione:* un fee rate a 0 produce transazioni che nessun nodo inoltra, + una durata a 0 fa scadere il round appena aperto, un `fee_address` estraneo + manda la commissione in uno script irrecuperabile (B-05). + + *Codice:* `app/api/routes/admin.py:RoundConfigUpdate` · + *Test:* `tests/unit/test_admin.py::test_config_rejects_unusable_values`, `tests/unit/test_admin.py::test_pause_cannot_be_toggled_through_the_config_endpoint` + +- **[DEC-10]** **I tempi del round sono fotografati all'apertura.** Durata e + pausa successiva vengono copiate sulla riga del round quando si apre; una + modifica vale dal round successivo, mai per quello in corso. La pausa fra + due round è quella annunciata dal round appena chiuso. + - *Motivazione:* abbassare la durata a metà round lo chiudeva all'istante e + spostava il conto alla rovescia di tutti i client (B-61). + - *Conseguenze:* **solo i tempi** sono fotografati. Costo della scommessa, + fee rate e `fee_address` sono letti dal vivo ([LIM-16]). + + *Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:round_deadline` · + *Test:* `tests/unit/test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing`, `tests/unit/test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`, `tests/unit/test_rounds_service.py::test_cooldown_comes_from_the_round_that_closed` + +- **[DEC-11]** **Il server rifiuta di partire con segreti inadeguati**: + `JWT_SECRET` sotto i 32 caratteri o `XPRV_ENCRYPTION_KEY` vuota fermano + l'avvio. Un `ADMIN_TOKEN` vuoto invece non è fatale: il pannello resta + chiuso a tutti. Il controllo è nel lifespan, non in un validatore di + `Settings`. + - *Motivazione:* evitare un container che sembra sano e si rompe al primo + utente reale; tenerlo fuori da `Settings` permette a import e test di + funzionare senza `.env`. + - *Alternative scartate:* validatore Pydantic su `Settings` (avrebbe fatto + fallire la raccolta dei test su un clone pulito, B-15). + + *Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` · + *Test:* `tests/unit/test_config.py` + +- **[DEC-12]** **Il server gira solo in Docker, in sviluppo come in + produzione**, dietro Caddy (TLS automatico, certificato autofirmato su + `localhost`). Il container rifiuta di partire senza il file della master + key, applica le migrazioni Alembic all'avvio e si riavvia da solo + (`restart: unless-stopped`). Il venv serve solo per test, migrazioni e + script della chiave. + - *Motivazione:* un solo modo di eseguire il server, identico ovunque. + - *Conseguenze:* dopo un crash `closing` e `paying_out` riprendono da soli, + `drawing` no ([LIM-01]). Procedure in [operativita.md](operativita.md). + + *Codice:* `Dockerfile` · `docker-compose.yml` · `Caddyfile` · + *Test:* `tests/unit/test_docs_no_bare_uvicorn.py` + +- **[DEC-13]** **Un solo processo, un solo file SQLite** (modalità WAL con + `busy_timeout`). Canale SSE, blocchi per-utente, limitatori di tentativi e + cache sono in memoria. + - *Motivazione:* semplicità a scala di ~100 utenti concorrenti. + - *Alternative scartate:* Redis o un database con lock condivisi, da non + introdurre in via speculativa. + - *Conseguenze:* nessuna distribuzione su più worker ([LIM-18]). L'unico + invariante che non dipende dal processo singolo è «al più un round + attivo», garantito da un indice ([DEC-22]). + + *Codice:* `app/db/base.py:_register_sqlite_pragmas` · `app/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` · + *Test:* `tests/unit/test_db_base.py` + +### Economia del gioco + +- **[DEC-14]** **Ripartizione fissa 70% al vincitore / 30% alla piattaforma**, + con l'arrotondamento a favore delle commissioni. È nel codice, non nella + configurazione: cambiarla è una modifica del software. + - *Motivazione:* una regola economica di fondo non deve poter cambiare con un + clic dal pannello. Un'unica funzione alimenta sia il montepremi mostrato sia + il pagamento, così i due non possono divergere. + + *Codice:* `app/rounds/service.py:winner_share` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/api/routes/rounds.py:current_round` · + *Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share` + +- **[DEC-15]** **Biglietto a prezzo fisso, una scommessa per utente per round, + probabilità uguale per tutti** indipendentemente dall'importo effettivo. + Nessun deposito minimo. + - *Conseguenze:* l'estrazione sceglie fra indirizzi, non pesa gli importi. + Poiché il costo è letto dal vivo, una modifica a metà round crea poste + diverse con la stessa probabilità ([LIM-16]). + + *Codice:* `app/bets/service.py:place_bet` · `app/rounds/draw.py:draw_winner` · + *Test:* `tests/unit/test_bets.py::test_place_bet_rejects_second_bet_same_round` + +- **[DEC-16]** **La commissione di rete si toglie dall'importo che si + muove**, non si aggiunge sopra: per una scommessa arriva al montepremi il + costo meno la commissione, per un prelievo arriva a destinazione l'importo + meno la commissione. + - *Conseguenze:* il montepremi si calcola sommando gli importi effettivi + delle scommesse, non moltiplicando il numero di giocatori per il costo + (B-11); il prelievo registra sia l'importo richiesto sia quello inviato. + + *Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · + *Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_deducts_fee_from_amount_not_change` + +- **[DEC-17]** **La commissione del pagamento esce dalla quota del + vincitore**; il 30% della piattaforma resta intatto. + - *Conseguenze:* il montepremi annunciato (il 70% delle scommesse + confermate) supera di poco quanto il vincitore riceve davvero; la + differenza, la commissione del pagamento, non è nota finché il pagamento non + è costruito. + + *Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` · + *Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share` + +- **[DEC-18]** **L'accelerazione RBF la paga chi riceve il resto**: l'utente + per scommesse e prelievi, il montepremi per i pagamenti. Le uscite verso la + controparte (montepremi, indirizzo esterno, vincitore, `fee_address`) non si + toccano mai; si riduce solo il resto del mittente. + - *Conseguenze:* una transazione senza resto non si può accelerare + ([LIM-02]). + + *Codice:* `app/tx/broadcast.py:bump_fee` · `app/tx/broadcast.py:_find_change_output` · + *Test:* `tests/unit/test_broadcast.py` + +- **[DEC-19]** **Ogni transazione utente conserva un'uscita di resto di almeno + `DUST_LIMIT_SATS` (294 sat)**, così resta accelerabile. Chi preleva tutto + sposta il saldo meno 294 sat, che restano sul conto (e la commissione di rete + esce, come sempre, dall'importo spostato); una scommessa da un saldo + pari al costo è rifiutata con `balance_leaves_no_change`, perché il prezzo + fisso non si può ridurre. + - *Motivazione:* un prelievo totale produceva una transazione a un'uscita + che nessun RBF poteva salvare (B-62). Aggiungere monete non avrebbe + aiutato: la transazione le spendeva già tutte. + - *Conseguenze:* **il pagamento del round fa eccezione**: un resto del + montepremi sotto la soglia va alla commissione e, nel caso normale, il + pagamento non ha resto ([LIM-02]). + + *Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · `app/withdrawals/service.py:request_withdrawal` · + *Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused` + +- **[DEC-20]** **Il prelievo minimo è il costo corrente della scommessa**, senza + un campo di configurazione separato. + - *Motivazione:* un parametro in meno da tenere coerente. + - *Conseguenze:* alzare il costo della scommessa alza anche il prelievo + minimo, con effetto immediato. + + *Codice:* `app/withdrawals/service.py:request_withdrawal` · + *Test:* `tests/unit/test_withdrawals.py::test_withdrawal_rejects_amount_below_minimum` + +- **[DEC-21]** **Tre limiti distinti sul numero di monete e di giocatori:** + + | Costante | Valore | Protegge da | + |---|---|---| + | `MAX_TX_INPUTS` | 50 | una transazione utente (scommessa, prelievo) che erode l'importo in commissioni per un indirizzo troppo frammentato; oltre, `too_many_inputs` | + | `MAX_PAYOUT_TX_INPUTS` | 500 | un pagamento troppo grande per le regole di inoltro (~1470 ingressi a 100 kvB); un terzo del margine | + | `MAX_PARTICIPANTS_PER_ROUND` | 400 | un round impagabile: il 401° giocatore è rifiutato con `round_full` **prima** che si muova denaro | + + - *Motivazione:* il pagamento spende una moneta del montepremi per scommessa; + usare per esso il limite utente rendeva impagabile ogni round oltre ~50 + giocatori e fermava la lotteria (B-52). I 100 di differenza fra 400 e 500 + lasciano spazio alle monete avanzate da round precedenti. + - *Conseguenze:* «ogni round si può pagare» è un invariante verificato + all'ingresso, non una scoperta al momento del pagamento. Il conteggio dei + partecipanti include quelli non ancora confermati (direzione prudente). + + *Codice:* `app/wallet/psbt_builder.py:select_utxos` · `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py:build_payout_transaction` · + *Test:* `tests/unit/test_psbt_builder.py::test_select_utxos_never_exceeds_the_input_cap`, `tests/unit/test_bets.py::test_place_bet_rejects_the_bet_past_the_participant_cap`, `tests/unit/test_payout_builder.py::test_payout_at_the_participant_cap_stays_well_inside_relay_limits` + +### Round ed estrazione + +- **[DEC-22]** **Al più un round attivo, come invariante del database.** Un + indice unico su un'espressione costante, limitato agli stati attivi, fa + fallire in modo pulito il secondo inserimento concorrente; chi perde adotta + il round del vincitore (al massimo 3 tentativi). I round non si + sovrappongono mai: il successivo si apre solo quando il precedente è + `closed`. + - *Alternative scartate:* rendere lo scheduler l'unico autore dei round (la + prima scommessa dopo una pausa non avrebbe potuto aprirne uno, B-09). + + *Codice:* `app/db/models.py:Round` · `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:get_active_round` · + *Test:* `tests/unit/test_rounds_service.py::test_the_database_refuses_a_second_active_round`, `tests/unit/test_rounds_service.py::test_losing_the_open_race_reuses_the_winning_round` + +- **[DEC-23]** **Il limite per scommettere è la scadenza del round stesso, non + il suo stato nel database**, e la chiusura «a semaforo giallo» aspetta che + ogni scommessa già inviata sia confermata prima di estrarre. L'iscrizione è + salvata con un compare-and-set sulla riga del round. + - *Motivazione:* lo scheduler controlla ogni 5s, quindi lo stato può restare + `open` oltre la scadenza; senza il compare-and-set una scommessa poteva + finire nel montepremi ed essere esclusa dall'estrazione (B-53). + - *Conseguenze:* l'attesa in `closing` è limitata solo dall'abbandono di una + scommessa che non conferma mai ([LIM-03]). Dettagli in + [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md). + + *Codice:* `app/rounds/service.py:round_accepts_bets` · `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_bets.py::test_place_bet_refuses_when_the_round_closed_between_the_check_and_the_commit`, `tests/unit/test_scheduler.py::test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check` + +- **[DEC-24]** **L'algoritmo di estrazione è un componente sostituibile, non il + disegno definitivo**: seme = hash del primo blocco confermato dopo la + chiusura, indice = seme modulo numero di partecipanti confermati, ordinati per + orario di invio della scommessa. Non si deve architettare il resto del + sistema attorno alla sua forma attuale. + - *Motivazione:* semplice e deterministico, pensato per essere rimpiazzato. + - *Conseguenze:* vedi [LIM-13] per i limiti di questa forma (entropia + dal minatore, verificabilità, pareggi). + + *Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_draw.py` + +- **[DEC-25]** **Il blocco che semina l'estrazione deve essere successivo alla + chiusura e corroborato.** L'attesa del blocco non ha timeout (l'entropia + dipende davvero da un blocco futuro) ma è osservabile (`draw_stalled`, + `draw_waiting_since`). Un'altezza `0` alla chiusura significa «sconosciuta»: + la prima altezza appresa diventa la base e si attende un blocco ancora + successivo. + - *Motivazione:* un hash già pubblico mentre le scommesse erano aperte + renderebbe il vincitore prevedibile (B-63); un header fabbricato + sceglierebbe il vincitore (B-28). + + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` · `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` · + *Test:* `tests/unit/test_scheduler.py::test_wait_for_next_block_never_seeds_the_draw_from_a_pre_close_block`, `tests/unit/test_scheduler.py::test_wait_for_next_block_retries_after_a_failed_corroboration` + +- **[DEC-26]** **Validazione minima degli header, senza catena completa.** Un + header deve rispettare il bersaglio di difficoltà che dichiara e, se avanza + di un solo blocco, concatenarsi alla punta attuale; la punta non regredisce + mai; un secondo header alla stessa altezza o un header senza `hex` vengono + ignorati senza chiudere la sessione. + - *Motivazione:* bloccare header inventati senza scaricare e validare la + storia delle difficoltà; lasciare al quorum ([DEC-07]) il caso residuo. + - *Alternative scartate:* validazione completa della catena degli header + (fuori scopo per la v1). + - *Conseguenze:* [LIM-14]. + + *Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/draw.py:header_meets_its_own_target` · + *Test:* `tests/unit/test_electrum_listener.py::test_apply_header_rejects_one_that_does_not_chain_from_the_tip`, `tests/unit/test_electrum_listener.py::test_apply_header_ignores_a_competing_header_at_the_current_tip_height` + +- **[DEC-27]** **Pausa di cortesia fra un round e l'altro** + (`round_cooldown_seconds`, 30s), contata dalla **conferma del pagamento**, non + dall'inizio della chiusura, così i giocatori vedono l'esito. + + *Codice:* `app/rounds/confirmation.py:_on_payout_confirmed` · `app/rounds/service.py:open_new_round_if_needed` · + *Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_during_cooldown` + +- **[DEC-28]** **La pausa per manutenzione ferma solo il round successivo.** + È un'azione esplicita dell'operatore con endpoint propri e registrati in + audit (`lottery_paused`, `lottery_resumed`), non un campo di configurazione; + il round in corso chiude, estrae e paga normalmente. + - *Motivazione:* sospendere a metà un round con denaro nel montepremi + creerebbe proprio lo stato bloccato che la pausa vuole evitare. + + *Codice:* `app/api/routes/admin.py:pause_lottery` · `app/api/routes/admin.py:resume_lottery` · `app/rounds/service.py:open_new_round_if_needed` · + *Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_while_paused`, `tests/unit/test_rounds_service.py::test_pause_does_not_interrupt_a_round_in_progress`, `tests/unit/test_admin.py::test_admin_can_pause_and_resume_the_lottery` + +- **[DEC-29]** **Senza `fee_address` non si apre nessun round.** Il campo non ha + valore predefinito: l'operatore deve impostare il proprio. Finché manca, nessun + round si apre (`lottery_configured = false` su `GET /rounds/current`, avviso + in `/admin`); un round già in corso invece chiude, estrae e paga anche se + l'indirizzo viene cancellato. Qualunque altra condizione che renderebbe un + round impagabile va aggiunta nello stesso punto. + - *Motivazione:* un round aperto senza `fee_address` raccoglieva scommesse e + solo al pagamento scopriva di non poter essere pagato (B-66). + + *Codice:* `app/rounds/service.py:rounds_can_open` · `app/rounds/config.py:get_round_config` · + *Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_while_no_fee_address_is_configured`, `tests/unit/test_rounds_service.py::test_a_round_in_progress_survives_the_fee_address_being_cleared`, `tests/unit/test_rounds_route.py::test_lottery_configured_flags_a_missing_fee_address` + +- **[DEC-30]** **Il montepremi annunciato è quello che sarà pagato**: il 70% + delle sole scommesse confermate, affiancato dai valori «in attesa» che + includono le scommesse in volo (valori inclusivi, non differenze). + - *Motivazione:* mai annunciare più di quanto l'estrazione e il pagamento + considereranno (B-65), senza nascondere al giocatore la propria scommessa + appena fatta. + + *Codice:* `app/api/routes/rounds.py:current_round` · + *Test:* `tests/unit/test_rounds_route.py::test_advertised_jackpot_covers_only_the_bets_that_will_be_paid` + +### Denaro in movimento + +- **[DEC-31]** **Tutto ciò che spende denaro si scrive prima di inviarlo** (stato + `building`, monete già segnate come spese) e si risolve contro la catena + dopo; nessuna sessione del database resta aperta durante una chiamata di + rete. Un invio rifiutato annulla tutto e risponde **502**. + - *Motivazione:* un crash fra invio e salvataggio lasciava monete spese in + catena senza traccia (B-08, B-25). Dettagli in + [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md). + + *Codice:* `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/reconcile.py:reconcile_once` · + *Test:* `tests/unit/test_bets.py::test_bet_is_persisted_before_it_is_broadcast`, `tests/unit/test_scheduler.py::test_trigger_payout_persists_before_broadcasting`, `tests/unit/test_reconcile.py` + +- **[DEC-32]** **Il pagamento si ritenta da solo, mai due volte**: un tentativo + ogni 60s, usando l'ultimo `payout_failed` in audit come orologio; prima di + costruire si rifiuta se esiste già un pagamento `building`/`pending` per il + round, e si escludono le monete impegnate da pagamenti non risolti. + + *Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` · `app/rounds/scheduler.py:_reserved_payout_outpoints` · + *Test:* `tests/unit/test_scheduler.py::test_trigger_payout_skips_when_already_in_flight`, `tests/unit/test_scheduler.py::test_tick_throttles_retry_after_a_recent_payout_failure`, `tests/unit/test_scheduler.py::test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payout` + +- **[DEC-33]** **Scommessa e prelievo si escludono solo tramite il blocco + per-utente** sulla *costruzione*: un prelievo è accettato anche mentre una + scommessa attende conferma, purché le monete confermate e libere lo coprano. + - *Motivazione:* il rischio è che scelgano la stessa moneta, già escluso dal + blocco e dal fatto che la selezione salta le monete segnate come spese; + congelare il resto del saldo non aggiungerebbe sicurezza (B-70). + + *Codice:* `app/tx/locks.py:UserLocks` · `app/api/routes/bets.py:create_bet` · `app/api/routes/withdrawals.py:create_withdrawal` · + *Test:* `tests/unit/test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed` + +- **[DEC-34]** **Saldo spendibile e saldo mostrato sono due numeri distinti.** + Si spende solo il confermato (`balance_sats`); si mostra quello che include i + resti ancora in volo (`pending_balance_sats`, in ambra finché non conferma). + Un prelievo coperto solo dal secondo riceve `balance_pending_confirmation`. + - *Motivazione:* la selezione a monete intere sottrae l'intera moneta al + saldo confermato già all'invio, molto più di quanto si stia spostando. + + *Codice:* `app/wallet/balance.py:compute_pending_balance` · `app/wallet/balance.py:recompute_balance` · `app/api/routes/users.py:me` · + *Test:* `tests/unit/test_balance.py`, `tests/unit/test_withdrawals.py::test_withdrawal_distinguishes_pending_from_truly_insufficient_balance` + +- **[DEC-35]** **Aritmetica solo in satoshi interi**, anche per i valori letti + dalla rete: l'importo di una moneta spesa si ricava dalla transazione grezza, + non dal valore decimale restituito dal server. + + *Codice:* `app/tx/broadcast.py:_prevout_amount` · `app/wallet/psbt_builder.py:estimate_vsize` · + *Test:* `tests/unit/test_broadcast.py::test_bump_fee_holds_no_session_open_during_network_calls` + +### Account, amministrazione e sicurezza + +- **[DEC-36]** **Password con Argon2, sessioni con JWT HS256 da 24 ore, + revocabili.** Il token porta la versione dell'account (`tv`); cambio password + e reset admin la incrementano e invalidano tutte le sessioni precedenti. Un + token senza `tv` vale come versione 0, così il rilascio non ha disconnesso + nessuno. Argon2 gira fuori dall'event loop. + - *Motivazione:* un reset per account compromesso deve buttare fuori subito + l'attaccante (B-34); Argon2 inline bloccava l'intero processo, attività in + background comprese (B-55). + + *Codice:* `app/auth/security.py:create_access_token` · `app/auth/security.py:decode_access_token` · `app/auth/dependencies.py:get_current_user` · `app/auth/security.py:verify_password_async` · + *Test:* `tests/unit/test_security.py`, `tests/unit/test_users.py::test_change_password_invalidates_the_old_token_but_not_the_new_one` + +- **[DEC-37]** **Nessun recupero password, solo reset.** Non si raccoglie + nessuna email. L'utente autenticato può *cambiare* la password fornendo + quella attuale; chi l'ha persa si rivolge all'operatore, che con + `POST /admin/users/{id}/reset-password` ne genera una casuale, mostrata una + volta sola e registrata in audit (`admin_password_reset`). + + *Codice:* `app/api/routes/users.py:change_password` · `app/api/routes/admin.py:reset_user_password` · + *Test:* `tests/unit/test_users.py::test_change_password_requires_current_password`, `tests/unit/test_admin.py::test_admin_resets_user_password` + +- **[DEC-38]** **Gli username sono un unico spazio di nomi senza distinzione fra + maiuscole e minuscole**, garantito da un indice unico su `lower(username)`; + il nome resta salvato come digitato (è ciò che mostrano `/admin` e l'audit). + Sono ammessi solo caratteri ASCII `[A-Za-z0-9_.-]`, 3–32. La migrazione si + rifiuta di partire se due account esistenti differiscono solo per le + maiuscole, invece di sceglierne uno da rinominare: entrambi potrebbero avere + fondi. + - *Motivazione:* «Bob» e «bob» erano due account che condividevano il + contatore dei tentativi e un comodo vettore di impersonazione (B-57). + + *Codice:* `app/db/models.py:User` · `app/auth/routes.py:register` · `app/auth/routes.py:login` · + *Test:* `tests/unit/test_auth.py::test_registration_refuses_a_username_differing_only_by_case`, `tests/unit/test_auth.py::test_the_database_itself_rejects_a_case_variant` + +- **[DEC-39]** **L'esportazione della chiave privata WIF di un utente è + voluta, non una vulnerabilità.** Il server possiede già la master key: + l'endpoint espone via API solo ciò che un operatore potrebbe comunque + ottenere con uno script (ad esempio per recuperare fondi bloccati). Ogni + accesso scrive `admin_privkey_accessed`, e quella registrazione non va tolta. + + *Codice:* `app/api/routes/admin.py:user_privkey` · `app/wallet/hd.py:derive_user_wif` · + *Test:* `tests/unit/test_admin.py::test_admin_exports_user_privkey` + +- **[DEC-40]** **L'amministrazione usa un unico token condiviso** + (`X-Admin-Token` confrontato a tempo costante sui byte UTF-8 con + `ADMIN_TOKEN`), non un login per operatore. `/admin` mostra una schermata di + inserimento del token, **non è collegato da `/`** (né `/` da `/admin`) ed è in + italiano, non tradotto. + - *Conseguenze:* l'audit registra *cosa* è cambiato, mai *chi* l'ha fatto + ([LIM-06]). + + *Codice:* `app/api/routes/admin.py:require_admin` · `app/main.py:admin_panel` · `app/static/admin.html` · + *Test:* `tests/unit/test_admin.py::test_admin_requires_token`, `tests/unit/test_admin.py::test_admin_rejects_non_ascii_token_with_403_not_500` + +- **[DEC-41]** **Limitazione dei tentativi solo su login e registrazione**, in + memoria. Il login usa un ritardo esponenziale per username (dopo 5 errori) e + per IP (dopo 20, soglia più alta per non punire una rete condivisa); un + login riuscito azzera solo il contatore dello username. La registrazione usa + invece una **quota** di 5 account per IP all'ora, consumata solo dagli + account creati davvero. I contatori sono limitati in numero. + - *Motivazione:* indovinare una password e creare account in massa sono due + problemi diversi (B-33, B-58); il ritardo punisce i tentativi, la quota + limita la produzione. + - *Conseguenze:* nessun limite su scommesse, prelievi, admin, cambio + password ([LIM-07]). + + *Codice:* `app/auth/rate_limit.py:AuthRateLimiters` · `app/auth/rate_limit.py:RateLimiter` · `app/auth/rate_limit.py:RollingQuota` · `app/auth/routes.py:login` · + *Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py` + +- **[DEC-42]** **L'IP del client è l'ultimo elemento di `X-Forwarded-For`**, e + Caddy sovrascrive l'header con il vero indirizzo del peer. Un solo proxy + fidato sta davanti all'applicazione, che non è esposta direttamente. + - *Motivazione:* il primo elemento è scelto dal client; leggerlo rendeva + aggirabile ogni controllo per IP (B-54). Basta una delle due misure. + + *Codice:* `app/api/client_ip.py:client_ip` · `Caddyfile` · + *Test:* `tests/unit/test_client_ip.py`, `tests/unit/test_caddyfile_security_headers.py::test_forwarded_for_is_overwritten_with_the_real_peer` + +- **[DEC-43]** **Header di sicurezza nel proxy, con `'unsafe-inline'` + accettato**: HSTS, `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy` e una + CSP `default-src 'self'` più Google Fonts. Script e stili inline restano + permessi perché entrambe le SPA usano `onclick` e `style=""`; rimuoverli è un + refactoring a parte. La documentazione automatica dell'API (`/docs`, + `/redoc`, `/openapi.json`) è spenta per default (`ENABLE_API_DOCS`). + - *Conseguenze:* [LIM-09]. + + *Codice:* `Caddyfile` · `app/main.py` · `app/config.py:Settings` · + *Test:* `tests/unit/test_caddyfile_security_headers.py`, `tests/unit/test_docs_disabled.py` + +- **[DEC-44]** **`/qr/{address}` è pubblico ma limitato**: nessuna + autenticazione (lo usa un ``, che non può portare il token), ma + l'indirizzo è validato davvero, la lunghezza è limitata, la resa è in cache + (512 voci) e fuori dall'event loop. + + *Codice:* `app/api/routes/qr.py:address_qr` · + *Test:* `tests/unit/test_qr.py` + +- **[DEC-45]** **Segnalazioni di bug aperte a tutti, triage in admin.** Si può + inviare una segnalazione da autenticati (viene attribuita all'utente) o no; + il testo va scritto in inglese, e la pagina lo dice nella lingua del + visitatore. Gli stati sono `open` → `read` → `resolved`, e ogni cambio reale + di stato è registrato in audit. + + *Codice:* `app/api/routes/bug_reports.py:create_bug_report` · `app/api/routes/admin.py:update_bug_report_status` · + *Test:* `tests/unit/test_bug_reports.py` + +### Interfacce, API e lingue + +- **[DEC-46]** **Il canale SSE è un'aggiunta al polling, non un sostituto.** + Nessun contenuto e nessuna autenticazione: è solo un «è cambiato qualcosa, + ricarica»; la personalizzazione resta negli endpoint REST autenticati. Un + unico segnale per tutti (non per utente), una coda da 1 elemento per client + così le notifiche ridondanti si fondono, tetto di 500 connessioni globali e 5 + per IP (oltre, si chiude la connessione più vecchia dello stesso IP). Le + pagine ricaricano anche all'evento `open` di ogni riconnessione. + - *Alternative scartate:* canale per utente (servirebbe autenticazione sul + flusso e sapere a chi interessa ogni evento); Redis pub/sub (solo quando ci + saranno più processi). + - *Conseguenze:* se il canale cade o è bloccato, le pagine continuano a + funzionare con il solo polling. + + *Codice:* `app/rounds/events.py:RoundEventBroadcaster` · `app/api/routes/rounds.py:round_stream` · + *Test:* `tests/unit/test_round_events.py` + +- **[DEC-47]** **L'API parla una sola lingua e risponde con codici.** Ogni + errore rivolto all'utente ha `detail = {code, message, params}`: `message` è + in inglese per chi non usa la dashboard, `code` è ciò che il frontend traduce + (`error.`), `params` porta i valori (gli importi come `*_sats`). Anche + l'errore 500 generico ha questa forma (`internal_error`) e non espone il + testo dell'eccezione. + - *Conseguenze:* aggiungere un errore significa dargli un codice e + aggiungere `error.` in tutte le 7 lingue (vedi [api.md](api.md)). + + *Codice:* `app/api/errors.py:ApiError` · `app/api/errors.py:http_error` · `app/main.py:log_unhandled_exception` · + *Test:* `tests/unit/test_rounds_route.py::test_unhandled_errors_use_the_structured_detail_shape` + +- **[DEC-48]** **Le pagine per l'utente (`/` e `/report-bug`) sono in 7 lingue** + (en, it, es, fr, de, ru, zh) da un'unica tabella piatta, senza build né + download. Lingua scelta da `localStorage.plm_lang`, poi dal browser, poi + inglese; nessun'altra lingua di riserva oltre l'inglese. Il selettore di `/` + sta nella barra di stato della catena, non nella barra di navigazione + (nascosta prima del login). Un elemento è tradotto *o* dagli attributi + `data-i18n` *o* dal codice con `t()`, mai da entrambi. Tutte le lingue devono + avere esattamente le stesse chiavi. + - *Conseguenze:* la parità delle chiavi non è verificata da un test + ([LIM-21]). + + *Codice:* `app/static/i18n.js:t` · `app/static/i18n.js:applyStaticTranslations` · `app/static/i18n.js:setLanguage` · `app/static/app.js:onLanguageChange` + +- **[DEC-49]** **Il pannello `/admin` e la pagina `/guida` non sono tradotti** + (sono in italiano, per l'operatore). + + *Codice:* `app/static/admin.html` · `app/static/guida.html` + +- **[DEC-50]** **Frontend statici, senza framework né build**: due SPA servite + direttamente da FastAPI con `Cache-Control: no-store`, che parlano con la + stessa API JSON (nessuna separazione fra API utente e admin oltre al + controllo del token). + + *Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` · `app/static/app.js` · `app/static/admin.js` + +--- + +## Parte 2 — Limiti noti (LIM) + +Formato di ogni voce: descrizione, *Impatto*, *Mitigazione attuale*, *Per +chiuderlo*, e il codice coinvolto. + +### Quali limiti bloccano il funzionamento non presidiato + +| Limite | Blocca il non presidiato? | +|---|---| +| [LIM-01] `drawing` non riprende dopo un riavvio | **Sì** — è lo stato che resta fermo con denaro in gioco, e l'ultimo prerequisito dichiarato | +| [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** | +| [LIM-03] Attesa in `closing` fino a 6 ore | **Sì, in caso di congestione** (conseguenza di LIM-02) | +| [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** — da fare prima di lasciare il sistema da solo | +| [LIM-06] Token admin unico | No per il funzionamento, ma è il rischio di sicurezza più ampio | +| tutti gli altri | No | + +### Robustezza del ciclo di vita + +- **[LIM-01]** **Lo stato `drawing` non riprende dopo un riavvio.** Lo + scheduler gestisce `open`, `closing` e `paying_out` a ogni giro; l'attesa del + blocco (`_wait_for_next_block`) vive solo dentro la chiamata a + `_close_and_draw` che l'ha avviata, con la lista dei partecipanti in memoria. + Se il processo si ferma in quel momento, nessuno la riprende. Il terzo audit + (2026-08-03) lo ha ribadito come problema **critico**, senza numerarlo + perché già elencato qui. + - *Impatto:* il round resta in `drawing` per sempre con il montepremi dentro; + poiché un nuovo round si apre solo a round chiuso, **l'intera lotteria si + ferma**. Con `restart: unless-stopped` un crash in quella finestra è + esattamente il caso che non si ripara da solo. + - *Mitigazione attuale:* l'attesa è osservabile — log periodici, evento + `draw_stalled` dopo 360s (e poi a ogni intervallo), `draw_waiting_since` su + `GET /rounds/current`. Il recupero è manuale (vedi + [operativita.md](operativita.md)). + - *Per chiuderlo:* salvare sulla riga del round l'altezza di base + dell'estrazione al passaggio in `drawing` e, in `_tick`, rientrare in + `_wait_for_next_block` per un round `drawing` senza un'attesa in corso, + ricostruendo i partecipanti dal database (sono già tutti `confirmed` e + ordinabili per `broadcast_at`). + + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` + +- **[LIM-02]** **L'RBF gestisce una sola forma di transazione**: un'unica uscita + di resto verso il mittente, abbastanza grande da assorbire l'aumento. Non c'è + il ripiego «aggiungi un'altra moneta». Ogni aumento costa almeno 1 sat/vB + sull'intera dimensione della transazione (circa 141 sat per una transazione + con un ingresso e due uscite) e si toglie dal resto; quando l'aumento supera + il resto, `bump_fee` solleva `RbfError`. Inoltre `bump_fee` **non controlla + che il resto ridotto resti sopra la soglia di polvere**: con un resto vicino + al minimo di 294 sat ([DEC-19]) già il primo aumento produce un'uscita sotto + soglia, che le regole di inoltro standard rifiutano. **Il pagamento del + round, nel caso normale, non ha alcun resto** (spende esattamente il + montepremi del round, e un resto sotto soglia va alla commissione), quindi + non è accelerabile affatto. + - *Impatto:* con la rete congestionata una transazione a 1 sat/vB può + restare in mempool indefinitamente. Poiché il riconciliatore abbandona solo + ciò che il server **non conosce**, una transazione ferma ma presente in + mempool non viene mai abbandonata: una scommessa così blocca il round in + `closing`, un pagamento così lo blocca in `paying_out`. `RbfBumper` + registra un errore ogni 30s. + - *Mitigazione attuale:* resto minimo garantito sulle transazioni utente; + tetto al fee rate (`MAX_FEE_RATE_SAT_VB`); abbandono dopo 6 ore per ciò che + sparisce dalla rete; ritentativo automatico del pagamento dopo un + abbandono. + - *Per chiuderlo:* aggiungere ingressi a una transazione da accelerare + (dall'indirizzo del mittente), oppure CPFP sul resto; per il pagamento, + garantire un'uscita di resto verso il montepremi come per le transazioni + utente; in ogni caso rifiutare un aumento che lasci il resto sotto + `DUST_LIMIT_SATS`. Il docstring di `bump_fee` cita ancora come esempio «una scommessa + senza resto», caso che dopo B-62 non esiste più. + + *Codice:* `app/tx/broadcast.py:bump_fee` · `app/wallet/psbt_builder.py:build_payout_transaction` · `app/tx/reconcile.py:reconcile_once` + +- **[LIM-03]** **L'attesa in `closing` non ha un limite proprio.** Il round + aspetta ogni scommessa `building`/`broadcast`; a porre fine all'attesa di una + scommessa che non conferma è solo il riconciliatore, dopo 6 ore dal primo + invio (e solo se la transazione è sparita dalla rete, [LIM-02]). + - *Impatto:* un round può restare chiuso ma non estratto per ore; nessun + nuovo round si apre nel frattempo. + - *Mitigazione attuale:* lo stato è visibile (`closing`) e il pannello + «Transazioni pendenti» mostra la scommessa che trattiene il round. + - *Per chiuderlo:* una politica esplicita per le scommesse lente (per esempio + escluderle e rimborsarle dopo un tempo massimo), che è una modifica della + specifica del round, non una correzione. + + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/tx/reconcile.py:_is_due` + +- **[LIM-04]** **Prelievo e accelerazione RBF non sono mai stati inviati in una + rete reale.** Sono coperti da test unitari, rami di errore e annullamento + compresi; il resto del flusso (registrazione, deposito, scommessa, un ciclo + completo di round con pagamento) è stato verificato in mainnet. + - *Impatto:* un errore di costruzione o di firma specifico della rete reale + emergerebbe al primo prelievo di un utente vero. + - *Per chiuderlo:* un prelievo e un RBF reali in mainnet, con verifica + dell'aritmetica sulla transazione inviata. + + *Codice:* `app/withdrawals/service.py:request_withdrawal` · `app/tx/broadcast.py:bump_fee` + +- **[LIM-05]** **Nessun test di integrazione con una connessione Electrum + vera.** `tests/integration/` è vuota; la verifica dal vivo è stata tutta + manuale (`scripts/electrum_smoke_test.py`, script ad hoc, transazioni reali). + - *Impatto:* le differenze di comportamento fra implementazioni di server + Electrum (formati di risposta, errori) emergono solo in produzione. + - *Per chiuderlo:* una suite su testnet o regtest, o contro un server + registrato. + + *Codice:* `tests/integration/__init__.py` · `scripts/electrum_smoke_test.py` + +### Sicurezza e amministrazione + +- **[LIM-06]** **Un unico token admin condiviso, senza identità per + operatore.** Il registro di audit dice *cosa* è cambiato (per esempio + `config_updated` con prima/dopo) ma non *chi*. Il token dà accesso a elenco + utenti, esportazione delle chiavi private, reset delle password e storico. + Non c'è limite ai tentativi sul token. + - *Impatto:* una fuga del token ha il raggio d'azione più ampio del sistema, + ed è impossibile attribuire un'azione a una persona. + - *Mitigazione attuale:* confronto a tempo costante, pannello chiuso se il + token è vuoto, ogni azione sensibile in audit, `/admin` non collegato dalla + pagina pubblica. Dettagli in [sicurezza.md](sicurezza.md). + - *Per chiuderlo:* account amministrativi individuali (con l'identità scritta + in ogni voce di audit) e limitazione dei tentativi sul token. + + *Codice:* `app/api/routes/admin.py:require_admin` · `app/audit/log.py:write_audit_log` + +- **[LIM-07]** **Nessuna limitazione di frequenza su scommesse, prelievi, + endpoint admin, cambio password e segnalazioni di bug.** Solo login e + registrazione sono limitati ([DEC-41]); il canale SSE ha tetti di + concorrenza (500 globali, 5 per IP), non di frequenza. + - *Impatto:* un utente autenticato può martellare scommesse e prelievi (il + blocco per-utente li serializza, non li limita); il cambio password + permette di provare password attuali senza ritardi a chi possiede già un + token valido; le segnalazioni anonime possono riempire il database. + - *Per chiuderlo:* un limitatore per utente e per IP sugli endpoint + interessati, sullo stesso modello di `RateLimiter`/`RollingQuota`. + + *Codice:* `app/api/routes/bets.py:create_bet` · `app/api/routes/withdrawals.py:create_withdrawal` · `app/api/routes/users.py:change_password` · `app/api/routes/bug_reports.py:create_bug_report` + +- **[LIM-08]** **Nessun registro di sicurezza lato utente.** Login, login + falliti e cambio password dell'utente non scrivono voci di audit (le scrive + solo il reset da parte dell'admin). + - *Impatto:* dopo una compromissione non c'è traccia di quando e da dove sia + avvenuto l'accesso. + - *Per chiuderlo:* eventi di audit per questi casi (vedi + [audit-log.md](audit-log.md)). + + *Codice:* `app/auth/routes.py:login` · `app/api/routes/users.py:change_password` + +- **[LIM-09]** **Il JWT sta in `localStorage` e la CSP ammette script inline.** + Una falla XSS permetterebbe di leggere il token. + - *Mitigazione attuale:* CSP `default-src 'self'`, `frame-ancestors 'none'`, + escape dei valori controllati dall'utente nelle pagine; sessioni revocabili + ([DEC-36]). + - *Per chiuderlo:* togliere gestori e stili inline dalle SPA per poter + eliminare `'unsafe-inline'`, oppure spostare la sessione in un cookie + `HttpOnly`. + + *Codice:* `Caddyfile` · `app/static/app.js` + +### Fiducia nella rete + +- **[LIM-10]** **Rischio di reorg a 1 conferma accettato, senza rollback.** Un + deposito accreditato a 1 conferma può sparire con una riorganizzazione; la + punta della catena non regredisce mai e un header concorrente alla stessa + altezza è ignorato. + - *Mitigazione attuale:* una moneta che sparisce dall'elenco del server viene, + previo quorum, segnata come spesa all'esterno e tolta dal saldo; se + riappare viene ripristinata. Una scommessa costruita su una moneta + scomparsa è rifiutata dalla rete e annullata. + - *Per chiuderlo:* soglie di conferma più alte (decisione esplicita, + [DEC-06]) o una gestione completa dei reorg. + + *Codice:* `app/deposits/service.py:find_utxos_missing_from` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/electrum/listener.py:ElectrumListener._apply_header` + +- **[LIM-11]** **Senza server di riserva configurati la corroborazione passa + sempre.** Con `ELECTRUM_FALLBACK_SERVERS` vuoto il solo server primario decide + estrazione, spese esterne e accrediti. Anche con server di riserva, la + maggioranza è calcolata **su chi risponde**, fra server scelti dall'operatore. + - *Impatto:* chi controlla il server primario (o la maggioranza dei server + raggiungibili) può scegliere il vincitore o alterare i saldi mostrati. + - *Mitigazione attuale:* nessun server risponde → la corroborazione + **fallisce** (una rete irraggiungibile non prova nulla). + - *Per chiuderlo:* rendere obbligatorio un numero minimo di server di riserva, + o un quorum minimo di risposte. + + *Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` + +- **[LIM-12]** **Non tutte le letture di rete sono corroborate.** Passano dal + solo server attivo: la conferma di scommesse, pagamenti e prelievi + (`poll_once`), la decisione del riconciliatore che una transazione «non + esiste più», l'elenco delle monete del montepremi usato per il pagamento, e il + ripristino di una moneta prima segnata come spesa all'esterno. + - *Impatto:* un server ostile potrebbe far considerare confermata una + scommessa che non lo è, o far abbandonare a torto una transazione viva + (liberandone le monete e, per un pagamento, azzerando `payout_txid`), + lasciando il database in disaccordo con la catena; il ripristino non + corroborato può reintegrare nel saldo una moneta davvero spesa. + - *Mitigazione attuale:* l'abbandono avviene solo dopo 6 ore per `pending`; un + invio basato su monete inesistenti è rifiutato dalla rete. + - *Per chiuderlo:* estendere il quorum di [DEC-07] a queste decisioni. + Il ripristino in particolare contraddice il principio «entrambe le + direzioni richiedono il quorum» ed è un candidato a un `B-nn`. + + *Codice:* `app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` + +- **[LIM-13]** **Limiti della forma attuale dell'estrazione.** + - *Entropia dal minatore:* chi mina il blocco può scegliere di non + pubblicarlo se l'esito non gli conviene, rinunciando alla ricompensa. + - *Verificabilità:* l'ordine dei partecipanti è l'orario di invio registrato + dal server, un dato non pubblico; un giocatore non può ricalcolare da solo + l'esito dai soli dati di catena (il commento di `draw_winner` dice il + contrario). + - *Pareggi:* due scommesse con lo stesso `broadcast_at` non hanno un criterio + secondario di ordinamento. + - *Distorsione del modulo:* un seme da 256 bit modulo al più 400 partecipanti + introduce una distorsione trascurabile. + - *Per chiuderlo:* sostituire l'algoritmo ([DEC-24]) con uno che pubblichi + l'ordine dei partecipanti (o lo derivi da dati di catena) e un criterio di + pareggio totale. + + *Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` + +- **[LIM-14]** **La validazione degli header non ricostruisce la difficoltà + reale.** Un server può dichiarare un bersaglio facile e minare davvero un + header a quella difficoltà con hardware modesto; la concatenazione si + verifica solo per un avanzamento di un blocco esatto. + - *Mitigazione attuale:* la corroborazione del blocco usato per l'estrazione + ([DEC-07], [DEC-25]). + - *Per chiuderlo:* validazione della catena degli header con le regole di + ricalcolo della difficoltà di PLM. + + *Codice:* `app/rounds/draw.py:header_meets_its_own_target` · `app/electrum/listener.py:ElectrumListener._apply_header` + +- **[LIM-15]** **La sottoscrizione di un nuovo indirizzo è a sforzo + singolo.** Se fallisce subito dopo la registrazione, l'indirizzo resta non + osservato fino alla riconnessione successiva o al giro di `DepositReconciler` + (300s, che attende prima di partire). + - *Impatto:* un deposito fatto subito dopo la registrazione può tardare fino + a cinque minuti. + + *Codice:* `app/electrum/listener.py:ElectrumListener.address_for_new_user` · `app/deposits/reconcile.py:DepositReconciler` + +### Configurazione ed economia + +- **[LIM-16]** **Solo i tempi del round sono fotografati all'apertura.** Costo + della scommessa, fee rate e `fee_address` sono letti dal vivo. + - *Impatto:* cambiare il costo a metà round fa pagare poste diverse a + giocatori con la stessa probabilità, e cambia subito il prelievo minimo; + cambiare `fee_address` a metà round devia la commissione del round in + corso (la modifica è comunque registrata in audit). + - *Per chiuderlo:* fotografare anche questi campi sulla riga del round, come + già si fa per durata e pausa ([DEC-10]). + + *Codice:* `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/withdrawals/service.py:request_withdrawal` + +- **[LIM-17]** **Nessun consolidamento automatico del montepremi.** Monete + avanzate e resti si accumulano sull'unico indirizzo del montepremi; il + margine fra 400 partecipanti e 500 ingressi ([DEC-21]) si consuma nel tempo. + - *Impatto:* raggiunto il limite, il pagamento fallisce con + `too_many_inputs` e il round resta in `paying_out`, con un `payout_failed` + ogni 60s. + - *Per chiuderlo:* un'operazione di consolidamento (manuale o automatica) del + montepremi. Procedura in [operativita.md](operativita.md). + + *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/wallet/psbt_builder.py:build_payout_transaction` + +### Prodotto e interfacce + +- **[LIM-18]** **Il sistema presuppone un solo processo.** Canale SSE, blocchi + per-utente, limitatori di tentativi, cache dei QR e della chiave dell'account + sono in memoria. + - *Impatto:* con più worker, due costruzioni dello stesso utente potrebbero + scegliere le stesse monete, i limiti per IP si dividerebbero per worker e + gli eventi SSE arriverebbero solo ai client dello stesso worker. + - *Per chiuderlo:* canale condiviso (Redis pub/sub) e lock su database o + Redis. L'invariante del round attivo è già un indice e non rientra qui. + + *Codice:* `app/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` · `app/auth/rate_limit.py:AuthRateLimiters` + +- **[LIM-19]** **Nessuna cronologia per l'utente.** L'utente non vede lo storico + dei round, delle scommesse, dei depositi o dei prelievi: un prelievo fallito + lascia una riga `failed` che l'utente non può mai vedere. Esistono solo + `GET /users/me/last-round-result` (l'ultimo round giocato) e + `GET /bug-reports/mine`. L'operatore ha invece `/admin/rounds`, + `/admin/pending-transactions`, `/admin/audit-log`. + - *Per chiuderlo:* endpoint e sezione di cronologia personale. + + *Codice:* `app/api/routes/users.py:last_round_result` · `app/withdrawals/service.py:request_withdrawal` + +- **[LIM-20]** **`/guida` è un segnaposto.** Il collegamento funziona ma la + pagina dice che la guida arriverà. (`/report-bug` invece è completa e + tradotta.) + + *Codice:* `app/main.py:user_guide` · `app/static/guida.html` + +- **[LIM-21]** **La parità delle chiavi di traduzione non è verificata da un + test.** Oggi le 7 lingue hanno le stesse 151 chiavi, ma nulla lo impone: una + chiave mancante ricade sull'inglese e, se manca anche lì, viene mostrata la + chiave grezza. + - *Per chiuderlo:* un test che confronti gli insiemi di chiavi di + `TRANSLATIONS`. + + *Codice:* `app/static/i18n.js:t` + +- **[LIM-22]** **L'API di amministrazione e alcuni errori di contorno non usano + il formato d'errore strutturato.** Gli endpoint `/admin/*` rispondono con un + `detail` stringa (per esempio `"invalid admin token"`, `"user not found"`), e + così il 503 del canale SSE oltre il tetto; è coerente con un pannello non + tradotto, ma non è dichiarato altrove. + - *Per chiuderlo:* usare `http_error` anche lì, se il pannello dovrà mai + tradurre o distinguere gli errori. + + *Codice:* `app/api/routes/admin.py:require_admin` · `app/api/routes/rounds.py:round_stream` + +- **[LIM-23]** **`GET /admin/users` non ha limite né paginazione**, a + differenza degli altri elenchi admin. + - *Impatto:* con molti utenti la risposta cresce senza limite. + + *Codice:* `app/api/routes/admin.py:list_users` diff --git a/docs/guida-admin.md b/docs/guida-admin.md index 22572a6..eb57f79 100644 --- a/docs/guida-admin.md +++ b/docs/guida-admin.md @@ -2,6 +2,8 @@ Come gestire la configurazione operativa di PLM Lottery. Presuppone che il server sia già avviato — vedi [running-the-server.md](running-the-server.md). +Per il funzionamento interno (stati del round, riconciliazione delle +transazioni, connessione Electrum) vedi [come-funziona.md](come-funziona.md). ## Accesso @@ -23,7 +25,8 @@ sola schermata di login. - **Utenti** — elenco utenti, saldo, accesso alla chiave privata, reset password - **Round** — storico round: stato, vincitore, importi, txid di payout - **Transazioni pendenti** — bet/payout/prelievi non ancora confermati, candidati al fee-bump RBF -- **Audit log** — eventi registrati dal sistema (config cambiata, bet, payout, accessi a chiavi private, ecc.) +- **Audit log** — eventi registrati dal sistema (config cambiata, bet, payout, accessi a chiavi private, ecc.; catalogo completo in [audit-log.md](audit-log.md)) +- **Bug report** — segnalazioni inviate da `/report-bug`, da smistare `open` → `read` → `resolved` ## Parametri @@ -90,8 +93,11 @@ sempre **chi ha originato la transazione**, non il destinatario: per bet e prelievi è l'utente stesso (gli torna un resto più piccolo), per i payout è il pool (il resto che torna all'indirizzo pool si riduce) — la quota del vincitore e quella delle fee, già fissate, non vengono mai toccate. Se non -c'è un resto abbastanza grande da assorbire l'aumento, il bump fallisce e -resta un intervento manuale (vedi "Limiti noti"). +c'è un resto abbastanza grande da assorbire l'aumento, il bump fallisce: la +transazione resta in attesa finché non conferma da sola o finché il +riconciliatore non la abbandona liberandone le monete (vedi +[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) e +[operativita.md](operativita.md)). ## Round @@ -125,7 +131,7 @@ Eventi a cui vale la pena prestare attenzione: | `pending_tx_abandoned` | Una transazione trasmessa è scomparsa dalla catena e il sistema l'ha dichiarata persa: UTXO liberati, bet rimossa o prelievo segnato `failed`. Se capita spesso, la fee rate configurata è probabilmente troppo bassa. | | `pending_tx_recovered` | Una transazione che si credeva incompleta è invece finita in catena (tipicamente dopo un riavvio a metà invio) e il sistema l'ha ripresa da sé. | | `bug_report_status_changed` | Un admin ha cambiato lo stato di una segnalazione (payload: `report_id`, stato precedente e nuovo). Con un token admin unico e condiviso, questa riga è l'unica traccia di chi tocca le segnalazioni. | -| `payout_failed` | Il payout di un round non è partito. Il round resta in `paying_out` e **richiede intervento manuale**: non esiste un retry automatico. Controlla `fee_address`, il saldo dell'indirizzo pool e la connessione Electrum. | +| `payout_failed` | Un tentativo di payout di un round non è partito; il campo `reason` dice perché. Il round resta in `paying_out` e lo scheduler **riprova da solo al massimo una volta ogni 60 secondi**. Se l'evento si ripete, controlla `fee_address`, il saldo dell'indirizzo pool e la connessione Electrum (procedure in [operativita.md](operativita.md)). | ## Alternative all'interfaccia grafica @@ -182,11 +188,14 @@ per l'utente: solo un admin col token può farlo. - Il token è unico e condiviso: non c'è identità per singolo admin né audit di chi ha cambiato cosa (oltre alla tabella `audit_log` generica). -- Nessun rate limiting sugli endpoint admin (né su registrazione/bet/ - prelievo utente). +- Nessun rate limiting sugli endpoint admin, né su bet e prelievo. Solo il + login (tentativi falliti) e la registrazione (5 account/ora per IP) sono + limitati — vedi [sicurezza.md](sicurezza.md). - Se un payout fallisce (es. Electrum disconnesso, UTXO insufficienti), il - round resta bloccato in `paying_out` senza retry automatico — richiede - intervento manuale. + round resta in `paying_out` e il payout viene ritentato ogni 60 secondi; + alcuni motivi però non si risolvono da soli (vedi + [operativita.md](operativita.md)). +- Elenco completo dei limiti accettati: [decisioni-e-limiti.md](decisioni-e-limiti.md). Per l'elenco completo dei gap noti vedi la sezione "Known gaps / TODO" in `CLAUDE.md`. diff --git a/docs/guida-utente.md b/docs/guida-utente.md index d2d9a31..f35d343 100644 --- a/docs/guida-utente.md +++ b/docs/guida-utente.md @@ -2,6 +2,8 @@ Come usare PLM Lottery dall'interfaccia web (`https:///` — vedi [running-the-server.md](running-the-server.md) per come avviare il server). +Per cosa succede dietro le quinte (depositi, round, estrazione, pagamenti) vedi +[come-funziona.md](come-funziona.md). ## Registrazione e accesso diff --git a/docs/modello-dati.md b/docs/modello-dati.md new file mode 100644 index 0000000..1b83f01 --- /dev/null +++ b/docs/modello-dati.md @@ -0,0 +1,1088 @@ +# Modello dati + +Questo documento descrive il database di PLM Lottery: tutte le tabelle, ogni +colonna, i valori ammessi per i campi di stato e le transizioni tra di essi, gli +indici e i vincoli, le invarianti che legano più tabelle, la configurazione del +motore SQLAlchemy e la politica delle migrazioni Alembic. + +La definizione di riferimento è [`app/db/models.py`](../app/db/models.py); lo +schema effettivo di un'istanza è quello prodotto dalla catena di migrazioni in +[`migrations/versions/`](../migrations/versions/). Le due cose coincidono (vedi +[Migrazioni](#migrazioni)), con le piccole differenze segnalate lì. + +Cosa **non** c'è qui, e dove trovarlo: + +- i flussi che scrivono queste righe, passo per passo: + [registrazione-e-depositi.md](registrazione-e-depositi.md), + [scommesse-e-prelievi.md](scommesse-e-prelievi.md), + [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md), + [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md); +- il catalogo dei tipi di evento di `audit_log`: [audit-log.md](audit-log.md); +- gli endpoint che leggono e scrivono queste tabelle: [api.md](api.md); +- chiavi, password e token: [sicurezza.md](sicurezza.md); +- backup e interventi manuali sul database: [operativita.md](operativita.md); +- la storia dei bug citati come `B-nn`: [storico-bug.md](storico-bug.md). + +## Indice + +- [Diagramma delle relazioni](#diagramma-delle-relazioni) +- [Convenzioni comuni](#convenzioni-comuni) +- Tabelle: [`users`](#users) · [`utxo_events`](#utxo_events) · + [`rounds`](#rounds) · [`round_participants`](#round_participants) · + [`round_config`](#round_config) · + [`pending_transactions`](#pending_transactions) · + [`withdrawals`](#withdrawals) · [`bug_reports`](#bug_reports) · + [`audit_log`](#audit_log) · [`alembic_version`](#alembic_version) +- [Indici e vincoli](#indici-e-vincoli) +- [Invarianti tra tabelle](#invarianti-tra-tabelle) +- [Motore e sessioni](#motore-e-sessioni) +- [Migrazioni](#migrazioni) +- [Scelte specifiche di SQLite](#scelte-specifiche-di-sqlite) + +## Diagramma delle relazioni + +Nove tabelle applicative. `round_config` non ha relazioni: è una riga singola +di parametri. L'indirizzo del montepremi (il "pool") non è in nessuna tabella: +è derivato dalla master key (`m/84'/746'/0'/1/0`, +`app/wallet/hd.py:derive_pool_key`) e i suoi UTXO si leggono dalla rete. + +```mermaid +erDiagram + users ||--o{ utxo_events : "possiede" + users ||--o{ round_participants : "scommette" + rounds ||--o{ round_participants : "raccoglie" + users |o--o{ rounds : "vince (winner_user_id)" + users ||--o{ withdrawals : "chiede" + rounds |o--o{ pending_transactions : "bet / payout" + withdrawals |o--o{ pending_transactions : "withdrawal" + users |o--o{ pending_transactions : "bet / withdrawal" + users |o--o{ bug_reports : "segnala" + users |o--o{ audit_log : "riguarda" + rounds |o--o{ audit_log : "riguarda" + + users { + int id PK + string username UK "unico anche su lower(username)" + string password_hash "Argon2" + int derivation_index UK + string address UK "P2WPKH plm1..." + bigint cached_balance_sats "cache di utxo_events" + int token_version + datetime created_at + } + utxo_events { + int id PK + int user_id FK + string txid "UK con vout" + int vout + bigint amount_sats + int confirmed_height + datetime confirmed_at + string spent_txid "NULL, txid o external-spend" + } + rounds { + int id PK + string status "open closing drawing paying_out closed" + datetime opened_at + datetime closed_at + int duration_seconds "snapshot" + int cooldown_seconds "snapshot" + datetime drawing_started_at + int draw_block_height + string draw_block_hash + string seed_int + int winner_user_id FK + bigint pool_amount_sats + bigint winner_amount_sats + bigint fee_amount_sats + string payout_txid + } + round_participants { + int id PK + int round_id FK "UK con user_id" + int user_id FK + bigint bet_amount_sats + string bet_txid + datetime broadcast_at + datetime confirmed_at + string status "building broadcast confirmed" + } + round_config { + int id PK + string fee_address + bigint bet_amount_sats + int round_duration_seconds + int round_cooldown_seconds + int draw_animation_seconds + int fee_rate_sat_vb + int rbf_timeout_seconds + bool paused + datetime updated_at + } + pending_transactions { + int id PK + string kind "bet payout withdrawal" + int round_id FK + int withdrawal_id FK + int user_id FK + string current_txid + int fee_rate_sat_vb + text raw_tx_hex + datetime broadcast_at + datetime last_broadcast_at + string status "building pending confirmed failed" + string replaced_by_txid + int attempt_count + string failure_reason + datetime updated_at + } + withdrawals { + int id PK + int user_id FK + string external_address + bigint amount_requested_sats + bigint amount_sent_sats + string txid + string status "building broadcast confirmed failed" + datetime created_at + datetime confirmed_at + } + bug_reports { + int id PK + text description + string contact + int user_id FK + string status "open read resolved" + datetime created_at + } + audit_log { + int id PK + string event_type + text payload_json + int user_id FK + int round_id FK + datetime created_at + } +``` + +## Convenzioni comuni + +- **[DAT-01]** Ogni importo è un numero intero di satoshi (1 PLM = + 100 000 000 sat), in una colonna `BigInteger` il cui nome finisce in `_sats`. + Nessun importo passa mai per un `float`: anche il valore degli input letti + dalla rete per un RBF è preso come intero dalla transazione grezza (B-40). + *Codice:* `app/db/models.py` · `app/tx/broadcast.py:_prevout_amount` · + *Test:* `tests/unit/test_broadcast.py` + +- **[DAT-02]** Ogni timestamp è in UTC. Si scrive con `app/db/models.py:utcnow` + (default di colonna) o con `datetime.now(timezone.utc)`; le colonne sono + `DateTime` senza fuso, e SQLite restituisce valori *naive*. Chi legge un + timestamp per confrontarlo con l'ora corrente lo marca UTC con + `.replace(tzinfo=timezone.utc)`, e ogni risposta API lo serializza con + `isoformat_utc`, che aggiunge l'offset (B-35). + *Codice:* `app/db/models.py:utcnow` · `app/api/timeutil.py:isoformat_utc` · + `app/rounds/service.py:round_deadline` · + *Test:* `tests/unit/test_api_timeutil.py`, `tests/unit/test_scheduler.py` + +- **[DAT-03]** I campi di stato (`status`, `kind`) sono stringhe libere + (`String(16)`), senza `CHECK` né enum nel database. I valori ammessi sono solo + quelli elencati in questo documento, e a garantirlo è esclusivamente il codice + che li scrive; l'unico vincolo del database che dipende da un valore di stato + è l'indice `ix_rounds_single_active` ([DAT-17]). + *Codice:* `app/db/models.py` + +- **[DAT-04]** Le chiavi esterne sono dichiarate ma, su SQLite, **non + applicate**: `PRAGMA foreign_keys` non viene attivato + ([DAT-52]). Nessuna chiave esterna ha `ON DELETE`. Le sole cancellazioni + fisiche nel codice sono quelle di una scommessa fallita (la riga + `round_participants` e la sua `pending_transactions`, + [DAT-24], [DAT-38]); nessun'altra tabella perde righe, quindi i riferimenti + restano validi per costruzione, non per vincolo. + *Codice:* `app/db/base.py:_register_sqlite_pragmas` · + `app/bets/service.py:_release_failed_bet` · `app/tx/reconcile.py:_abandon` + +- **[DAT-05]** Ogni chiave primaria è un intero autoincrementale `id`, assegnato + dal database al `flush`/`commit`. Nessuna tabella usa chiavi naturali come + chiave primaria; le chiavi naturali (`txid`+`vout`, `round_id`+`user_id`, + `username`) sono vincoli di unicità separati. + *Codice:* `app/db/models.py` + +## Tabelle + +### `users` + +Un account. Nasce alla registrazione e non viene mai cancellato. + +| Colonna | Tipo | Null | Default | Significato | Scritta da | +|---|---|---|---|---|---| +| `id` | Integer PK | no | autoincrement | Identificativo; è il `sub` del JWT | database | +| `username` | String(64) | no | — | Nome scelto dall'utente, conservato **così come digitato** | `app/auth/routes.py:register` | +| `password_hash` | String(256) | no | — | Hash Argon2 della password | `register`; `app/api/routes/users.py:change_password`; `app/api/routes/admin.py:reset_user_password` | +| `derivation_index` | Integer, unico | no | — | Indice BIP84 dell'indirizzo: `m/84'/746'/0'/0/` | `register` | +| `address` | String(128), unico | no | — | Indirizzo P2WPKH (`plm1…`) derivato da `derivation_index`; deposito, vincite e resto | `register` | +| `cached_balance_sats` | BigInteger | no | `0` | Saldo confermato e spendibile, cache di `utxo_events` | solo `app/wallet/balance.py:recompute_balance` | +| `token_version` | Integer | no | `0` (ORM e `server_default` nel modello) | Versione dei token di sessione; il JWT la porta nel claim `tv` | `change_password`, `reset_user_password` | +| `created_at` | DateTime | no | `utcnow` | Istante della registrazione | database/ORM | + +- **[DAT-06]** Lo username è unico **senza distinzione tra maiuscole e + minuscole** (B-57): lo garantisce l'indice unico funzionale + `ix_users_username_lower` su `lower(username)`, e registrazione e login + confrontano con `func.lower(...)`. Il valore è comunque memorizzato così come + l'utente l'ha scritto, ed è quello mostrato in `/admin` e nel registro di + audit. Poiché lo username ammesso è solo ASCII (`^[A-Za-z0-9_.-]+$`, 3–32 + caratteri, validato dall'API), `lower()` è l'intera normalizzazione. Resta + anche l'indice unico `ix_users_username` sulla colonna esatta, ridondante + rispetto al primo ma innocuo. + *Codice:* `app/db/models.py:User` · `app/auth/routes.py:register` · + `app/auth/routes.py:login` · *Test:* `tests/unit/test_auth.py` + +- **[DAT-07]** `derivation_index` è assegnato alla registrazione come + `max(derivation_index) + 1` (0 per il primo utente). Se il commit fallisce per + una collisione sull'indice (due registrazioni concorrenti) si ricalcola e si + riprova, fino a 5 volte; una collisione sullo username invece non si riprova e + risponde `username_taken`. `derivation_index` e `address` non vengono **mai** + modificati dopo l'inserimento: l'indirizzo è permanente. + *Codice:* `app/auth/routes.py:register` · `app/wallet/hd.py:derive_user_address` · + *Test:* `tests/unit/test_auth.py` + +- **[DAT-08]** `cached_balance_sats` è uguale, in ogni momento osservabile, alla + somma di `amount_sats` delle righe `utxo_events` dell'utente con + `spent_txid IS NULL`. La fonte di verità è `utxo_events`; la colonna è + scritta solo da `recompute_balance`, che va chiamata **nella stessa + transazione** di qualunque modifica alle righe `utxo_events` dell'utente (un + inserimento o un cambio di `spent_txid` da o verso `NULL`). I chiamanti sono: + `credit_confirmed_utxos`, `reinstate_reappeared_utxos`, + `mark_utxos_spent_externally`, `place_bet`, `_release_failed_bet`, + `request_withdrawal`, `_release_failed_withdrawal` e il `_abandon` del + riconciliatore. Il retarget di `bump_fee` ([DAT-14]) non la ricalcola perché + sostituisce un txid con un altro e non cambia la somma. + Il saldo "in attesa" mostrato all'utente non è una colonna: è calcolato al + volo (vedi [registrazione-e-depositi.md](registrazione-e-depositi.md)). + *Codice:* `app/wallet/balance.py:recompute_balance` · + `app/deposits/service.py:credit_confirmed_utxos` · + `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · + `app/tx/reconcile.py:_abandon` · + *Test:* `tests/unit/test_balance.py`, `tests/unit/test_deposits.py` + +- **[DAT-09]** `token_version` parte da 0 e aumenta di 1 a ogni cambio password + (self-service) e a ogni reset da parte dell'amministratore; nient'altro la + modifica. Un token il cui claim `tv` non coincide con il valore corrente è + rifiutato (B-34). Un token senza claim `tv` vale come `tv = 0`. + *Codice:* `app/api/routes/users.py:change_password` · + `app/api/routes/admin.py:reset_user_password` · + `app/auth/dependencies.py:get_current_user` · + `app/auth/security.py:decode_access_token` · + *Test:* `tests/unit/test_security.py`, `tests/unit/test_admin.py` + +### `utxo_events` + +Un output **confermato** che paga l'indirizzo di un utente: un deposito, il +resto di una sua scommessa o di un suo prelievo, una vincita. Dal punto di vista +del database sono tutti uguali, perché arrivano tutti dallo stesso percorso +(l'accredito dei depositi, vedi +[registrazione-e-depositi.md](registrazione-e-depositi.md)). + +| Colonna | Tipo | Null | Default | Significato | Scritta da | +|---|---|---|---|---|---| +| `id` | Integer PK | no | autoincrement | — | database | +| `user_id` | FK `users.id`, indicizzata | no | — | Proprietario dell'indirizzo pagato | `app/deposits/service.py:credit_confirmed_utxos` | +| `txid` | String(64) | no | — | Transazione che ha creato l'output | `credit_confirmed_utxos` | +| `vout` | Integer | no | — | Posizione dell'output | `credit_confirmed_utxos` | +| `amount_sats` | BigInteger | no | — | Valore dell'output (`value` di `listunspent`) | `credit_confirmed_utxos` | +| `confirmed_height` | Integer | no | — | Altezza del blocco riportata dal server (sempre > 0) | `credit_confirmed_utxos` | +| `confirmed_at` | DateTime | no | `utcnow` | Istante dell'**accredito** nel database, non l'orario del blocco | ORM | +| `spent_txid` | String(64) | sì | `NULL` | Chi ha speso l'output, vedi [DAT-12] | vedi tabella sotto | + +- **[DAT-10]** Una riga si inserisce solo per un output con `height > 0` (almeno + 1 conferma) che non è già presente, e solo dopo che gli altri server Electrum + hanno corroborato lo stesso outpoint con lo stesso importo (B-59; la + corroborazione è in [connessione-electrum.md](connessione-electrum.md)). + L'inserimento è idempotente su (`txid`, `vout`), che è unico su tutta la + tabella, non solo per utente. Le righe non si cancellano mai e, dopo + l'inserimento, solo `spent_txid` cambia. + *Codice:* `app/deposits/service.py:find_new_credit_candidates` · + `app/deposits/service.py:credit_confirmed_utxos` · + `app/electrum/listener.py:ElectrumListener.refresh_user` · + *Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py` + +- **[DAT-11]** Gli UTXO del pool **non** sono in `utxo_events`: il pool non è un + utente. Il pagamento sceglie i suoi input da una `listunspent` dell'indirizzo + del pool, escludendo gli outpoint già impegnati da un pagamento non ancora + risolto (letti dal `raw_tx_hex` delle righe `pending_transactions` di tipo + `payout` in `building`/`pending`). Per lo stesso motivo l'abbandono di un + pagamento non ha input da liberare. + *Codice:* `app/rounds/scheduler.py:_reserved_payout_outpoints` · + `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · + `app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py` + +- **[DAT-12]** `spent_txid` ha tre significati: + - `NULL`: l'output è spendibile e conta nel saldo; + - un txid: l'output è impegnato da una transazione della piattaforma + (scommessa o prelievo), dal momento della sua costruzione, prima ancora + della trasmissione; resta impostato per sempre una volta confermata; + - la stringa `external-spend`: l'output è sparito dalla `listunspent` e gli + altri server hanno confermato che è stato speso fuori dalla piattaforma + (B-29). + + Le transizioni ammesse sono queste, e nessun'altra funzione scrive la colonna: + + | Da | A | Funzione | Quando | + |---|---|---|---| + | `NULL` | txid | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal` | fase 1, prima del broadcast | + | txid | nuovo txid | `app/tx/broadcast.py:_retarget_txid_references` | bump RBF | + | txid | `NULL` | `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal` | la rete ha rifiutato il broadcast | + | txid | `NULL` | `app/tx/reconcile.py:_release_inputs` | la transazione è stata abbandonata ([DAT-13]) | + | `NULL` | `external-spend` | `app/deposits/service.py:mark_utxos_spent_externally` | spesa esterna corroborata | + | `external-spend` | `NULL` | `app/deposits/service.py:reinstate_reappeared_utxos` | l'outpoint ricompare come non speso | + + Chi sceglie gli input di una nuova transazione considera solo le righe con + `spent_txid IS NULL`. + *Codice:* le funzioni in tabella · + *Test:* `tests/unit/test_deposits.py`, `tests/unit/test_bets.py`, + `tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py` + +- **[DAT-13]** Il riconciliatore libera un input solo se il suo `spent_txid` è + uguale al `current_txid` della riga che sta abbandonando: se nel frattempo lo + stesso output risulta impegnato da un'altra transazione, quella rivendicazione + è quella valida e non va toccata. + *Codice:* `app/tx/reconcile.py:_release_inputs` · + *Test:* `tests/unit/test_reconcile.py` + +- **[DAT-14]** Per ogni output impegnato, `spent_txid` è sempre il txid + **corrente** della transazione che lo spende, cioè il `current_txid` della sua + riga `pending_transactions`. Un bump RBF cambia il txid e quindi riscrive, + nella stessa transazione del database, ogni `spent_txid` uguale al vecchio + txid. Senza questo allineamento [DAT-13] non riconoscerebbe più gli input di + una transazione rimpiazzata. + *Codice:* `app/tx/broadcast.py:bump_fee` · + `app/tx/broadcast.py:_retarget_txid_references` · + *Test:* `tests/unit/test_broadcast.py` + +### `rounds` + +Un round della lotteria, dall'apertura al pagamento confermato. I round chiusi +restano nella tabella come storico. + +| Colonna | Tipo | Null | Default | Significato | Scritta da | +|---|---|---|---|---|---| +| `id` | Integer PK | no | autoincrement | — | database | +| `status` | String(16) | no | `"open"` | Fase del round, vedi [DAT-15] | vedi diagramma | +| `opened_at` | DateTime | no | `utcnow` | Apertura; base della scadenza | `app/rounds/service.py:open_new_round_if_needed` (default) | +| `closed_at` | DateTime | sì | `NULL` | Vedi [DAT-19] | `app/rounds/scheduler.py:RoundScheduler._tick`; `app/rounds/confirmation.py:_on_payout_confirmed` | +| `duration_seconds` | Integer | no | `600` (anche `server_default`) | Durata di **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` | +| `cooldown_seconds` | Integer | no | `30` (anche `server_default`) | Pausa dopo **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` | +| `drawing_started_at` | DateTime | sì | `NULL` | Istante del passaggio a `drawing` | `RoundScheduler._close_and_draw` | +| `draw_block_height` | Integer | sì | `NULL` | Altezza del blocco usato per l'estrazione | `_close_and_draw` | +| `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_close_and_draw` | +| `seed_int` | String(128) | sì | `NULL` | Il seme: `int(draw_block_hash, 16)` scritto in decimale (fino a 78 cifre, per questo è una stringa) | `_close_and_draw` | +| `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_close_and_draw` | +| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate | `_close_and_draw` | +| `winner_amount_sats` | BigInteger | sì | `NULL` | Quanto riceve il vincitore (70% meno la commissione di rete) | `RoundScheduler._trigger_payout` | +| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address` (30% più il resto dell'arrotondamento) | `_trigger_payout` | +| `payout_txid` | String(64) | sì | `NULL` | Txid corrente della transazione di pagamento | `_trigger_payout`; `_retarget_txid_references`; `app/tx/reconcile.py:_abandon` | + +- **[DAT-15]** `status` assume solo i valori `open`, `closing`, `drawing`, + `paying_out`, `closed`. I primi quattro sono gli stati **attivi**. Le + transizioni sono esclusivamente queste: + + ```mermaid + stateDiagram-v2 + [*] --> open: open_new_round_if_needed + open --> closing: _tick, scadenza superata + closing --> closed: _close_and_draw, nessun partecipante confermato + closing --> drawing: _close_and_draw, nessuna scommessa in volo + drawing --> paying_out: _close_and_draw, blocco corroborato + paying_out --> closed: _on_payout_confirmed + closed --> [*] + ``` + + | Da | A | Funzione | Condizione | + |---|---|---|---| + | — | `open` | `app/rounds/service.py:open_new_round_if_needed` | nessun round attivo, non in pausa, `fee_address` impostato, cooldown del round precedente trascorso | + | `open` | `closing` | `app/rounds/scheduler.py:RoundScheduler._tick` | `now >= opened_at + duration_seconds` | + | `closing` | `closed` | `RoundScheduler._close_and_draw` | nessuna scommessa `building`/`broadcast` e nessuna `confirmed` | + | `closing` | `drawing` | `RoundScheduler._close_and_draw` | nessuna scommessa `building`/`broadcast`, almeno una `confirmed` | + | `drawing` | `paying_out` | `RoundScheduler._close_and_draw` | `_wait_for_next_block` ha restituito un blocco corroborato | + | `paying_out` | `closed` | `app/rounds/confirmation.py:_on_payout_confirmed` | la transazione di pagamento ha 1 conferma | + + Un pagamento abbandonato **non** cambia lo stato: il round resta `paying_out` + con `payout_txid = NULL` e il pagamento viene ricostruito. L'unica altra + scrittura di `status` fuori da questa tabella è la migrazione `8a1c4e7b2d90`, + che una tantum portò a `closed` i round attivi duplicati ([DAT-59]). Il + significato di ogni fase è in + [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md). + *Codice:* le funzioni in tabella · + *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_rounds_service.py`, + `tests/unit/test_confirmation.py` + +- **[DAT-16]** `place_bet` protegge l'inserimento del partecipante con un + compare-and-set sulla riga del round, nella stessa transazione: + `UPDATE rounds SET status = 'open' WHERE id = :id AND status = 'open'`. Se + l'aggiornamento non tocca esattamente una riga (o SQLite segnala un conflitto + di scrittura) la transazione viene annullata e la scommessa rifiutata prima di + qualunque broadcast (B-53). L'aggiornamento non cambia alcun valore: serve a + prendere il lock di scrittura sulla riga, così il passaggio a `closing` e + l'inserimento non possono intrecciarsi. + *Codice:* `app/bets/service.py:place_bet` · *Test:* `tests/unit/test_bets.py` + +- **[DAT-17]** Al massimo **un round attivo** alla volta, come vincolo del + database: `ix_rounds_single_active` è un indice unico sull'espressione + costante `(1)`, parziale sugli stati attivi + (`WHERE status IN ('open', 'closing', 'drawing', 'paying_out')`). La tabella + può quindi contenere quanti round `closed` si vuole, ma un secondo inserimento + attivo fallisce con `IntegrityError` (B-09). `open_new_round_if_needed` + gestisce il conflitto annullando il proprio inserimento e adottando il round + del concorrente, con al massimo 3 tentativi. L'indice è dichiarato sia con + `sqlite_where` sia con `postgresql_where`, così resta parziale anche su + PostgreSQL. `get_active_round` ordina comunque per `id` decrescente e registra + un errore se ne trova più di uno. + *Codice:* `app/db/models.py:Round` · + `app/rounds/service.py:open_new_round_if_needed` · + `app/rounds/service.py:get_active_round` · + *Test:* `tests/unit/test_rounds_service.py` + +- **[DAT-18]** `duration_seconds` e `cooldown_seconds` sono una **copia** dei + valori di `round_config` presa quando il round si apre, e non cambiano più + (B-61). La scadenza del round è `opened_at + duration_seconds`; la pausa prima + del round successivo è il `cooldown_seconds` del round chiuso più recente, + contata dal suo `closed_at`. Modificare `round_config` influisce quindi solo + sui round aperti dopo. + *Codice:* `app/rounds/service.py:open_new_round_if_needed` · + `app/rounds/service.py:round_deadline` · + *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py` + +- **[DAT-19]** `closed_at` si scrive due volte. La prima al passaggio + `open → closing`, con l'ora della chiusura delle scommesse. La seconda alla + conferma del pagamento (`paying_out → closed`), dove viene sovrascritto con + l'ora della conferma, così la pausa tra i round parte da quando il vincitore + è stato davvero pagato. Un round chiuso senza partecipanti tiene il primo + valore. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · + `app/rounds/confirmation.py:_on_payout_confirmed` · + *Test:* `tests/unit/test_rounds_service.py` (conteggio della pausa da + `closed_at`; la seconda scrittura non ha un test dedicato) + +- **[DAT-20]** `drawing_started_at` si scrive una sola volta, al passaggio a + `drawing`. Da lì si misura quanto l'estrazione sta aspettando un blocco + (`draw_waiting_since` in `GET /rounds/current`, le voci `draw_stalled` del + registro, B-36). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + `app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_rounds_route.py` + +- **[DAT-21]** `draw_block_height`, `draw_block_hash`, `seed_int`, + `winner_user_id` e `pool_amount_sats` si scrivono insieme, nella stessa + transazione che porta il round a `paying_out`, e poi non cambiano più. + `pool_amount_sats` è la somma di `bet_amount_sats` dei soli partecipanti + `confirmed`, gli stessi fra cui si estrae il vincitore. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` + +- **[DAT-22]** `winner_amount_sats`, `fee_amount_sats` e `payout_txid` si + scrivono nella fase 3 di `_trigger_payout`, insieme alla riga + `pending_transactions` del pagamento e **prima** del broadcast (B-25). + `payout_txid` segue poi il txid corrente a ogni bump RBF, e torna `NULL` + quando il riconciliatore abbandona il pagamento. `winner_amount_sats` e + `fee_amount_sats` invece non vengono azzerati da un abbandono: li riscrive il + tentativo successivo. Un bump RBF non li cambia, perché riduce solo il resto + del pool. + *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` + +### `round_participants` + +Una scommessa: un utente in un round. + +| Colonna | Tipo | Null | Default | Significato | Scritta da | +|---|---|---|---|---|---| +| `id` | Integer PK | no | autoincrement | — | database | +| `round_id` | FK `rounds.id`, indicizzata | no | — | Il round | `app/bets/service.py:place_bet` | +| `user_id` | FK `users.id`, indicizzata | no | — | Chi scommette | `place_bet` | +| `bet_amount_sats` | BigInteger | no | — | Quanto arriva davvero al pool: costo della scommessa **meno** la commissione di rete | `place_bet` | +| `bet_txid` | String(64) | no | — | Txid corrente della scommessa | `place_bet`; `app/tx/broadcast.py:_retarget_txid_references` | +| `broadcast_at` | DateTime | no | `utcnow` | Ordine di estrazione, vedi [DAT-25] | `place_bet` | +| `confirmed_at` | DateTime | sì | `NULL` | Istante in cui la scommessa è risultata confermata | `app/bets/confirmation.py:_on_bet_confirmed` | +| `status` | String(16) | no | `"broadcast"` (mai usato: si scrive sempre esplicitamente) | Vedi [DAT-24] | vedi diagramma | + +- **[DAT-23]** Al massimo una scommessa per utente per round: vincolo unico su + (`round_id`, `user_id`). `place_bet` lo verifica già prima di costruire la + transazione (`already_betting`). Il vincolo è anche la chiave con cui i + gestori di conferma, bump e riconciliazione ritrovano la riga, invece del txid + che cambia. + *Codice:* `app/db/models.py:RoundParticipant` · `app/bets/service.py:place_bet` · + `app/bets/confirmation.py:_on_bet_confirmed` · *Test:* `tests/unit/test_bets.py` + +- **[DAT-24]** `status` assume solo i valori `building`, `broadcast`, + `confirmed`. Non esiste uno stato di fallimento: una scommessa che non va a + buon fine viene **cancellata**, e l'utente non risulta nel round. + + ```mermaid + stateDiagram-v2 + [*] --> building: place_bet fase 1 + building --> broadcast: place_bet fase 2 / _promote + building --> confirmed: _on_bet_confirmed + broadcast --> confirmed: _on_bet_confirmed + building --> [*]: cancellata (_release_failed_bet / _abandon) + broadcast --> [*]: cancellata (_abandon) + confirmed --> [*] + ``` + + | Da | A | Funzione | + |---|---|---| + | — | `building` | `app/bets/service.py:place_bet` (fase 1, prima del broadcast) | + | `building` | `broadcast` | `place_bet` (fase 2, broadcast riuscito); `app/tx/reconcile.py:_promote` | + | `building`, `broadcast` | `confirmed` | `app/bets/confirmation.py:_on_bet_confirmed` | + | `building` | cancellata | `app/bets/service.py:_release_failed_bet` (broadcast rifiutato) | + | `building`, `broadcast` | cancellata | `app/tx/reconcile.py:_abandon` | + + Se il compare-and-set di [DAT-16] fallisce, la riga `building` non arriva + nemmeno al commit. + *Codice:* le funzioni in tabella · + *Test:* `tests/unit/test_bets.py`, `tests/unit/test_confirmation.py`, + `tests/unit/test_reconcile.py` + +- **[DAT-25]** `broadcast_at` è l'istante della fase 1 di `place_bet`, appena + prima del broadcast, e non viene mai riscritto (nemmeno da un bump RBF). + L'estrazione ordina i partecipanti confermati per `broadcast_at` crescente. + *Codice:* `app/bets/service.py:place_bet` · + `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` + +- **[DAT-26]** `bet_amount_sats` è l'importo del primo output della scommessa + (`recipient_sats`), cioè `round_config.bet_amount_sats` in vigore al momento + della scommessa meno la commissione di rete. Non cambia più: un bump RBF + riduce il resto dell'utente, non l'output verso il pool. Per questo il + montepremi si calcola sommando questa colonna, non moltiplicando il numero di + partecipanti per il costo attuale (B-11). + *Codice:* `app/bets/service.py:place_bet` · + `app/wallet/psbt_builder.py:build_signed_transaction` · + `app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_bets.py` + +- **[DAT-27]** Il numero di righe di un round, contando tutti gli stati, non + supera `MAX_PARTICIPANTS_PER_ROUND` (400): la 401ª scommessa è rifiutata con + `round_full` prima che si muova denaro (B-52). + *Codice:* `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py` · + *Test:* `tests/unit/test_bets.py` + +### `round_config` + +I parametri operativi, in **una sola riga**, modificabili a caldo da `/admin` +senza riavviare (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)). + +| Colonna | Tipo | Default | Limiti accettati da `PUT /admin/config` | Significato | Quando ha effetto | +|---|---|---|---|---|---| +| `id` | Integer PK | autoincrement | — | — | — | +| `fee_address` | String(128), non null | nessun default nel modello; la riga nasce con `""` | indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Subito: si legge a ogni costruzione del pagamento, quindi vale anche per il round in corso se il suo pagamento non è ancora stato costruito | +| `bet_amount_sats` | BigInteger | `1_000_000_000` (10 PLM) | `> 0`, `≤ 100 000 PLM` | Costo della scommessa; anche il prelievo minimo | Subito, per le scommesse e i prelievi successivi; le scommesse già piazzate tengono il proprio importo ([DAT-26]) | +| `round_duration_seconds` | Integer | `600` | 30 s – 7 giorni | Durata del round | Dal round **successivo** ([DAT-18]) | +| `round_cooldown_seconds` | Integer | `30` | 0 s – 24 h | Pausa dopo la chiusura | Dal round **successivo**: la pausa dopo il round in corso è quella copiata alla sua apertura ([DAT-18]) | +| `draw_animation_seconds` | Integer | `20` | 0 – 600 s | Durata minima dell'animazione dell'estrazione nel frontend; non condiziona l'estrazione vera | Subito (letto da `GET /rounds/current`) | +| `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (10 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` | +| `rbf_timeout_seconds` | Integer | `900` | 60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) | +| `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa di manutenzione | Subito per l'apertura del round successivo; il round in corso non è toccato | +| `updated_at` | DateTime | `utcnow`, aggiornato a ogni modifica (`onupdate`) | — | Ultima modifica della riga | ORM | + +- **[DAT-28]** La tabella contiene al più una riga, creata alla prima lettura + con i default di colonna di `RoundConfig` e `fee_address = ""`. Tutti leggono + la configurazione attraverso `get_round_config`, che prende la prima riga + trovata. Il database non impone l'unicità della riga: la garanzia è che solo + `get_round_config` ne inserisce una. I default di un'istanza nuova sono quelli + del modello, non variabili d'ambiente. + *Codice:* `app/rounds/config.py:get_round_config` · `app/db/models.py:RoundConfig` · + *Test:* `tests/unit/test_admin.py` + +- **[DAT-29]** Le colonne modificabili da `PUT /admin/config` sono esattamente + `fee_address`, `bet_amount_sats`, `round_duration_seconds`, + `round_cooldown_seconds`, `fee_rate_sat_vb`, `rbf_timeout_seconds`, + `draw_animation_seconds`, entro i limiti della tabella sopra. Ogni modifica + effettiva scrive una voce `config_updated` con il valore prima e dopo, e un + valore uguale a quello attuale non conta come modifica. + *Codice:* `app/api/routes/admin.py:update_config` · + `app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py` + +- **[DAT-30]** `paused` si modifica solo con `POST /admin/pause` e + `POST /admin/resume`, che scrivono `lottery_paused` e `lottery_resumed` nel + registro (B-10). + *Codice:* `app/api/routes/admin.py:pause_lottery` · + `app/api/routes/admin.py:resume_lottery` · *Test:* `tests/unit/test_admin.py` + +- **[DAT-31]** Con `fee_address` vuoto (o di soli spazi) non si apre nessun round + (B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la + validazione rifiuta la stringa vuota come indirizzo non valido. + *Codice:* `app/rounds/service.py:rounds_can_open` · + `app/api/routes/admin.py:RoundConfigUpdate` · + *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_admin.py` + +### `pending_transactions` + +Ogni transazione in uscita dalla piattaforma: scommesse, prelievi e pagamenti. +È la riga su cui lavorano il poller delle conferme, il bumper RBF e il +riconciliatore (vedi +[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md)). + +| Colonna | Tipo | Null | Default | Significato | Scritta da | +|---|---|---|---|---|---| +| `id` | Integer PK | no | autoincrement | — | database | +| `kind` | String(16) | no | — | `bet`, `withdrawal` o `payout` | chi crea la riga | +| `round_id` | FK `rounds.id` | sì | `NULL` | Il round (`bet`, `payout`) | `place_bet`; `_trigger_payout` | +| `withdrawal_id` | FK `withdrawals.id` | sì | `NULL` | Il prelievo (`withdrawal`) | `request_withdrawal` | +| `user_id` | FK `users.id` | sì | `NULL` | Il mittente (`bet`, `withdrawal`); `NULL` per `payout` | `place_bet`; `request_withdrawal` | +| `current_txid` | String(64) | no | — | Txid della versione più recente della transazione | creazione; `bump_fee` | +| `fee_rate_sat_vb` | Integer | no | — | Commissione della versione corrente, in sat/vB | creazione; `bump_fee` | +| `raw_tx_hex` | Text | no | — | Transazione firmata della versione corrente | creazione; `bump_fee` | +| `broadcast_at` | DateTime | no | `utcnow` | Prima trasmissione, vedi [DAT-35] | ORM, alla creazione | +| `last_broadcast_at` | DateTime | no | `utcnow` | Ultima trasmissione, vedi [DAT-35] | ORM; `bump_fee` | +| `status` | String(16) | no | `"pending"` (mai usato: si crea sempre `building`) | Vedi [DAT-33] | vedi diagramma | +| `replaced_by_txid` | String(64) | sì | `NULL` | Il txid **sostituito** dall'ultimo bump (nonostante il nome, punta all'indietro) | `bump_fee` | +| `attempt_count` | Integer | no | `1` | 1 + numero di bump riusciti | `bump_fee` | +| `failure_reason` | String(128) | sì | `NULL` | Perché la transazione è fallita; solo con `status = failed` | `_abandon`; `_release_failed_withdrawal` | +| `updated_at` | DateTime | no | `utcnow`, `onupdate` | Ultima modifica | ORM | + +Le funzioni citate sono `app/bets/service.py:place_bet`, +`app/withdrawals/service.py:request_withdrawal`, +`app/rounds/scheduler.py:RoundScheduler._trigger_payout`, +`app/tx/broadcast.py:bump_fee`, `app/tx/reconcile.py:_abandon` e +`app/withdrawals/service.py:_release_failed_withdrawal`. + +- **[DAT-32]** Le colonne di collegamento dipendono da `kind`: + + | `kind` | `round_id` | `user_id` | `withdrawal_id` | Firmata con | Riga di dominio collegata | + |---|---|---|---|---|---| + | `bet` | il round | lo scommettitore | `NULL` | chiave dell'utente | `round_participants` (`round_id`, `user_id`) | + | `withdrawal` | `NULL` | chi preleva | il prelievo | chiave dell'utente | `withdrawals` (`withdrawal_id`) | + | `payout` | il round | `NULL` | `NULL` | chiave del pool | `rounds` (`round_id`) | + + I gestori delle conferme e del riconciliatore ritrovano la riga di dominio da + questi identificativi, che non cambiano, e non dal txid, che cambia a ogni + bump (B-02). L'indirizzo di cui controllare la cronologia si ricava da + `kind` e `user_id`. + *Codice:* `app/tx/pending_address.py:own_address_for` · + `app/tx/broadcast.py:_signing_context` · + `app/bets/confirmation.py:_on_bet_confirmed` · + `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` · + `app/rounds/confirmation.py:_on_payout_confirmed` · + *Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_pending_address.py` + +- **[DAT-33]** `status` assume solo i valori `building`, `pending`, `confirmed`, + `failed`; gli ultimi due sono terminali. + + ```mermaid + stateDiagram-v2 + [*] --> building: creazione, prima del broadcast + building --> pending: broadcast riuscito / _promote + building --> failed: _release_failed_withdrawal / _abandon + building --> [*]: cancellata (_release_failed_bet) + pending --> confirmed: poll_once + pending --> failed: _abandon + confirmed --> [*] + failed --> [*] + ``` + + | Da | A | Funzione | + |---|---|---| + | — | `building` | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` | + | `building` | `pending` | le stesse tre, dopo il broadcast riuscito; `app/tx/reconcile.py:_promote` | + | `pending` | `confirmed` | `app/tx/confirmation.py:poll_once` (poi il gestore del `kind`) | + | `building`, `pending` | `failed` | `app/tx/reconcile.py:_abandon` | + | `building` | `failed` | `app/withdrawals/service.py:_release_failed_withdrawal` | + | `building` | cancellata | `app/bets/service.py:_release_failed_bet` | + + Un pagamento il cui broadcast fallisce resta `building` finché il + riconciliatore non lo risolve. Il poller delle conferme guarda solo le righe + `pending`. + *Codice:* le funzioni in tabella · + *Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_reconcile.py`, + `tests/unit/test_scheduler.py`, `tests/unit/test_bets.py`, + `tests/unit/test_withdrawals.py` + +- **[DAT-34]** Dopo la creazione, `current_txid`, `raw_tx_hex`, + `fee_rate_sat_vb`, `replaced_by_txid`, `attempt_count` e `last_broadcast_at` + cambiano solo in `bump_fee`, tutti nella stessa transazione del database, solo + dopo che il nuovo broadcast è riuscito e solo per una riga ancora `pending`. + `fee_rate_sat_vb` registra la commissione effettivamente pagata dalla nuova + versione, non quella obiettivo. `replaced_by_txid` conserva solo il txid + immediatamente precedente: la catena completa dei rimpiazzi non è memorizzata. + *Codice:* `app/tx/broadcast.py:bump_fee` · *Test:* `tests/unit/test_broadcast.py` + +- **[DAT-35]** `broadcast_at` è la **prima** trasmissione e non viene mai + riscritta: da lì il riconciliatore misura dopo quanto abbandonare (120 s per + `building`, 6 h per `pending`). `last_broadcast_at` è l'ultima trasmissione: + la aggiorna `bump_fee` e da lì `should_bump` misura se serve un altro bump. + Tenerle separate impedisce che bump ripetuti rinviino all'infinito + l'abbandono (B-27). + *Codice:* `app/tx/reconcile.py:_is_due` · `app/tx/broadcast.py:should_bump` · + `app/tx/broadcast.py:bump_fee` · + *Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_reconcile.py` + +- **[DAT-36]** `failure_reason` si scrive solo quando la riga passa a `failed`, + troncato a 128 caratteri: `"not found on chain"` dal riconciliatore, il + messaggio d'errore della rete per un prelievo rifiutato. + *Codice:* `app/tx/reconcile.py:_abandon` · + `app/withdrawals/service.py:_release_failed_withdrawal` · + *Test:* `tests/unit/test_reconcile.py` + +- **[DAT-37]** Per le righe `bet` e `withdrawal` in `building`/`pending`, + `raw_tx_hex` è la fonte del saldo "in attesa" (gli output che tornano + all'indirizzo dell'utente). Per le righe `payout` in `building`/`pending` + definisce gli outpoint del pool già impegnati ([DAT-11]). Per ogni riga + abbandonata definisce gli input da liberare ([DAT-13]). + *Codice:* `app/wallet/balance.py:compute_pending_balance` · + `app/rounds/scheduler.py:_reserved_payout_outpoints` · + `app/tx/reconcile.py:_release_inputs` · + *Test:* `tests/unit/test_balance.py`, `tests/unit/test_scheduler.py`, + `tests/unit/test_reconcile.py` + +- **[DAT-38]** Le righe `pending_transactions` non si cancellano, con una sola + eccezione: la riga `bet` il cui broadcast è stato rifiutato, cancellata insieme + al partecipante da `_release_failed_bet`. Un prelievo rifiutato invece lascia + la riga, in `failed`. + *Codice:* `app/bets/service.py:_release_failed_bet` · + `app/withdrawals/service.py:_release_failed_withdrawal` · + *Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py` + +### `withdrawals` + +Una richiesta di prelievo verso un indirizzo esterno. A differenza di una +scommessa, resta nella tabella anche quando fallisce: è un'istruzione data +dall'utente. + +| Colonna | Tipo | Null | Default | Significato | Scritta da | +|---|---|---|---|---|---| +| `id` | Integer PK | no | autoincrement | — | database | +| `user_id` | FK `users.id`, indicizzata | no | — | Chi preleva | `app/withdrawals/service.py:request_withdrawal` | +| `external_address` | String(128) | no | — | Destinatario: un indirizzo PLM valido, diverso da quello dell'utente | `request_withdrawal` | +| `amount_requested_sats` | BigInteger | no | — | Importo chiesto dall'utente | `request_withdrawal` | +| `amount_sent_sats` | BigInteger | sì (sempre valorizzata dal codice) | `NULL` | Quanto riceve il destinatario, vedi [DAT-40] | `request_withdrawal` | +| `txid` | String(64) | sì | `NULL` | Txid corrente; `NULL` dopo un fallimento | `request_withdrawal`; `_retarget_txid_references`; `_release_failed_withdrawal`; `_abandon` | +| `status` | String(16) | no | `"pending"` (mai usato: si crea sempre `building`) | Vedi [DAT-39] | vedi diagramma | +| `created_at` | DateTime | no | `utcnow` | Istante della richiesta | ORM | +| `confirmed_at` | DateTime | sì | `NULL` | Istante in cui il prelievo è risultato confermato | `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` | + +- **[DAT-39]** `status` assume solo i valori `building`, `broadcast`, + `confirmed`, `failed`; gli ultimi due sono terminali. Il passaggio a `failed` + azzera `txid`. + + ```mermaid + stateDiagram-v2 + [*] --> building: request_withdrawal + building --> broadcast: broadcast riuscito / _promote + building --> confirmed: _on_withdrawal_confirmed + broadcast --> confirmed: _on_withdrawal_confirmed + building --> failed: _release_failed_withdrawal / _abandon + broadcast --> failed: _abandon + confirmed --> [*] + failed --> [*] + ``` + + | Da | A | Funzione | + |---|---|---| + | — | `building` | `app/withdrawals/service.py:request_withdrawal` | + | `building` | `broadcast` | `request_withdrawal` (broadcast riuscito); `app/tx/reconcile.py:_promote` | + | `building`, `broadcast` | `confirmed` | `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` | + | `building` | `failed` | `app/withdrawals/service.py:_release_failed_withdrawal` | + | `building`, `broadcast` | `failed` | `app/tx/reconcile.py:_abandon` | + + *Codice:* le funzioni in tabella · + *Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py` + +- **[DAT-40]** `amount_requested_sats` è ciò che l'utente ha chiesto; + `amount_sent_sats` è l'output effettivo verso il destinatario: l'importo + chiesto meno la commissione di rete e, per un prelievo dell'intero saldo, meno + quanto serve a lasciare un resto di `DUST_LIMIT_SATS` (B-62). Le due colonne si + scrivono insieme alla creazione e non cambiano più: un bump RBF riduce il + resto, non l'output del destinatario. + *Codice:* `app/withdrawals/service.py:request_withdrawal` · + `app/wallet/psbt_builder.py:build_signed_transaction` · + *Test:* `tests/unit/test_withdrawals.py` + +### `bug_reports` + +Una segnalazione inviata da `/report-bug`, anche senza login. + +| Colonna | Tipo | Null | Default | Significato | Scritta da | +|---|---|---|---|---|---| +| `id` | Integer PK | no | autoincrement | — | database | +| `description` | Text | no | — | Testo della segnalazione, già ripulito dagli spazi, 1–2000 caratteri (limite dell'API) | `app/api/routes/bug_reports.py:create_bug_report` | +| `contact` | String(256) | sì | `NULL` | Recapito facoltativo; una stringa vuota diventa `NULL` | `create_bug_report` | +| `user_id` | FK `users.id` | sì | `NULL` | Chi ha segnalato, se aveva fatto il login | `create_bug_report` | +| `status` | String(16) | no | `"open"` | `open`, `read` o `resolved` | `create_bug_report` (default); `app/api/routes/admin.py:update_bug_report_status` | +| `created_at` | DateTime | no | `utcnow` | Istante della segnalazione | ORM | + +- **[DAT-41]** `status` assume solo i valori `open`, `read`, `resolved`. Nasce + `open`. Poi lo cambia solo l'amministratore, verso **uno qualunque** dei tre + valori (il percorso previsto è `open → read → resolved`, ma anche i ritorni + indietro sono ammessi). Ogni cambio effettivo scrive + `bug_report_status_changed` con il valore prima e dopo, e riassegnare lo + stesso valore non scrive nulla (B-60). `read` esiste per permettere a chi ha + segnalato di distinguere "visto" da "risolto". + *Codice:* `app/api/routes/admin.py:update_bug_report_status` · + `app/api/routes/bug_reports.py:create_bug_report` · + *Test:* `tests/unit/test_bug_reports.py` + +### `audit_log` + +Il registro di audit: chi ha fatto cosa, e quando. Il catalogo dei tipi di +evento e dei loro payload è in [audit-log.md](audit-log.md). + +| Colonna | Tipo | Null | Default | Significato | +|---|---|---|---|---| +| `id` | Integer PK | no | autoincrement | Ordine di scrittura | +| `event_type` | String(32) | no | — | Tipo di evento (es. `bet_placed`, `payout_failed`) | +| `payload_json` | Text | no | — | Dettagli dell'evento, oggetto JSON serializzato | +| `user_id` | FK `users.id` | sì | `NULL` | Utente interessato, se c'è | +| `round_id` | FK `rounds.id` | sì | `NULL` | Round interessato, se c'è | +| `created_at` | DateTime | no | `utcnow` | Istante di scrittura | + +- **[DAT-42]** Il registro è in sola aggiunta: le righe si inseriscono solo con + `write_audit_log`, che aggiunge la riga alla sessione del chiamante senza + fare commit, così la voce entra nella stessa transazione del cambiamento che + descrive. Nessun codice aggiorna o cancella righe di `audit_log`. + `payload_json` è `Text` (B-47). + *Codice:* `app/audit/log.py:write_audit_log` · + *Test:* `tests/unit/test_models_text_columns.py`, `tests/unit/test_admin.py` + +- **[DAT-43]** Il registro non è solo informativo. Il `created_at` dell'ultima + voce `payout_failed` di un round fa da orologio per il riprovare del + pagamento: un nuovo tentativo parte solo 60 s dopo (B-26). Per questo ogni + uscita anticipata di `_trigger_payout` scrive una voce `payout_failed`. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` · + `app/rounds/scheduler.py:RoundScheduler._log_payout_failure` · + *Test:* `tests/unit/test_scheduler.py` + +### `alembic_version` + +Tabella di servizio di Alembic: una riga con la revisione applicata +(`version_num`). Non è nel modello e il codice applicativo non la legge. + +## Indici e vincoli + +| Nome | Tabella | Definizione | Perché | +|---|---|---|---| +| PK `id` | tutte | intero autoincrementale | [DAT-05] | +| `ix_users_username` | `users` | `UNIQUE (username)` | unicità esatta, dallo schema iniziale; ridondante rispetto alla riga sotto | +| `ix_users_username_lower` | `users` | `UNIQUE (lower(username))` | username senza distinzione di maiuscole, [DAT-06] (B-57) | +| unico `derivation_index` | `users` | `UNIQUE` | un indice HD, quindi un indirizzo, per utente, [DAT-07] | +| unico `address` | `users` | `UNIQUE` | un indirizzo non appartiene a due utenti | +| unico (`txid`, `vout`) | `utxo_events` | `UNIQUE` | un outpoint si accredita una volta sola, [DAT-10] | +| `ix_utxo_events_user_id` | `utxo_events` | indice su `user_id` | calcolo del saldo e selezione degli UTXO per utente | +| `ix_rounds_single_active` | `rounds` | `UNIQUE ((1)) WHERE status IN ('open','closing','drawing','paying_out')` | al massimo un round attivo, [DAT-17] (B-09) | +| unico (`round_id`, `user_id`) | `round_participants` | `UNIQUE` | una scommessa per utente per round, [DAT-23] | +| `ix_round_participants_round_id` | `round_participants` | indice | partecipanti di un round | +| `ix_round_participants_user_id` | `round_participants` | indice | round di un utente (`last-round-result`) | +| `ix_withdrawals_user_id` | `withdrawals` | indice | prelievi di un utente | + +Non ci sono indici su `pending_transactions`, `audit_log` o `bug_reports` oltre +alla chiave primaria. In particolare `pending_transactions.status` e +`audit_log.event_type`/`round_id`, usati dai filtri dei task in background, sono +letti con una scansione completa. + +## Invarianti tra tabelle + +- **[DAT-44]** **Prima si scrive, poi si trasmette.** Ogni transazione in + uscita esiste nel database prima di essere trasmessa: la riga + `pending_transactions` in `building`, la riga di dominio (partecipante in + `building`, prelievo in `building`, oppure `payout_txid` sul round) e, per + scommesse e prelievi, gli input già marcati con `spent_txid`, tutto in un + commit che precede il broadcast (B-08, B-25). Nessuna sessione del database + resta aperta durante una chiamata di rete. + *Codice:* `app/bets/service.py:place_bet` · + `app/withdrawals/service.py:request_withdrawal` · + `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · + *Test:* `tests/unit/test_bets.py`, `tests/unit/test_scheduler.py` + +- **[DAT-45]** **Un solo txid valido.** Per ogni transazione non terminale, + `pending_transactions.current_txid` coincide con il txid registrato sulla + riga di dominio (`round_participants.bet_txid`, `withdrawals.txid`, + `rounds.payout_txid`) e con lo `spent_txid` di ogni input impegnato + ([DAT-14]). `bump_fee` li aggiorna tutti nella stessa transazione del + database. Per un pagamento, `payout_txid` si aggiorna solo se era ancora + uguale al vecchio txid. + *Codice:* `app/tx/broadcast.py:_retarget_txid_references` · + *Test:* `tests/unit/test_broadcast.py` + +- **[DAT-46]** **Il montepremi si conserva.** Per un round pagato: + `pool_amount_sats` = somma dei `bet_amount_sats` dei partecipanti + `confirmed`; `fee_amount_sats` = `pool_amount_sats` − 70% (arrotondato per + difetto) di `pool_amount_sats`; `winner_amount_sats` = quel 70% meno la + commissione di rete del pagamento. La commissione di rete esce quindi dalla + quota del vincitore, mai da quella della piattaforma. La ripartizione è nel + codice, non in `round_config`. + *Codice:* `app/rounds/service.py:winner_share` · + `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · + `app/wallet/psbt_builder.py:build_payout_transaction` · + *Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py` + +- **[DAT-47]** **Un round non si chiude con scommesse in volo.** Finché un round + ha partecipanti in `building` o `broadcast` resta in `closing`. Il conteggio + si ripete nella stessa sessione che legge i partecipanti da estrarre (B-53), e + l'attesa finisce perché ogni riga in volo viene prima o poi confermata o + cancellata dal riconciliatore. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · + `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` + +- **[DAT-48]** **Un solo pagamento per round.** Non si costruisce un pagamento + per un round che ha già una riga `payout` in `building` o `pending`, e gli + outpoint del pool impegnati da qualunque pagamento non risolto sono esclusi + dalla selezione ([DAT-11]). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · + `app/rounds/scheduler.py:_reserved_payout_outpoints` · + *Test:* `tests/unit/test_scheduler.py` + +- **[DAT-49]** **Le transazioni ritrovano la loro riga senza il txid.** Le righe + collegate a una `pending_transactions` si ritrovano tramite `round_id`, + `user_id` e `withdrawal_id` ([DAT-32]); il txid serve solo come ripiego per + righe senza collegamento. + *Codice:* `app/bets/confirmation.py:_on_bet_confirmed` · + `app/rounds/confirmation.py:_on_payout_confirmed` · + *Test:* `tests/unit/test_confirmation.py` + +## Motore e sessioni + +- **[DAT-50]** C'è un solo motore asincrono per processo, creato + all'importazione di `app/db/base.py` da `settings.database_url` + (`DATABASE_URL`; predefinito `sqlite+aiosqlite:///./plm_lottery.db`, e nel + container `sqlite+aiosqlite:////app/db_data/plm_lottery.db`, montato da + `./data/db`). Le sessioni si creano con `AsyncSessionLocal` + (`async_sessionmaker` con `expire_on_commit=False`): gli oggetti letti restano + utilizzabili dopo il commit. + *Codice:* `app/db/base.py` · `app/config.py` + +- **[DAT-51]** Gli endpoint HTTP ricevono una sessione per richiesta dalla + dipendenza `get_session`. I task in background ricevono `AsyncSessionLocal` + come factory e aprono sessioni brevi, chiuse prima di ogni chiamata di rete. + Il commit è sempre esplicito: `get_session` non ne fa alla fine della + richiesta. + *Codice:* `app/db/session.py:get_session` · `app/main.py` · + *Test:* `tests/unit/test_broadcast.py` + +- **[DAT-52]** Su SQLite ogni nuova connessione esegue + `PRAGMA journal_mode=WAL`, `PRAGMA synchronous=NORMAL` e + `PRAGMA busy_timeout=5000` (B-39): lettori e scrittori non si bloccano a + vicenda, e un secondo scrittore aspetta fino a 5 s invece di fallire subito. + Con un altro dialetto non viene eseguito nulla. `PRAGMA foreign_keys` non è + tra questi, da cui [DAT-04]. + *Codice:* `app/db/base.py:_register_sqlite_pragmas` · + *Test:* `tests/unit/test_db_base.py` + +## Migrazioni + +### Politica + +- **[DAT-53]** Lo schema si cambia solo con una revisione Alembic in + `migrations/versions/`. Si modifica `app/db/models.py`, poi si genera la + revisione con `alembic revision --autogenerate -m "..."` e la si **rilegge e + corregge a mano** prima del commit. La catena è lineare, senza rami. + `migrations/env.py` prende l'URL da `settings.database_url` e i metadati da + `Base.metadata`, e non abilita `render_as_batch`: le modifiche a colonne + esistenti su SQLite vanno scritte a mano dentro `op.batch_alter_table`. + *Codice:* `migrations/env.py` · `app/db/models.py` + +- **[DAT-54]** Le migrazioni si applicano **solo** all'avvio del container: il + comando del `Dockerfile` controlla che esista la master key, esegue + `alembic upgrade head` e solo se riesce avvia `uvicorn`. Una migrazione che + fallisce impedisce quindi al server di partire invece di lasciarlo girare su + uno schema a metà. `alembic upgrade head` non va mai lanciato a mano. + *Codice:* `Dockerfile` + +- **[DAT-55]** Una colonna `NOT NULL` aggiunta a una tabella che può avere già + righe deve dare un valore a quelle righe nella migrazione stessa, in uno di + due modi: un `server_default` temporaneo, rimosso subito dopo in modo che le + righe nuove prendano il default dell'ORM (`round_config`, `users.token_version`); + oppure un backfill esplicito calcolato dai dati (`last_broadcast_at` copiato da + `broadcast_at`; `rounds.duration_seconds`/`cooldown_seconds` copiati da + `round_config`, così il round in corso non cambia durata con la migrazione). + *Codice:* `migrations/versions/53cc70d16e63_add_operational_params_to_round_config.py` · + `migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py` · + `migrations/versions/283844a44b4a_snapshot_round_timing_onto_the_round_.py` + +- **[DAT-56]** Gli indici su espressioni (`ix_rounds_single_active`, + `ix_users_username_lower`) vanno scritti a mano nella migrazione: + l'autogenerate su SQLite non li sa confrontare e li ignora con un avviso. + Inoltre `op.batch_alter_table` su SQLite ricrea la tabella e **perde senza + errori** gli indici su espressioni, perché SQLAlchemy non li legge dallo + schema. Una migrazione futura che usi `batch_alter_table` su `rounds` o + `users` deve quindi ricreare esplicitamente l'indice relativo. Oggi nessuna + migrazione lo fa dopo la creazione di quegli indici. + *Codice:* `migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py` · + `migrations/versions/c1d4a97b5e10_case_insensitive_usernames.py` + +- **[DAT-57]** Una migrazione che incontra dati incompatibili con un nuovo + vincolo si comporta in uno di due modi. Se esiste una correzione **senza + ambiguità**, la applica e procede. Se correggere significherebbe decidere + sulla proprietà dei fondi, **si rifiuta**, e il server non parte ([DAT-54]). + Le due migrazioni di questo tipo sono [DAT-58] e [DAT-59]. + +- **[DAT-58]** `c1d4a97b5e10` (username senza distinzione di maiuscole, B-57) + **si rifiuta di procedere** se due account esistenti hanno username che + differiscono solo per maiuscole/minuscole: solleva un errore che elenca i + gruppi in collisione e non crea l'indice. Non sceglie quale account + rinominare, perché entrambi possono contenere fondi: l'operatore rinomina a + mano un account (e avvisa l'utente), poi riavvia (vedi + [operativita.md](operativita.md)). + *Codice:* `migrations/versions/c1d4a97b5e10_case_insensitive_usernames.py` + +- **[DAT-59]** `8a1c4e7b2d90` (indice del round attivo unico, B-09), se trova più + round attivi, porta a `closed` tutti tranne il più recente prima di creare + l'indice. È la stessa scelta che `get_active_round` faceva già in silenzio. + *Codice:* `migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py` + +Nessun test esegue le migrazioni. I test unitari creano lo schema direttamente +dal modello (`Base.metadata.create_all`), quindi verificano il modello, non la +catena di revisioni. + +### Storia dello schema + +In ordine di applicazione (ogni riga rivede la precedente): + +| Revisione | Contenuto | +|---|---| +| `274efdcbfbcc` | Schema iniziale: `round_config` (solo `fee_address`, `bet_amount_sats`, `updated_at`), `users`, `rounds`, `utxo_events`, `withdrawals`, `audit_log`, `pending_transactions`, `round_participants` | +| `53cc70d16e63` | `round_config`: `round_duration_seconds`, `round_cooldown_seconds`, `min_amount_sats`, `fee_rate_sat_vb`, `rbf_timeout_seconds` (valori prima presi da `app/config.py`) | +| `1db52f3a7c67` | `round_config.draw_animation_seconds` | +| `5f2079b95b33` | `round_config.paused` | +| `6cb50b29f64c` | Rimosso `round_config.min_amount_sats`: il prelievo minimo è il costo della scommessa | +| `8a1c4e7b2d90` | `pending_transactions.failure_reason`; indice `ix_rounds_single_active` (B-04, B-09) | +| `861e76aaf34c` | `pending_transactions.last_broadcast_at`, con backfill da `broadcast_at` (B-27) | +| `943dbd74d983` | `users.token_version` (B-34) | +| `9ef6a51509f7` | `rounds.drawing_started_at` (B-36) | +| `87a0c640355c` | `audit_log.payload_json` e `pending_transactions.raw_tx_hex` da `VARCHAR` a `Text` (B-47) | +| `ee8508d98d34` | Tabella `bug_reports`, con un booleano `resolved` | +| `be71fdac734e` | `bug_reports.resolved` sostituito da `status` a tre valori (`resolved` → `'resolved'`, il resto → `'open'`) | +| `c1d4a97b5e10` | Indice `ix_users_username_lower` (B-57), [DAT-58] | +| `283844a44b4a` | `rounds.duration_seconds`/`cooldown_seconds` (B-61), backfill dalla configurazione attuale — **head** | + +### Differenze note tra modello e schema migrato + +- `users.token_version`: il modello dichiara `server_default="0"`, ma la + migrazione `943dbd74d983` rimuove il default del server dopo il backfill. Un + database migrato non ha quindi un default a livello SQL. Non ha effetti + pratici, perché ogni inserimento passa dall'ORM che fornisce `0`, e + l'autogenerate non confronta i default del server, quindi non lo segnala. +- `rounds.duration_seconds`/`cooldown_seconds`: `server_default` presente sia + nel modello sia nel database migrato. + +## Scelte specifiche di SQLite + +- **Un solo file, un solo processo.** SQLite con WAL regge i sei task in + background e le richieste HTTP di un singolo processo uvicorn ([DAT-52]). I + lock per utente e il canale SSE sono comunque in memoria, quindi più + processi non sono supportati in nessun caso (vedi + [decisioni-e-limiti.md](decisioni-e-limiti.md)). +- **Serializzazione degli scrittori.** Il compare-and-set di [DAT-16] si basa sul + lock di scrittura di SQLite: un conflitto tra snapshot arriva come + `OperationalError` e viene trattato come "round non più aperto". +- **Indice parziale su espressione.** `ix_rounds_single_active` usa sia un'espressione + (`(1)`) sia una clausola `WHERE`; SQLite e PostgreSQL supportano entrambe, e il + modello dichiara la clausola per tutti e due (`sqlite_where`, + `postgresql_where`). La migrazione la crea invece con SQL grezzo + (`CREATE UNIQUE INDEX ... ON rounds ((1)) WHERE ...`). +- **Timestamp senza fuso.** SQLite non conserva il fuso orario ([DAT-02]). +- **Chiavi esterne non applicate** ([DAT-04]). +- **`Text` invece di `VARCHAR` senza lunghezza** per i contenuti di lunghezza + arbitraria (`raw_tx_hex`, `payload_json`, `description`), così lo schema resta + portabile ad altri database (B-47). +- **Portabilità.** I pragma sono limitati al dialetto `sqlite` e l'indice del + round attivo è dichiarato anche per PostgreSQL. Un `DATABASE_URL` PostgreSQL + non è però mai stato provato, e il database supportato resta SQLite. diff --git a/docs/operativita.md b/docs/operativita.md new file mode 100644 index 0000000..19d9272 --- /dev/null +++ b/docs/operativita.md @@ -0,0 +1,562 @@ +# Operatività + +Manuale operativo di PLM Lottery: dove stanno i dati, cosa salvare, cosa +succede a un riavvio, e cosa fare quando qualcosa si blocca. Presuppone +installazione e avvio già fatti: la prima configurazione è in +[setup.md](setup.md), i comandi Docker di tutti i giorni in +[running-the-server.md](running-the-server.md), l'uso del pannello in +[guida-admin.md](guida-admin.md). Qui c'è quello che quelle guide non coprono. + +Le procedure citano spesso eventi del registro di audit: il loro significato +esatto è in [audit-log.md](audit-log.md). Il funzionamento interno che spiega +*perché* un round si blocca è in +[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md), +[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) e +[connessione-electrum.md](connessione-electrum.md). + +## Topologia + +```mermaid +flowchart LR + Internet -- "80 / 443" --> caddy["container caddy
(TLS, header di sicurezza)"] + caddy -- "app:8123" --> app["container app
(FastAPI + 6 attività)"] + app -- "Electrum SSL" --> E1["server Electrum
primario"] + app -. "rotazione e quorum" .-> E2["server di riserva
(ELECTRUM_FALLBACK_SERVERS)"] + app --- db[("./data/db
plm_lottery.db")] + app --- keys[("./data/keys
master.xprv.enc")] + app --- logs[("./data/logs
app.log")] + caddy --- cdata[("volumi Docker
caddy_data, caddy_config")] +``` + +- **[OPS-01]** Il deployment è composto da due container definiti in + `docker-compose.yml`, entrambi con `restart: unless-stopped`: `app` (questo + codice, porta 8123 esposta solo sulla rete interna) e `caddy` (reverse proxy + sulle porte 80 e 443 dell'host). Il container `app` monta tre cartelle + dell'host: `./data/db` su `/app/db_data`, `./data/keys` su `/app/key_data`, + `./data/logs` su `/app/logs`; `DATABASE_URL` e `MASTER_KEY_PATH` sono + sovrascritti per puntare lì. Caddy tiene certificati e stato ACME nei volumi + Docker con nome `caddy_data` e `caddy_config`, non in `./data`. + *Codice:* `docker-compose.yml`, `Caddyfile` + +- **[OPS-02]** All'avvio il container `app` esegue, in quest'ordine: controllo + che il file della master key esista (altrimenti esce con errore e un + suggerimento del comando da lanciare), `alembic upgrade head`, avvio del + server. Durante l'avvio dell'applicazione `validate_runtime_secrets` rifiuta + di partire se `JWT_SECRET` è più corto di 32 caratteri o + `XPRV_ENCRYPTION_KEY` è vuoto, e `parse_endpoints` rifiuta una voce malformata + in `ELECTRUM_FALLBACK_SERVERS`. Un `ADMIN_TOKEN` vuoto non blocca l'avvio: il + pannello resta chiuso a tutti. + *Codice:* `Dockerfile` (`CMD`), `app/main.py:lifespan`, + `app/config.py:validate_runtime_secrets`, + `app/electrum/client.py:parse_endpoints` · + *Test:* `tests/unit/test_config.py`, `tests/unit/test_electrum_client.py` + +- **[OPS-03]** Il database SQLite lavora in modalità **WAL**: accanto a + `plm_lottery.db` esistono `plm_lottery.db-wal` e `plm_lottery.db-shm`, e le + scritture più recenti possono trovarsi solo nel file `-wal`. I tre file vanno + sempre trattati insieme. + *Codice:* `app/db/base.py:_register_sqlite_pragmas` · + *Test:* `tests/unit/test_db_base.py` + +- **[OPS-04]** `/health` risponde sempre `{"status": "ok"}` finché il processo + è vivo: **non** dice nulla sulla connessione Electrum né sullo stato dei + round. Non va usato come unico segnale di salute (vedi + [Segnali da tenere d'occhio](#segnali-da-tenere-docchio)). + *Codice:* `app/main.py:health` + +## Backup e ripristino + +### Cosa salvare + +| Cosa | Dove | Quanto spesso | Perché | +|---|---|---|---| +| Database | `./data/db/plm_lottery.db` (+ `-wal`, `-shm`) | Spesso (almeno giornaliero) | Utenti, indici di derivazione, UTXO, round, prelievi, audit | +| Master key cifrata | `./data/keys/master.xprv.enc` | Una volta (non cambia) | Da essa derivano tutti gli indirizzi e il montepremi | +| `XPRV_ENCRYPTION_KEY` | `.env` | Una volta, **in un luogo separato** dalla master key | Senza, la master key cifrata è illeggibile | +| Altri segreti di `.env` | `.env` | A ogni cambio | `JWT_SECRET`, `ADMIN_TOKEN`, server Electrum | +| Stato di Caddy | volume `caddy_data` | Facoltativo | Certificati; Caddy li richiede di nuovo se mancano | + +- **[OPS-05]** Master key cifrata e `XPRV_ENCRYPTION_KEY` si salvano **in posti + diversi**: uno dei due da solo non basta a spendere, entrambi insieme danno il + controllo di tutti i fondi. Perderli entrambi significa perdere per sempre i + fondi di tutti gli utenti e del montepremi. In alternativa si può conservare + offline la xprv in chiaro, ottenuta con `scripts/decrypt_master_key.py`: in + quel caso quella copia da sola è la chiave di tutto. + *Codice:* `app/wallet/keystore.py:decrypt_xprv`, + `scripts/decrypt_master_key.py` + +- **[OPS-06]** Un backup coerente del database, **senza fermare il server**, si + fa con l'API di backup di SQLite, che include il contenuto del file WAL: + + ```bash + docker compose exec app python -c "import sqlite3; s=sqlite3.connect('/app/db_data/plm_lottery.db'); d=sqlite3.connect('/app/db_data/backup.db'); s.backup(d); d.close(); s.close()" + # il file compare come ./data/db/backup.db sull'host: spostarlo altrove + ``` + + Copiare a mano il solo `plm_lottery.db` mentre il server gira **non** è un + backup affidabile ([OPS-03]). In alternativa: `docker compose stop app`, + copiare i tre file, `docker compose start app`, ma quel riavvio ha le + conseguenze descritte in [OPS-10]. + *Codice:* `app/db/base.py:_register_sqlite_pragmas` + +### Ripristino + +- **[OPS-07]** Procedura di ripristino: + 1. `docker compose down`. + 2. Rimettere `.env` (con la stessa `XPRV_ENCRYPTION_KEY` usata per cifrare la + master key) e `./data/keys/master.xprv.enc`. Se si ha solo la xprv in + chiaro, ricifrarla con `scripts/encrypt_master_key.py`. + 3. Rimettere il database in `./data/db/plm_lottery.db` e **cancellare** + eventuali `-wal`/`-shm` rimasti dall'istanza precedente, che non + appartengono al file ripristinato. + 4. `docker compose up -d --build`. Le migrazioni si applicano da sole. + 5. Controllare in `/admin` lo stato del round attivo e le transazioni + pendenti; un round rimasto in `drawing` nel backup va sbloccato con + [OPS-13]. + + All'avvio il riconciliatore delle transazioni e, dopo 300s, il controllo + periodico dei depositi riallineano il database alla catena: UTXO spesi dopo + il backup vengono segnati come spesi all'esterno (`utxo_spent_externally`), + quelli arrivati dopo vengono accreditati. + *Codice:* `app/tx/reconcile.py:reconcile_once`, + `app/deposits/reconcile.py:DepositReconciler` + +- **[OPS-08]** **Rischio di un backup vecchio: indirizzi riassegnati.** Il + nuovo utente riceve l'indice di derivazione `max(derivation_index) + 1`. + Gli utenti registrati dopo il backup spariscono con il ripristino, e i loro + indici vengono riassegnati ai prossimi iscritti: il nuovo utente riceve lo + **stesso indirizzo** di un utente perso, e con esso i fondi che quello vi + aveva depositato. Il codice non ha alcuna protezione contro questo caso, né + un modo per sospendere le registrazioni. Dopo un ripristino da un backup non + recentissimo, prima di riaprire il servizio, bisogna ricostruire gli utenti + mancanti (ad esempio dai log di `POST /auth/register` in `app.log`, se ancora + presenti) oppure accettare consapevolmente il rischio. + *Codice:* `app/auth/routes.py:register` + +## Riavvio + +Un riavvio avviene a ogni deploy (`docker compose up -d --build`), a ogni +modifica di `.env` e da solo dopo un crash (`restart: unless-stopped`). + +- **[OPS-09]** Al riavvio nessuna attività lavora finché il listener non ha una + connessione con un'altezza di catena nota. Poi, a seconda dello stato del + round attivo: + + | Stato al momento del riavvio | Cosa succede | + |---|---| + | nessun round / `closed` | Si apre un nuovo round rispettando il cooldown del round precedente | + | `open` | Riprende: la scadenza è calcolata dai valori salvati sul round | + | `closing` | Riprende: si aspetta che le scommesse in volo confermino, poi si estrae | + | `drawing` | **Non riprende.** Il round resta in `drawing` per sempre e nessun nuovo round si apre. Serve [OPS-13] | + | `paying_out` | Riprende: il pagamento viene ritentato subito se non c'è un `payout_failed` negli ultimi 60s; un pagamento già in volo non viene duplicato | + + Le transazioni rimaste `building` (crash a metà invio) vengono risolte dal + riconciliatore, non prima di 120 secondi dalla loro creazione e al primo + passaggio con la connessione attiva (i passaggi sono ogni 120s, e quello + "immediato" all'avvio di norma trova la connessione non ancora pronta e viene + saltato): promosse se sono in catena, abbandonate altrimenti. Si perdono invece, perché solo in memoria: i + blocchi dei login per troppi tentativi e la quota di registrazioni per IP, le + connessioni SSE (i browser si riconnettono da soli), i lock per utente. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, + `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`, + `app/tx/reconcile.py:reconcile_once` · + *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_reconcile.py` + +- **[OPS-10]** **Riavvio sicuro** (deploy, modifica di `.env`, rotazione di un + segreto): + 1. In `/admin` → Parametri → Manutenzione, "Interrompi dopo questo round" + (`POST /admin/pause`). Il round in corso finisce normalmente. + 2. Aspettare che `GET /rounds/current` non riporti più un round attivo + (`round_id` vuoto) e che in "Transazioni pendenti" non ci siano scommesse + o prelievi `building`. + 3. Riavviare (`docker compose up -d --build`, oppure `docker compose up -d` + per un cambio di `.env`). + 4. Controllare in `data/logs/app.log` la riga `Electrum connected to …`, + poi "Riprendi lotteria" (`POST /admin/resume`). + + La pausa non blocca scommesse e prelievi sul round in corso, né + registrazioni e depositi. Un riavvio con il round in `open`, `closing` o + `paying_out` è comunque sicuro ([OPS-09]); quello da evitare è il riavvio + durante `drawing`. + *Codice:* `app/api/routes/admin.py:pause_lottery`, + `app/rounds/service.py:open_new_round_if_needed` · + *Test:* `tests/unit/test_admin.py` + +## Procedure di intervento + +Ogni procedura ha la stessa forma: come si riconosce, perché succede, cosa +fare. Per leggere il log: `tail -f data/logs/app.log` sull'host (i messaggi +dell'applicazione non compaiono in `docker compose logs`, vedi +[audit-log.md](audit-log.md)). + +### Electrum irraggiungibile + +- **[OPS-11]** + **Sintomi.** In `/admin` la pillola "Blocco #N" e `chain_tip_height` di + `GET /rounds/current` restano fermi sull'ultimo valore noto (l'altezza non si + azzera alla disconnessione, quindi *non* si vede un errore, si vede un numero + che non cresce). Scommesse e prelievi rispondono 503 `network_unavailable`. + Nel log: `Electrum session on … failed`, `Electrum session on … ending`, + `all N Electrum server(s) unreachable, retrying in Ns`. Lo scheduler e le + altre attività saltano i loro cicli; un round in `drawing` continua ad + aspettare e dopo 360s scrive `draw_stalled`. + **Causa.** Tutti i server configurati sono giù, irraggiungibili, o rifiutati + (un header che non supera la validazione chiude la sessione e fa passare al + server successivo). + **Cosa fare.** + 1. Nessuna azione è necessaria per il ripristino: il listener prova il + server successivo subito e, dopo un giro completo di fallimenti, riprova + con attesa crescente da 1 a 30 secondi, all'infinito. + 2. Verificare la raggiungibilità dal server: + `docker compose exec app python scripts/electrum_smoke_test.py` (prova + solo il primario, vedi [OPS-26]). + 3. Se i server configurati sono davvero tutti inutilizzabili, aggiungerne + altri in `ELECTRUM_FALLBACK_SERVERS` (elenco di riferimento: + `ChainProfiles.Mainnet.BootstrapServers` del repository PalladiumWallet) e + riavviare. **Se un round è in `drawing`, il riavvio lo blocca**: seguire + poi [OPS-13]. + *Codice:* `app/electrum/listener.py:ElectrumListener.run`, + `app/api/routes/bets.py`, `app/api/routes/withdrawals.py` · + *Test:* `tests/unit/test_electrum_listener.py` + +- **[OPS-12]** Con `ELECTRUM_FALLBACK_SERVERS` vuoto la corroborazione passa + sempre (non c'è nessuno a cui chiedere), ma un solo server guasto ferma + tutto. Con server di riserva configurati ma tutti irraggiungibili, la + corroborazione **fallisce**: depositi non accreditati, estrazione che scarta + ogni blocco (`draw_header_corroboration_failed`) e segnalazioni di spesa + esterna ignorate, anche se il server attivo funziona. In produzione servono + almeno due server di riserva raggiungibili. + *Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` · + *Test:* `tests/unit/test_electrum_listener.py` + +### Round bloccato in `drawing` + +- **[OPS-13]** + **Sintomi.** `GET /rounds/current` riporta `status: "drawing"` con + `draw_waiting_since` lontano nel tempo; nessun nuovo round si apre. Possibili + cause visibili: + - righe `draw_stalled` ripetute ogni 6 minuti: l'altezza della catena non + sale (Electrum, vedi [OPS-11]); + - righe `draw_header_corroboration_failed`, una per blocco: i blocchi + arrivano ma gli altri server non li confermano (vedi [OPS-12]); + - **nessuna riga nuova e nessun messaggio `still waiting for a block` nel + log**: l'attesa non è più in corso. Succede dopo un riavvio del processo, + oppure dopo un'eccezione durante l'attesa (messaggio + `round scheduler tick failed`, per esempio un database bloccato mentre si + scriveva `draw_stalled`). In questo caso il round non si sbloccherà mai da + solo. + + **Causa.** L'attesa del blocco di estrazione vive solo dentro la chiamata + dello scheduler che ha portato il round in `drawing`; a ogni ciclo successivo + lo scheduler vede `drawing` e non fa nulla. Nessun codice riprende l'attesa. + + **Cosa fare.** Nei primi due casi basta risolvere la causa (Electrum, + server di riserva): l'estrazione avviene da sola al primo blocco valido. + Nel terzo caso **non esiste una procedura supportata dal codice**. L'unico + intervento possibile è una modifica manuale del database che riporti il round + a `closing`, lo stato da cui lo scheduler rifà l'estrazione da capo: + + 1. Annotare l'id del round (dal pannello, sezione Round) e controllare che + sia davvero in `drawing` e che `winner_user_id` sia vuoto. + 2. `docker compose stop app`. + 3. Aggiornare lo stato: + ```bash + docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); n=c.execute(\"UPDATE rounds SET status='closing' WHERE id= AND status='drawing' AND winner_user_id IS NULL\").rowcount; c.commit(); print(n, 'riga aggiornata')" + ``` + Deve stampare `1 riga aggiornata`. + 4. `docker compose start app`. + 5. Al primo ciclo lo scheduler rilegge i partecipanti confermati, riporta il + round in `drawing` con un nuovo `drawing_started_at` e aspetta un blocco + successivo all'altezza *attuale*; poi estrae e paga normalmente + (`winner_drawn`, `payout_sent`). + + Conseguenze da sapere: l'estrazione usa un blocco successivo al momento + dello sblocco invece del primo blocco dopo la chiusura, quindi quale blocco + decide il vincitore dipende da quando l'operatore interviene; la modifica non + lascia alcuna riga nel registro di audit, quindi va annotata altrove, con + data, id del round e motivo. I partecipanti non cambiano: il round è fuori da + `open`, quindi nessuna scommessa nuova può entrare. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, + `app/rounds/scheduler.py:RoundScheduler._close_and_draw`, + `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` + +### Round bloccato in `closing` + +- **[OPS-14]** + **Sintomi.** `GET /rounds/current` riporta `status: "closing"` a lungo, con + `has_pending_bets` vero; 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`. + **Causa.** Il round non estrae finché ogni scommessa trasmessa non ha una + conferma. Una scommessa `pending` che resta nel mempool non viene mai + abbandonata dal riconciliatore, che abbandona solo ciò che **non compare** + nella cronologia dell'indirizzo: finché il server Electrum la conosce, anche + non confermata, il round aspetta. + **Cosa fare.** + 1. Controllare `attempt_count` e `fee_rate_sat_vb` della riga: il + `RbfBumper` alza la commissione di 1 sat/vB ogni `rbf_timeout_seconds` + (predefinito 900s) dall'ultimo invio. Se sale, la scommessa confermerà da + sola. + 2. Se nel log compare `could not bump pending_transaction N`, l'RBF è + impossibile: vedi [OPS-17]. + 3. Se la riga è `building` da più di 2 minuti, il riconciliatore la risolve + al suo prossimo passaggio (ogni 120s). + 4. Una transazione che il server non conosce più viene abbandonata dopo 6 + ore dal primo invio (`pending_tx_abandoned`); il partecipante viene + rimosso e il round può chiudere. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, + `app/tx/broadcast.py:RbfBumper`, `app/tx/reconcile.py:_is_due` · + *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`, + `tests/unit/test_reconcile.py` + +### Round bloccato in `paying_out` + +- **[OPS-15]** + **Sintomi.** `status: "paying_out"` a lungo e, nel registro di audit, righe + `payout_failed` dello stesso round, al massimo una al minuto. Il campo + `reason` dice il motivo. Lo scheduler riprova da solo ogni 60 secondi: una + volta rimossa la causa, il pagamento parte al tentativo successivo senza + altri interventi. + + | `reason` | Cosa fare | + |---|---| + | `electrum client not connected` | Vedi [OPS-11]. Si risolve da sola al ritorno della connessione | + | `no fee_address configured` | Impostare `fee_address` in `/admin` → Parametri. Succede solo se è stato svuotato a round in corso | + | `winner user not found` | Possibile solo se la riga dell'utente è stata cancellata a mano dal database. Nessuna procedura supportata: ripristinare la riga | + | `insufficient pool UTXOs` | Vedi sotto | + | `too_many_inputs` | Il pagamento richiederebbe più di 500 UTXO del montepremi (resti accumulati in molti round). Serve consolidare gli UTXO del montepremi con la sua chiave: **nessuno strumento lo fa**, vedi [OPS-24] | + | `winner_share_below_network_fee` | Il montepremi è troppo piccolo per la commissione di rete. Abbassare `fee_rate_sat_vb`; se è già 1, nessuna procedura supportata | + | `commission_below_dust_limit` | Il 30% è sotto 294 sat (montepremi sotto circa 1.000 sat, solo con un costo della scommessa minuscolo). Nessuna procedura supportata | + | `payout build failed` | Leggere la traccia nel log (`round N payout build failed`) | + | `broadcast rejected` | Si risolve da sola: la riga `building` viene abbandonata dal riconciliatore entro 2–4 minuti, `payout_txid` si azzera e il pagamento viene ricostruito. Se si ripete, la rete rifiuta la transazione: controllare il messaggio nel log e `fee_rate_sat_vb` | + + **`insufficient pool UTXOs`.** Il pagamento usa solo gli UTXO **confermati** + dell'indirizzo del montepremi, escludendo quelli già riservati da un altro + pagamento `building` o `pending` non risolto. Controllare in "Transazioni + pendenti" se c'è un `payout` di un round precedente ancora aperto: finché + resta tale, i suoi input sono esclusi. L'indirizzo del montepremi non è + mostrato dal pannello; si ottiene con + `docker compose exec app python -c "from app.wallet.hd import derive_pool_address; print(derive_pool_address())"` + e se ne può controllare il saldo con un explorer. + + Un pagamento già in volo (riga `payout` `building` o `pending` del round) + non viene mai duplicato: finché esiste, i tentativi vengono saltati senza + scrivere `payout_failed` e il round aspetta la conferma o l'abbandono. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, + `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`, + `app/rounds/scheduler.py:_reserved_payout_outpoints` · + *Test:* `tests/unit/test_scheduler.py` + +### Transazione abbandonata + +- **[OPS-16]** + **Sintomi.** Riga `pending_tx_abandoned` nel registro di audit; nel log + `abandoned pending_transaction …`; la riga in "Transazioni pendenti" + passa a `failed`. + **Causa.** La transazione non compare più nella cronologia del proprio + indirizzo: mai partita (riga `building` da oltre 120s) o scomparsa dal + mempool (riga `pending` da oltre 6 ore dal primo invio). + **Effetto e cosa fare.** Il sistema ha già annullato tutto: UTXO liberati e + saldo ricalcolato; per una scommessa il partecipante è rimosso (l'utente può + riscommettere se il round è ancora aperto); per un prelievo la richiesta è + `failed` e l'utente, che non vede lo storico, va avvisato di ripeterla; per + un pagamento `payout_txid` è azzerato e il nuovo tentativo parte da solo. Se + gli abbandoni sono frequenti, la commissione di rete configurata è + probabilmente troppo bassa. + *Codice:* `app/tx/reconcile.py:_abandon` · + *Test:* `tests/unit/test_reconcile.py` + +### RBF impossibile + +- **[OPS-17]** + **Sintomi.** Nel log, ogni 30 secondi per la stessa riga, + `could not bump pending_transaction N` con un `RbfError`: "no change output + large enough to absorb a fee bump" oppure "already at the maximum fee rate". + Nessuna riga di audit. `attempt_count` smette di salire. + **Causa.** L'RBF riduce solo il resto che torna al mittente; se il resto non + basta a coprire l'aumento, o la commissione è già al massimo (10.000 sat/vB), + non si può fare. + **Cosa fare.** Nessuna procedura supportata. La transazione resta in attesa + alla commissione che ha: conferma da sola quando il mempool si svuota, + oppure, se sparisce dal mempool, viene abbandonata dopo 6 ore ([OPS-16]). Se + blocca un round in `closing` ([OPS-14]), l'alternativa manuale è spendere il + resto non confermato con una commissione alta (CPFP) usando la chiave + dell'utente ([OPS-23]); il sistema accrediterà l'uscita della nuova + transazione come un normale deposito. + *Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:RbfBumper._tick` · + *Test:* `tests/unit/test_broadcast.py` + +### Deposito non accreditato + +- **[OPS-18]** + **Sintomi.** Un utente segnala un deposito che non vede nel saldo. + **Cause, in ordine di probabilità.** + 1. La transazione non ha ancora 1 conferma: il deposito non viene mostrato + nemmeno come "in attesa" finché non è in un blocco. + 2. La corroborazione fallisce: nel log + `N new UTXO(s) for user_id=… not corroborated by the other servers` e + spesso `could not corroborate … with any of N other configured server(s)`. + Vedi [OPS-12]. + 3. La sottoscrizione dell'indirizzo si è persa (log + `could not subscribe a newly-registered user's address` o + `resubscribing all users failed partway through`). + 4. Electrum irraggiungibile ([OPS-11]). + **Cosa fare.** Verificare con un explorer che la transazione sia confermata + e paghi esattamente l'indirizzo dell'utente (sezione Utenti del pannello). + Poi aspettare: `DepositReconciler` ricontrolla ogni indirizzo ogni 300 + secondi e accredita tutto ciò che il quorum conferma, indipendentemente + dalle notifiche. Un riavvio risottoscrive tutti gli indirizzi, ma non serve + e va fatto con [OPS-10]. Ogni accredito lascia una riga `deposit_credited`. + *Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, + `app/deposits/reconcile.py:DepositReconciler._sweep_once` · + *Test:* `tests/unit/test_electrum_listener.py`, + `tests/unit/test_deposit_reconcile.py` + +### Utente bloccato fuori + +- **[OPS-19]** + **Password dimenticata.** In `/admin` → Utenti → "Reset": viene generata una + nuova password casuale, mostrata una sola volta, da comunicare all'utente per + un canale di cui ci si fida (non esiste un'email). Tutte le sessioni esistenti + dell'utente vengono invalidate. Resta la riga `admin_password_reset`. + Verificare l'identità di chi chiede: chi riceve la password controlla i fondi. + **Troppi tentativi.** Dopo 5 login falliti sullo stesso username (20 dallo + stesso IP) ogni ulteriore errore impone un'attesa che parte da 2 secondi e + raddoppia fino a 300; il contatore si azzera dopo 15 minuti senza errori o al + primo login riuscito. Basta aspettare; un riavvio azzera tutti i contatori + ma non è necessario. + *Codice:* `app/api/routes/admin.py:reset_user_password`, + `app/auth/rate_limit.py:AuthRateLimiters` · + *Test:* `tests/unit/test_admin.py`, `tests/unit/test_rate_limit.py` + +### Segreto compromesso + +- **[OPS-20]** **`ADMIN_TOKEN` trapelato.** Chi lo possiede può esportare la + chiave privata di qualunque utente, cambiare `fee_address`, resettare + password e mettere in pausa la lotteria. + 1. Generare un nuovo token + (`python -c "import secrets; print(secrets.token_urlsafe(32))"`), + scriverlo in `.env` e riavviare ([OPS-10], ma senza aspettare la fine del + round se l'urgenza lo richiede ed evitando solo lo stato `drawing`). Il + vecchio token smette di funzionare subito; gli operatori rientrano nel + pannello con quello nuovo. + 2. Nel registro di audit cercare, dal momento presunto della fuga: + `admin_privkey_accessed` (quali chiavi sono uscite), `config_updated` + (soprattutto `fee_address`), `admin_password_reset` (account presi), e per + quegli utenti `withdrawal_sent`. + 3. **La chiave di un utente esportata resta compromessa per sempre**: + l'indirizzo di un utente è permanente e il codice non ha un modo per + assegnargliene un altro. L'unica difesa è spostare subito i fondi di + quell'indirizzo ([OPS-23]) e avvisare l'utente di non depositarvi più. + *Codice:* `app/api/routes/admin.py:require_admin` + +- **[OPS-21]** **`JWT_SECRET` trapelato.** Chi lo possiede può fabbricare una + sessione valida per qualunque utente e quindi scommettere o prelevarne i + fondi. Generare un nuovo segreto di almeno 32 caratteri, scriverlo in `.env` e + riavviare: **tutte** le sessioni esistenti diventano invalide e ogni utente + deve rifare il login. Poi controllare `withdrawal_sent` nel registro di audit + per prelievi inattesi. Per invalidare le sessioni di un solo utente basta + invece un reset della sua password ([OPS-19]). + *Codice:* `app/auth/security.py:decode_access_token`, + `app/config.py:validate_runtime_secrets` + +- **[OPS-22]** **Rotazione di `XPRV_ENCRYPTION_KEY`** (la master key non + cambia, cambia solo la sua cifratura): + 1. `PYTHONPATH=. python scripts/decrypt_master_key.py` con la chiave vecchia + in `.env`, e conservare la xprv stampata solo per il tempo necessario. + 2. Generare la chiave nuova + (`python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`) + e scriverla in `.env`. + 3. `PYTHONPATH=. python scripts/encrypt_master_key.py --overwrite` e + incollare la xprv. + 4. Riavviare ([OPS-10]) e aggiornare i backup ([OPS-05]). + **Se è trapelata la master key stessa** (file cifrato *e* chiave, oppure la + xprv in chiaro), sono a rischio tutti i fondi degli utenti e del montepremi. + Non esiste una procedura supportata per passare a una nuova master key: ogni + indirizzo, compreso quello del montepremi, deriva da essa, e cambiarla + cambierebbe tutti gli indirizzi senza spostare i fondi né aggiornare il + database. + *Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/hd.py:_load_account_key` + +### Intervento manuale sui fondi + +- **[OPS-23]** **Spostare i fondi di un utente con la sua chiave privata.** + Serve per restituire fondi bloccati, per un CPFP ([OPS-17]) o dopo una fuga + della chiave ([OPS-20]). + 1. In "Transazioni pendenti" verificare che l'utente non abbia scommesse o + prelievi `building`/`pending`: i loro input sono già impegnati e + spenderli altrove crea un conflitto che fa fallire una delle due + transazioni. + 2. Per evitare che l'utente agisca nel frattempo, si può resettarne la + password ([OPS-19]), che chiude le sue sessioni. + 3. `/admin` → Utenti → "Mostra" (riga `admin_privkey_accessed`). La chiave + WIF va importata in un portafoglio PLM (per esempio PalladiumWallet), mai + salvata altrove. + 4. Inviare i fondi dove serve. + 5. Quando la spesa è confermata, il listener vede gli UTXO scomparire, chiede + conferma agli altri server e li segna come spesi all'esterno + (`utxo_spent_externally`): il saldo nel database scende da solo. Nulla va + modificato a mano. + + **Restituire fondi a un utente** non richiede chiavi: si inviano al suo + indirizzo e vengono accreditati come un deposito (`deposit_credited`). + *Codice:* `app/api/routes/admin.py:user_privkey`, + `app/deposits/service.py:mark_utxos_spent_externally` + +- **[OPS-24]** **Fondi del montepremi.** La chiave del montepremi + (`m/84'/746'/0'/1/0` della master key) non è esportabile dal pannello né da + alcuno script: l'unico modo è derivarla a mano dalla xprv ottenuta con + `scripts/decrypt_master_key.py`. Non c'è quindi una procedura supportata per + consolidare gli UTXO del montepremi (`too_many_inputs`) o per recuperare + resti accumulati. Spendere UTXO del montepremi fuori dal sistema non è + rilevato come spesa esterna (l'indirizzo non è in `utxo_events`) e può far + fallire il pagamento di un round in corso (`insufficient pool UTXOs` o una + trasmissione rifiutata): farlo solo senza round in `paying_out`. + *Codice:* `app/wallet/hd.py:derive_pool_key`, + `app/rounds/scheduler.py:RoundScheduler._trigger_payout` + +## Segnali da tenere d'occhio + +- **[OPS-25]** Non esiste un sistema di allarmi: i segnali vanno controllati + a mano o con uno strumento esterno che interroghi l'API e legga il log. + + | Segnale | Dove | Allarme se | + |---|---|---| + | Altezza della catena | `chain_tip_height` in `GET /rounds/current` | Non cresce per più di ~10 minuti (blocchi ogni 120s): Electrum giù ([OPS-11]) | + | Stato e durata del round | `status`, `closes_at`, `draw_waiting_since` in `GET /rounds/current` | `closing` o `paying_out` per più di qualche blocco; `drawing` oltre 6 minuti ([OPS-13]–[OPS-15]) | + | Istanza configurata | `lottery_configured` in `GET /rounds/current` | `false`: manca `fee_address`, nessun round si apre | + | Pausa dimenticata | `lottery_paused` in `GET /rounds/current` | `true` fuori da una manutenzione | + | Pagamenti ed estrazioni | Audit: `payout_failed`, `draw_stalled`, `draw_header_corroboration_failed` | Qualunque occorrenza | + | Transazioni perse | Audit: `pending_tx_abandoned` | Frequenti: commissione troppo bassa | + | Spese esterne | Audit: `utxo_spent_externally` | Non dovute a un intervento dell'operatore: chiave dell'utente compromessa o server che mente | + | Uso del pannello | Audit: `admin_privkey_accessed`, `config_updated`, `admin_password_reset` | Non attribuibili a un operatore: `ADMIN_TOKEN` compromesso ([OPS-20]) | + | Transazioni lente | `/admin` → Transazioni pendenti | `attempt_count` alto o `broadcast_at` vecchio di ore | + | Log | `data/logs/app.log` | `all N Electrum server(s) unreachable`, `round scheduler tick failed`, `could not bump`, `invariant violated` | + | Disco | `./data/db`, `./data/logs` | Il database cresce senza limite (registro di audit incluso); i log ruotano a 6 × 10 MB | + + *Codice:* `app/api/routes/rounds.py`, `app/api/routes/admin.py`, + `app/logging_config.py:setup_logging` + +## Script manuali + +Si lanciano dal venv sull'host (`source .venv/bin/activate`, con +`PYTHONPATH=.`) oppure nel container con `docker compose run --rm app python +scripts/.py`. Nel container `MASTER_KEY_PATH` punta a +`/app/key_data/master.xprv.enc`, cioè allo stesso file `./data/keys` dell'host; +dal venv conta il `MASTER_KEY_PATH` di `.env`. + +- **[OPS-26]** Gli script disponibili: + + | Script | Cosa fa | Quando | + |---|---|---| + | `scripts/generate_master_key.py` | Crea una master key casuale, la cifra con `XPRV_ENCRYPTION_KEY` e la scrive in `MASTER_KEY_PATH`. Si rifiuta se il file esiste già | Una sola volta, alla prima installazione ([setup.md](setup.md)) | + | `scripts/encrypt_master_key.py` | Cifra una xprv incollata (input nascosto) e la scrive in `MASTER_KEY_PATH`, con ripiego su `./data/keys/` se la cartella di `MASTER_KEY_PATH` non esiste. Rifiuta una xpub e un file già presente senza `--overwrite` | Installazione con una xprv generata altrove; ripristino da xprv in chiaro ([OPS-07]); rotazione della cifratura ([OPS-22]) | + | `scripts/decrypt_master_key.py` | Decifra e stampa la xprv, dopo aver chiesto di scrivere `yes`. Cerca il file in `MASTER_KEY_PATH`, poi in `./data/keys/master.xprv.enc` | Backup offline ([OPS-05]), rotazione ([OPS-22]), recupero dei fondi del montepremi ([OPS-24]) | + | `scripts/electrum_smoke_test.py` | Si collega **solo** al server primario (`ELECTRUM_HOST`/`PORT`/`USE_SSL`), fa l'handshake, si iscrive agli header e stampa l'altezza | Diagnosi di [OPS-11]. Per provare un server di riserva, lanciarlo con `ELECTRUM_HOST`/`ELECTRUM_PORT` impostati su quel server | + + `generate_master_key.py` non ha il ripiego su `./data/keys/` degli altri + due: dal venv scrive esattamente dove dice `MASTER_KEY_PATH` di `.env`. + *Codice:* `scripts/generate_master_key.py`, `app/wallet/hd.py:generate_master_key`, + `scripts/encrypt_master_key.py`, `scripts/decrypt_master_key.py`, + `scripts/electrum_smoke_test.py` diff --git a/docs/processo-di-modifica.md b/docs/processo-di-modifica.md new file mode 100644 index 0000000..a68b5d7 --- /dev/null +++ b/docs/processo-di-modifica.md @@ -0,0 +1,84 @@ +# Processo di modifica: dalla documentazione al codice + +La cartella `docs/` è la **specifica** di PLM Lottery. Descrive cosa il +software fa e perché; il codice è l'implementazione di questa specifica. +Ogni modifica di comportamento parte da qui. + +## Convenzione dei requisiti + +Ogni regola verificabile ha un **identificativo stabile**, nella forma +`PREFISSO-NN`, scritto in grassetto all'inizio del punto che la descrive, +seguito dai riferimenti all'implementazione e, dove esistono, ai test: + +```markdown +- **[RND-11]** Alla scadenza il round passa subito a `closing`: da quel momento + nessuna scommessa è più accettata. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · + *Test:* `tests/unit/test_scheduler.py` +``` + +Regole della convenzione: + +- **Un identificativo non si riusa mai.** Un requisito eliminato resta nel + documento come `~~[RND-11]~~ Rimosso: `, così i riferimenti già + scritti (commenti, commit, test) non puntano a una regola diversa. +- **I numeri non si rinumerano.** Un requisito nuovo prende il numero + successivo al più alto del suo prefisso, anche se finisce in mezzo al testo. +- **I riferimenti al codice** sono `percorso:funzione` o `percorso:Classe.metodo`, + mai numeri di riga (cambiano a ogni modifica). +- Il testo descrittivo che non contiene regole (introduzioni, spiegazioni, + esempi) non ha identificativo. + +### Prefissi + +| Prefisso | Documento | Ambito | +|---|---|---| +| `ARC` | [architettura.md](architettura.md) | Struttura del codice, processi, frontend, test | +| `DAT` | [modello-dati.md](modello-dati.md) | Tabelle, campi, stati, vincoli | +| `API` | [api.md](api.md) | Endpoint, formato delle risposte, codici di errore | +| `SEC` | [sicurezza.md](sicurezza.md) | Chiavi, autenticazione, limiti, rischi accettati | +| `REG` | [registrazione-e-depositi.md](registrazione-e-depositi.md) | Registrazione e account | +| `DEP` | [registrazione-e-depositi.md](registrazione-e-depositi.md) | Depositi e saldo | +| `BET` | [scommesse-e-prelievi.md](scommesse-e-prelievi.md) | Scommesse | +| `WDR` | [scommesse-e-prelievi.md](scommesse-e-prelievi.md) | Prelievi | +| `RND` | [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) | Round, estrazione, pagamento | +| `TX` | [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) | Ciclo di vita delle transazioni, RBF, riconciliazione | +| `ELC` | [connessione-electrum.md](connessione-electrum.md) | Connessione alla rete | +| `AUD` | [audit-log.md](audit-log.md) | Eventi del registro di audit | +| `OPS` | [operativita.md](operativita.md) | Procedure operative | +| `DEC` | [decisioni-e-limiti.md](decisioni-e-limiti.md) | Decisioni di progetto | +| `LIM` | [decisioni-e-limiti.md](decisioni-e-limiti.md) | Limiti noti accettati | + +## Il ciclo di una modifica + +1. **Modificare la specifica.** Si cambia il testo del requisito in `docs/` + (o se ne aggiunge uno nuovo con il prossimo numero libero). Se la modifica + tocca una decisione (`DEC-*`) o un limite (`LIM-*`), si aggiorna anche + quello. +2. **Individuare l'impatto.** I riferimenti *Codice* e *Test* del requisito + dicono dove intervenire. Si cercano anche le altre occorrenze + dell'identificativo nel repository (`git grep RND-11`) e i requisiti di + altri documenti che dipendono dallo stesso codice. +3. **Implementare e testare.** Si modifica il codice e si aggiunge o aggiorna + un test che verifica il nuovo comportamento. Il test cita l'identificativo + nella sua docstring. +4. **Allineare i riferimenti.** Se il codice si è spostato (funzione + rinominata, file diviso), si aggiornano i riferimenti *Codice* del + requisito. +5. **Commit unico.** Specifica, codice e test entrano nello stesso commit, + con l'identificativo nel messaggio (ad esempio + `Close the round on its own deadline (RND-11)`). + +Una correzione di bug che non cambia la specifica (il codice non faceva ciò +che il documento già diceva) segue la convenzione `B-nn` descritta in +[storico-bug.md](storico-bug.md): numero successivo, commit dedicato, test di +regressione. Se invece il bug rivela che la specifica era sbagliata o +incompleta, si corregge prima la specifica, come per qualunque altra modifica. + +## Documenti della specifica + +Il punto d'ingresso è [come-funziona.md](come-funziona.md). Le guide +operative ([setup.md](setup.md), [running-the-server.md](running-the-server.md), +[guida-utente.md](guida-utente.md), [guida-admin.md](guida-admin.md)) non +contengono requisiti: descrivono come usare il software, e si aggiornano +quando la specifica cambia qualcosa di visibile. diff --git a/docs/registrazione-e-depositi.md b/docs/registrazione-e-depositi.md new file mode 100644 index 0000000..c4edadf --- /dev/null +++ b/docs/registrazione-e-depositi.md @@ -0,0 +1,287 @@ +# Registrazione, depositi e saldo + +Fasi 1 e 2 del [funzionamento generale](come-funziona.md): come nasce un +account e come i fondi entrano nella piattaforma. + +I requisiti usano i prefissi `REG` (registrazione e account) e `DEP` (depositi +e saldo), secondo la convenzione di +[processo-di-modifica.md](processo-di-modifica.md). Gli endpoint citati sono +descritti in dettaglio in [api.md](api.md); hashing, token e limiti di +frequenza in [sicurezza.md](sicurezza.md); le colonne delle tabelle in +[modello-dati.md](modello-dati.md); gli eventi di audit in +[audit-log.md](audit-log.md). + +## Registrazione + +L'utente sceglie username e password (`POST /auth/register`). Il server: + +1. salva la password come hash **Argon2** (mai in chiaro); +2. assegna all'utente il prossimo indice libero e deriva da questo il suo + indirizzo, secondo lo schema BIP84 `m/84'/746'/0'/0/` a partire + dalla master xprv cifrata. Ne risulta un indirizzo P2WPKH (`plm1…`); +3. collega l'indirizzo al profilo, in modo **permanente**; +4. si iscrive, via Electrum, alle notifiche di quell'indirizzo. Questo passo + è "al meglio": se fallisce, l'indirizzo viene comunque ripreso alla + riconnessione successiva o al giro periodico di `DepositReconciler` + (ogni 5 minuti). + +- **[REG-01]** Lo username è lungo da 3 a 32 caratteri, composto solo da + lettere ASCII, cifre, `_`, `.` e `-`; la password è lunga da 8 + (`MIN_PASSWORD_LENGTH`) a 256 caratteri. Una richiesta che non rispetta + questi vincoli è rifiutata con HTTP 422 prima di qualunque altra verifica. + *Codice:* `app/auth/routes.py:RegisterRequest`, `app/auth/security.py:MIN_PASSWORD_LENGTH` · + *Test:* `tests/unit/test_users.py` +- **[REG-02]** Gli username **non distinguono maiuscole e minuscole**: + `Mario` e `mario` sono lo stesso account. Uno username già usato, in + qualunque combinazione di maiuscole, è rifiutato con `username_taken` + (HTTP 409), sia dal controllo iniziale sia se la collisione emerge solo al + salvataggio (registrazione concorrente). L'unicità è garantita dal database + con un indice univoco su `lower(username)`. Il nome resta però salvato come + è stato digitato. + *Codice:* `app/auth/routes.py:register`, `app/db/models.py:User` · + *Test:* `tests/unit/test_auth.py` +- **[REG-03]** La password è salvata solo come hash Argon2, calcolato fuori + dal ciclo di eventi (in un thread separato) per non bloccare le attività in + background. + *Codice:* `app/auth/security.py:hash_password_async` · + *Test:* `tests/unit/test_security.py` +- **[REG-04]** L'indice di derivazione del nuovo utente è il massimo indice + esistente più uno (0 per il primo utente); l'indirizzo è il P2WPKH di + `m/84'/746'/0'/0/`, con prefisso bech32 `plm`. Se il salvataggio + fallisce per una collisione sull'indice (due registrazioni concorrenti), si + riprova con un indice ricalcolato fino a 5 volte (`_MAX_REGISTER_RETRIES`), + poi la richiesta fallisce con `derivation_index_conflict` (HTTP 409). + *Codice:* `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` · + *Test:* `tests/unit/test_hd.py` +- **[REG-05]** L'indirizzo è **permanente** e unico per utente, ed è allo + stesso tempo indirizzo di deposito, destinazione delle vincite e + destinazione del resto di ogni scommessa e prelievo: non esiste un + "indirizzo vincite" separato. + *Codice:* `app/db/models.py:User`, `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · + *Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py` +- **[REG-06]** Una registrazione riuscita risponde HTTP 201 con un token di + sessione (`access_token`) e l'indirizzo: l'utente è già autenticato, senza + un login separato. + *Codice:* `app/auth/routes.py:register` · + *Test:* `tests/unit/test_users.py` +- **[REG-07]** Subito dopo il salvataggio, l'indirizzo viene aggiunto agli + indirizzi osservati e, se la connessione Electrum è attiva, gli ci si iscrive + in un'attività separata. Se non c'è connessione, o l'iscrizione fallisce, + l'errore viene solo registrato nei log: la registrazione riesce comunque, e + l'indirizzo viene ripreso dalla reiscrizione completa alla prossima + riconnessione ([ELC-18](connessione-electrum.md#riconnessione)) o dal giro + periodico di [DEP-10](#dep-10). + *Codice:* `app/electrum/listener.py:ElectrumListener.address_for_new_user` · + *Test:* `tests/unit/test_electrum_listener.py` +- **[REG-08]** La registrazione è limitata a **5 account all'ora per + indirizzo IP**. Il limite è verificato prima di calcolare l'hash, e conta + solo le registrazioni riuscite; oltre il limite la risposta è + `rate_limited` (HTTP 429) con `retry_after_seconds`. I dettagli sono in + [sicurezza.md](sicurezza.md). + *Codice:* `app/auth/routes.py:register`, `app/auth/rate_limit.py:AuthRateLimiters` · + *Test:* `tests/unit/test_auth.py` + +### Sessione e password + +Il login (`POST /auth/login`) restituisce un token JWT valido 24 ore. Cambiare +la password (dal Profilo) o farsela reimpostare dall'operatore invalida +**tutte** le sessioni aperte in precedenza. + +- **[REG-09]** Il login confronta lo username senza distinguere maiuscole e + minuscole. Username inesistente e password sbagliata producono la stessa + risposta, `invalid_credentials` (HTTP 401), così che non si possa scoprire + quali account esistono. Una risposta riuscita contiene `access_token` e + l'indirizzo dell'utente. + *Codice:* `app/auth/routes.py:login` · + *Test:* `tests/unit/test_auth.py` +- **[REG-10]** I tentativi di login falliti sono frenati sia per username sia + per IP; durante il blocco la risposta è `rate_limited` (HTTP 429) con + `retry_after_seconds`. Un login riuscito azzera il contatore dello username, + non quello dell'IP. Soglie e tempi sono in [sicurezza.md](sicurezza.md). + *Codice:* `app/auth/routes.py:login`, `app/auth/rate_limit.py:RateLimiter` · + *Test:* `tests/unit/test_auth.py` +- **[REG-11]** Il token di sessione scade dopo 24 ore (`jwt_expire_minutes`) + e porta con sé la versione del token dell'utente (`token_version`). Un token + la cui versione non coincide più con quella dell'utente, oppure scaduto, + malformato o di un utente inesistente, è rifiutato con `session_expired` + (HTTP 401) dagli endpoint autenticati, e trattato come "non autenticato" + dagli endpoint accessibili anche senza login. Un token emesso prima che la + versione esistesse vale come versione 0. + *Codice:* `app/auth/security.py:create_access_token`, `app/auth/security.py:decode_access_token`, `app/auth/dependencies.py:get_current_user`, `app/auth/dependencies.py:get_optional_user` · + *Test:* `tests/unit/test_security.py`, `tests/unit/test_users.py` +- **[REG-12]** Il cambio password (`POST /users/me/change-password`, dal + Profilo) richiede la password attuale — altrimenti `current_password_incorrect` + (HTTP 401) — e una nuova password di almeno 8 caratteri — altrimenti + `password_too_short` (HTTP 400). Se riesce, incrementa `token_version`, + invalidando **tutte** le sessioni aperte, compresa quella della richiesta, e + restituisce un token nuovo perché la sessione corrente continui senza nuovo + login. + *Codice:* `app/api/routes/users.py:change_password` · + *Test:* `tests/unit/test_users.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 + nuova, la restituisce **una sola volta** perché venga comunicata + all'utente, incrementa `token_version` (invalidando tutte le sessioni) e + scrive l'evento `admin_password_reset`. Un utente inesistente dà HTTP 404. + Procedura in [guida-admin.md](guida-admin.md). + *Codice:* `app/api/routes/admin.py:reset_user_password` · + *Test:* `tests/unit/test_admin.py` + +### Il conto del montepremi + +Oltre agli indirizzi utente, dalla stessa master key si deriva un indirizzo +riservato al **montepremi**. Tutte le scommesse vengono inviate lì, e i +pagamenti ai vincitori sono firmati con la sua chiave. + +- **[REG-14]** L'indirizzo del montepremi è il P2WPKH del ramo `1/0` + dell'account (`m/84'/746'/0'/1/0`), mentre gli utenti stanno sul ramo `0`. + Le sue monete non sono registrate tra le monete degli utenti: il pagamento + le legge direttamente dalla rete (vedi + [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento)). + *Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address` · + *Test:* `tests/unit/test_pending_address.py` + +## Deposito + +L'utente invia PLM al proprio indirizzo da qualunque wallet esterno. Non c'è +un importo minimo. + +```mermaid +flowchart TB + A["Arriva una notifica Electrum
per l'indirizzo, oppure
il giro periodico di controllo"] --> B["Si legge l'elenco delle
monete (UTXO) dell'indirizzo
dal server attivo"] + B --> C{"Moneta nuova
con almeno 1 conferma?"} + C -- "No" --> Z["Nulla da fare"] + C -- "Sì" --> D{"La maggioranza degli
altri server conferma stessa
moneta, stesso importo?"} + D -- "No / non rispondono" --> R["Accredito rinviato
al prossimo controllo"] + D -- "Sì" --> E["Moneta registrata,
saldo ricalcolato
(deposit_credited)"] + B --> F{"Una moneta nota
non spesa è sparita?"} + F -- "Sì, e la maggioranza degli
altri server la conferma spesa" --> G["Segnata come spesa
fuori dalla piattaforma
(utxo_spent_externally)"] + B --> H{"Una moneta data per spesa
fuori è ricomparsa?"} + H -- "Sì" --> I["Reintegrata
(utxo_external_spend_reinstated)"] +``` + +Le vincite arrivano allo stesso modo: il pagamento del round è una normale +transazione verso l'indirizzo del vincitore, e viene accreditato come un +deposito. Lo stesso vale per il resto di scommesse e prelievi, che torna +all'indirizzo dell'utente. + +- **[DEP-01]** Non c'è un importo minimo di deposito. + *Codice:* `app/deposits/service.py:credit_confirmed_utxos` +- **[DEP-02]** Un aggiornamento dell'indirizzo di un utente legge l'elenco + delle monete non spese (`listunspent`) dal server Electrum attivo ed esegue, + in quest'ordine: individuazione degli accrediti nuovi, reintegro delle + monete ricomparse, individuazione delle monete sparite; poi corrobora + accrediti e sparizioni presso gli altri server e solo dopo scrive il + risultato. Nessuna sessione del database resta aperta durante le chiamate + di rete. + *Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` · + *Test:* `tests/unit/test_electrum_listener.py` +- **[DEP-03]** **Una conferma basta.** Una moneta è candidata all'accredito + solo se è confermata (altezza maggiore di 0: le monete ancora in mempool + sono ignorate) e non è già registrata. Il rischio di una riorganizzazione + della catena a 1 conferma è accettato consapevolmente; non c'è logica di + annullamento (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)). + *Codice:* `app/deposits/service.py:find_new_credit_candidates` · + *Test:* `tests/unit/test_deposits.py` +- **[DEP-04]** **Ogni accredito è corroborato.** Prima di aumentare un saldo, + il server chiede agli *altri* server Electrum configurati se quella moneta + esiste davvero, con quell'importo, confermata + ([ELC-17](connessione-electrum.md#5-il-quorum)). Un server malevolo non può + quindi inventarsi un deposito. Se la verifica non riesce l'accredito non è + perso, solo rimandato: la moneta viene riproposta all'aggiornamento + successivo (notifica o giro periodico), e nei log resta un avviso. + *Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` · + *Test:* `tests/unit/test_electrum_listener.py` +- **[DEP-05]** L'accredito registra la moneta (txid, posizione, importo, + altezza) in modo idempotente — una moneta già registrata non viene mai + accreditata due volte, e il database impone l'unicità della coppia + txid/posizione —, scrive per ciascuna un evento `deposit_credited`, + ricalcola il saldo e invia una notifica SSE. + *Codice:* `app/deposits/service.py:credit_confirmed_utxos` · + *Test:* `tests/unit/test_deposits.py` +- **[DEP-06]** **Monete spese altrove.** Una moneta registrata come non spesa + che non compare più nell'elenco è *candidata* alla spesa esterna (ad esempio + perché qualcuno ha usato la chiave privata esportata dall'admin). Le monete + spese dalla piattaforma stessa non sono mai candidate, perché vengono + segnate come spese già al momento della costruzione della transazione. Se + l'elenco è **completamente vuoto** mentre il database conosce monete non + spese, non si produce alcun candidato: una risposta vuota è trattata come + incompleta, non come una spesa di tutto. + *Codice:* `app/deposits/service.py:find_utxos_missing_from` · + *Test:* `tests/unit/test_deposits.py` +- **[DEP-07]** Un candidato viene tolto dal saldo solo dopo che la + maggioranza degli altri server conferma che la moneta non è più non spesa + ([ELC-16](connessione-electrum.md#5-il-quorum)). Al salvataggio si + ricontrolla che la moneta sia ancora non spesa (nel frattempo potrebbe + averla spesa la piattaforma); la si segna con il marcatore di spesa esterna, + si scrive `utxo_spent_externally`, si ricalcola il saldo e si invia una + notifica SSE. + *Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/deposits/service.py:mark_utxos_spent_externally` · + *Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py` +- **[DEP-08]** **Reintegro.** Una moneta segnata come spesa fuori dalla + piattaforma che ricompare nell'elenco viene reintegrata: il marcatore è + rimosso, si scrive `utxo_external_spend_reinstated`, si ricalcola il saldo e + si invia una notifica SSE. Il reintegro si basa sulla sola risposta del + server attivo, senza corroborazione. + *Codice:* `app/deposits/service.py:reinstate_reappeared_utxos` · + *Test:* `tests/unit/test_deposits.py` +- **[DEP-09]** **Due vie, un solo codice.** Un aggiornamento parte quando il + server notifica un cambiamento su un indirizzo osservato, alla + (re)iscrizione di un indirizzo, e al giro periodico: tutte e tre usano la + stessa funzione di aggiornamento, così i due percorsi non possono + comportarsi diversamente. + *Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/electrum/listener.py:ElectrumListener._subscribe_and_refresh`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` · + *Test:* `tests/unit/test_deposit_reconcile.py` +- **[DEP-10]** Il giro periodico (`DepositReconciler`) + attende 300 secondi, poi riaggiorna **tutti** gli utenti, non solo quelli + che risultano non iscritti: serve a recuperare un'iscrizione persa + silenziosamente. Il giro salta se non c'è connessione, si interrompe se la + connessione cade a metà (la reiscrizione alla riconnessione copre tutti), e + l'errore su un utente viene registrato senza fermare gli altri. + *Codice:* `app/deposits/reconcile.py:DepositReconciler.run`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` · + *Test:* `tests/unit/test_deposit_reconcile.py` + +## Il saldo mostrato + +Scommesse e prelievi spendono monete **intere**: per pagare 10 PLM con una +moneta da 50, la moneta da 50 viene spesa tutta e 40 tornano all'utente come +resto (nella scommessa la commissione è dedotta dai 10 PLM, vedi +[scommesse-e-prelievi.md](scommesse-e-prelievi.md#cosa-succede)), in una nuova +moneta che però è confermata solo al blocco successivo. + +Per questo esistono due saldi, entrambi restituiti da `GET /users/me`: + +| Campo | Cosa conta | A cosa serve | +|---|---|---| +| `balance_sats` | Solo monete confermate e non spese | È ciò che si può spendere: prelievo massimo, controlli sulla scommessa | +| `pending_balance_sats` | Il precedente **più** il resto delle proprie transazioni ancora in corso | È ciò che l'interfaccia mostra | + +Senza il secondo, subito dopo una scommessa l'utente vedrebbe sparire l'intero +resto (spesso molto più dei 10 PLM giocati) fino alla conferma. L'interfaccia +mostra il saldo in **verde** quando tutto è confermato e in **ambra** finché +c'è qualcosa in corso (`has_pending`). + +- **[DEP-11]** Il saldo confermato (`balance_sats`, colonna + `cached_balance_sats`) è la somma delle monete registrate dell'utente non + segnate come spese. Viene ricalcolato nella stessa transazione del database + di ogni operazione che registra, spende, libera o reintegra monete. + *Codice:* `app/wallet/balance.py:recompute_balance` · + *Test:* `tests/unit/test_deposits.py` +- **[DEP-12]** Il saldo mostrato (`pending_balance_sats`) è il saldo + confermato più le uscite verso l'indirizzo dell'utente di ogni sua + scommessa o prelievo in stato `building` o `pending`, lette dalla + transazione corrente (aggiornata a ogni RBF). Un'uscita già registrata come + moneta (perché il resto è stato accreditato prima che la transazione + risultasse confermata) non viene contata due volte. `has_pending` è vero + finché esiste almeno una di queste transazioni. + *Codice:* `app/wallet/balance.py:compute_pending_balance`, `app/api/routes/users.py:me` · + *Test:* `tests/unit/test_balance.py` +- **[DEP-13]** Ogni spesa (scommessa, prelievo) usa solo monete confermate e + non spese: il saldo mostrato è informativo, non spendibile. Se un utente + chiede un prelievo coperto dal saldo mostrato ma non ancora da quello + confermato, riceve l'errore `balance_pending_confirmation` ("attendi la + conferma") invece di un generico "saldo insufficiente" che contraddirebbe + ciò che vede ([WDR-05](scommesse-e-prelievi.md#prelievo)). + *Codice:* `app/withdrawals/service.py:request_withdrawal` · + *Test:* `tests/unit/test_withdrawals.py` diff --git a/docs/running-the-server.md b/docs/running-the-server.md index ffd8b37..02d689e 100644 --- a/docs/running-the-server.md +++ b/docs/running-the-server.md @@ -64,8 +64,10 @@ docker compose down # ferma e rimuove i container (i dati in ./data/ ### ⚠️ Attenzione: riavvii automatici a metà round `docker-compose.yml` imposta `restart: unless-stopped` sul container dell'app: -se crasha, riparte da solo. Questo però non è ancora sicuro in ogni caso — se -il crash avviene mentre un round è in stato `closing`/`drawing`/`paying_out`, -lo scheduler non lo riprende al riavvio e il round resta bloccato (gap noto, -vedi "Known gaps" in `CLAUDE.md`). Non trattare questo setup come -"non supervisionato" finché quel gap non è risolto. +se crasha, riparte da solo. Questo però non è ancora sicuro in ogni caso: i +round in `closing` e `paying_out` riprendono da soli al riavvio, ma se il +crash avviene mentre un round è in `drawing`, lo scheduler non lo riprende e +il round resta bloccato (limite noto, vedi +[decisioni-e-limiti.md](decisioni-e-limiti.md); procedura in +[operativita.md](operativita.md)). Non trattare questo setup come +"non supervisionato" finché quel limite non è risolto. diff --git a/docs/scommesse-e-prelievi.md b/docs/scommesse-e-prelievi.md new file mode 100644 index 0000000..1b1bf84 --- /dev/null +++ b/docs/scommesse-e-prelievi.md @@ -0,0 +1,257 @@ +# Scommesse e prelievi + +Fasi 3 e 5 del [funzionamento generale](come-funziona.md): le due operazioni +con cui l'utente spende il proprio saldo. Hanno la stessa forma — una +transazione dal suo indirizzo verso una destinazione, con il resto che torna a +lui — e condividono le stesse regole di sicurezza. + +I requisiti usano i prefissi `BET` (scommesse) e `WDR` (prelievi), secondo la +convenzione di [processo-di-modifica.md](processo-di-modifica.md). Il formato +delle risposte e il catalogo completo dei codici di errore sono in +[api.md](api.md); cosa succede a una transazione dopo l'invio in +[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md). + +Costanti citate in questo documento (tutte in `app/wallet/psbt_builder.py`): + +| Costante | Valore | Significato | +|---|---|---| +| `DUST_LIMIT_SATS` | 294 sat | Resto minimo di ogni transazione utente | +| `MAX_TX_INPUTS` | 50 | Monete massime in ingresso per scommessa o prelievo | +| `MAX_PARTICIPANTS_PER_ROUND` | 400 | Partecipanti massimi per round | +| `MAX_PAYOUT_TX_INPUTS` | 500 | Monete massime in ingresso per il pagamento del round | + +## Scommessa + +### Quando è accettata + +Prima dei controlli valgono due condizioni di contorno: + +- **[BET-01]** Se il server non è connesso alla rete, la scommessa è + rifiutata con `network_unavailable` (HTTP 503) prima di qualunque altro + controllo. + *Codice:* `app/api/routes/bets.py:create_bet` +- **[BET-02]** Tutta l'elaborazione di una scommessa, dai controlli all'invio, + avviene tenendo il **blocco per-utente**, lo stesso usato dal prelievo + ([WDR-01](#prelievo)). + *Codice:* `app/api/routes/bets.py:create_bet`, `app/tx/locks.py:UserLocks.acquire` + +Una scommessa (`POST /bets`, senza corpo) passa solo se **tutte** queste +condizioni sono vere, verificate nell'ordine della tabella; altrimenti viene +rifiutata con il codice indicato (HTTP 400), prima che si muova qualunque +cifra: + +| ID | Condizione | Codice di errore | +|---|---|---| +| **[BET-03]** | C'è un round attivo. Se non ce n'è nessuno e le condizioni di apertura sono soddisfatte ([RND-03](ciclo-di-vita-del-round.md#0--apertura)), è la scommessa stessa ad aprirlo | `no_round_open` | +| **[BET-04]** | Il round è in stato `open` e la sua scadenza non è ancora passata | `round_closing` | +| **[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-07]** | La somma delle monete confermate e non spese copre il costo della scommessa (`bet_amount_sats`) | `insufficient_balance` (parametro `required_sats`) | +| **[BET-08]** | Il costo si copre con al massimo 50 monete (`MAX_TX_INPUTS`), scelte dalla più grande | `too_many_inputs` (parametro `max_inputs`) | +| **[BET-09]** | Dopo la scommessa resta un resto di almeno 294 sat (`DUST_LIMIT_SATS`) | `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` | + +*Codice:* `app/bets/service.py:place_bet`, `app/rounds/service.py:round_accepts_bets`, +`app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction` · +*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py` + +La **scadenza** è quella del round stesso (apertura + durata fissata +all'apertura), non il suo stato nel database: lo scheduler controlla ogni 5 +secondi, quindi per qualche istante il round potrebbe risultare ancora +"aperto" oltre la scadenza. + +- **[BET-11]** La scadenza viene verificata due volte: all'arrivo della + richiesta ([BET-04]) e di nuovo dopo aver costruito e firmato la + transazione. L'iscrizione al round viene poi salvata nella stessa + transazione del database di un *compare-and-set* sulla riga del round + (`UPDATE rounds … WHERE status = 'open'`): se il round non è più aperto in + quell'istante esatto, o il database segnala un conflitto di scrittura, tutto + viene annullato prima dell'invio e la scommessa è rifiutata con + `round_closing`. Così nessuna scommessa può finire nel montepremi senza + essere inclusa nell'estrazione. + *Codice:* `app/bets/service.py:place_bet` · + *Test:* `tests/unit/test_bets.py` + +Il limite di **400 partecipanti** esiste perché il pagamento deve spendere una +moneta del montepremi per ogni scommessa, e una singola transazione di +pagamento può contenerne al massimo 500 (`MAX_PAYOUT_TX_INPUTS`); la +differenza lascia spazio ai resti accumulati nel montepremi dai round +precedenti. Bloccare il 401° giocatore all'ingresso garantisce che ogni round +si possa sempre pagare. + +### Cosa succede + +```mermaid +flowchart TB + A["Richiesta di scommessa"] --> N{"Connessi
alla rete?"} + N -- "No" --> N1["network_unavailable (503)"] + N -- "Sì" --> B["Blocco per-utente acquisito"] + B --> C["Controlli (tabella sopra)"] + C --> D["Scelta delle monete,
dalla più grande"] + D --> E["Transazione firmata:
costo meno commissione → montepremi
resto → indirizzo utente"] + E --> CAS{"Scadenza non passata e
round ancora 'open'
(compare-and-set)?"} + CAS -- "No" --> CAS1["Annullato prima dell'invio:
round_closing"] + CAS -- "Sì" --> F["Salvataggio PRIMA dell'invio:
monete segnate come spese,
partecipante 'building'"] + F --> G{"La rete accetta
l'invio?"} + G -- "No" --> H["Tutto annullato: monete liberate,
saldo ripristinato,
partecipante rimosso (errore 502)"] + G -- "Sì" --> I["Partecipante 'broadcast'
(in attesa)"] + I --> J{"Confermata entro
il tempo limite?"} + J -- "No" --> K["Commissione aumentata (RBF),
pagata dal resto dell'utente"] --> J + J -- "Mai, e la rete non
la conosce più" --> X["Abbandonata dal riconciliatore:
monete liberate, partecipante rimosso"] + J -- "Sì" --> L["Partecipante confermato:
entra nell'estrazione"] +``` + +- **[BET-12]** La transazione spende monete **intere** dell'utente, scelte + dalla più grande; tutti gli ingressi segnalano la sostituibilità (BIP125, + `RBF_SEQUENCE`). Ha due uscite: il costo meno la commissione verso + l'indirizzo del montepremi, e il resto (monete spese meno il costo) verso + l'indirizzo dell'utente. + *Codice:* `app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction` · + *Test:* `tests/unit/test_psbt_builder.py` +- **[BET-13]** La **commissione di rete** è la dimensione stimata della + transazione per `fee_rate_sat_vb` (1 sat/vB predefinito, modificabile da + `/admin`) ed è **dedotta dal costo** della scommessa: al montepremi arrivano + 10 PLM meno la commissione, e l'importo registrato per il partecipante + (`bet_amount_sats`) è proprio questo netto. Per questo il montepremi si + calcola sommando gli importi effettivi delle scommesse, non moltiplicando il + numero di giocatori per il costo. + *Codice:* `app/wallet/psbt_builder.py:build_signed_transaction`, `app/bets/service.py:place_bet` · + *Test:* `tests/unit/test_psbt_builder.py`, `tests/unit/test_rounds_route.py` +- **[BET-14]** Il **resto** torna sempre allo stesso indirizzo dell'utente ed è + sempre di almeno 294 sat: senza un'uscita di resto non si potrebbe aumentare + la commissione in caso di ritardo. A differenza del prelievo, la scommessa + non riduce mai il proprio importo per far posto al resto: la rifiuta + ([BET-09]). Il saldo di un utente che scommette deve quindi essere + strettamente maggiore del costo. + *Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · + *Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py` +- **[BET-15]** **Prima si scrive, poi si invia.** Prima dell'invio, in + un'unica transazione del database, le monete spese vengono segnate come tali + (con il txid della scommessa), il saldo viene ricalcolato, e si registrano il + partecipante (stato `building`, con l'orario di invio che ne determina + l'ordine nell'estrazione) e la transazione in corso (stato `building`). + Dettagli in + [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia). + *Codice:* `app/bets/service.py:place_bet` · + *Test:* `tests/unit/test_bets.py` +- **[BET-16]** Se l'invio fallisce per qualunque motivo, tutto torna com'era: + monete liberate, partecipante e transazione in corso **cancellati**, saldo + ricalcolato, evento `bet_broadcast_failed` nel registro di audit, notifica + SSE; la risposta è `broadcast_failed` (HTTP 502). + *Codice:* `app/bets/service.py:_release_failed_bet`, `app/api/routes/bets.py:create_bet` · + *Test:* `tests/unit/test_bets.py` +- **[BET-17]** Se l'invio riesce, il partecipante passa a `broadcast`, la + transazione in corso a `pending`, si scrive `bet_placed` (txid e importo + netto) e si invia una notifica SSE. La risposta è HTTP 201 con `round_id`, + `bet_txid`, `bet_amount_sats` e `status`. + *Codice:* `app/bets/service.py:place_bet`, `app/api/routes/bets.py:create_bet` · + *Test:* `tests/unit/test_bets.py` +- **[BET-18]** Alla prima conferma il partecipante passa a `confirmed` + ([TX-07](transazioni-e-riconciliazione.md#conferma)). Solo le scommesse + **confermate** partecipano all'estrazione; il round, allo scadere, aspetta + che tutte quelle già inviate confermino (vedi + [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#2--chiusura)). + *Codice:* `app/bets/confirmation.py:_on_bet_confirmed` · + *Test:* `tests/unit/test_confirmation.py` +- **[BET-19]** Tutte le scommesse hanno **la stessa probabilità** di vincere, + indipendentemente dall'importo netto e da tutto il resto + ([RND-17](ciclo-di-vita-del-round.md#3--estrazione)). + *Codice:* `app/rounds/draw.py:draw_winner` · + *Test:* `tests/unit/test_draw.py` + +## Prelievo + +Il prelievo (`POST /withdrawals`, con `external_address` e `amount_sats`) è +l'**unico** modo per portare fondi fuori dalla piattaforma, verso un indirizzo +esterno scelto dall'utente. + +- **[WDR-01]** Come per la scommessa, un prelievo senza connessione alla rete + è rifiutato con `network_unavailable` (HTTP 503), e tutta l'elaborazione + avviene tenendo il **blocco per-utente**. + *Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/tx/locks.py:UserLocks.acquire` + +Le condizioni sono verificate nell'ordine della tabella; un rifiuto risponde +HTTP 400 prima che si muova qualunque cifra: + +| ID | Condizione | Codice di errore | +|---|---|---| +| **[WDR-02]** | L'indirizzo di destinazione è un indirizzo PLM bech32: inizia con `plm1` ed è decodificabile. Un indirizzo di un'altra catena (ad esempio `bc1…`) o in formato legacy (`P…`) è rifiutato | `invalid_address` | +| **[WDR-03]** | L'indirizzo di destinazione non è l'indirizzo di deposito dell'utente stesso | `withdrawal_to_own_address` | +| **[WDR-04]** | L'importo è almeno pari al costo corrente di una scommessa (`bet_amount_sats`; non esiste un minimo separato) | `amount_below_minimum` (parametro `minimum_sats`) | +| **[WDR-05]** | La somma delle monete confermate e non spese copre l'importo | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`) | +| **[WDR-06]** | L'importo si copre con al massimo 50 monete (`MAX_TX_INPUTS`) | `too_many_inputs` (parametro `max_inputs`) | +| **[WDR-07]** | L'importo meno la commissione di rete resta positivo e di almeno 294 sat | `amount_below_network_fee`, `amount_below_dust_limit` | + +*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/wallet/address.py:is_valid_plm_address`, +`app/wallet/psbt_builder.py:build_signed_transaction` · +*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py` + +La struttura è identica alla scommessa: transazione firmata dall'indirizzo +dell'utente verso l'indirizzo esterno, resto all'utente, **commissione dedotta +dall'importo prelevato**, salvataggio prima dell'invio, annullamento completo +se la rete rifiuta, RBF se non conferma in tempo. + +- **[WDR-08]** La transazione spende monete intere dell'utente (dalla più + grande, ingressi sostituibili BIP125); all'indirizzo esterno arriva + l'importo meno la commissione di rete, il resto torna all'indirizzo + dell'utente. + *Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · + *Test:* `tests/unit/test_psbt_builder.py`, `tests/unit/test_withdrawals.py` +- **[WDR-09]** **Resto garantito.** Se l'importo richiesto lascerebbe un resto + inferiore a 294 sat — tipicamente il prelievo dell'intero saldo —, l'importo + viene ridotto quanto basta perché il resto sia esattamente 294 sat, che + restano sul conto dell'utente. Chi preleva tutto riceve quindi il saldo meno + 294 sat meno la commissione. Il database registra sia l'importo richiesto + (`amount_requested_sats`) sia quello effettivamente inviato al destinatario, + al netto della commissione (`amount_sent_sats`). + *Codice:* `app/wallet/psbt_builder.py:build_signed_transaction`, `app/withdrawals/service.py:request_withdrawal` · + *Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py` +- **[WDR-10]** Prima dell'invio, in un'unica transazione del database, le + monete spese vengono segnate come tali, il saldo ricalcolato, e si + registrano il prelievo e la transazione in corso, entrambi in stato + `building`. + *Codice:* `app/withdrawals/service.py:request_withdrawal` · + *Test:* `tests/unit/test_withdrawals.py` +- **[WDR-11]** Se l'invio fallisce, le monete vengono liberate e il saldo + ricalcolato, ma — a differenza della scommessa — il prelievo **non viene + cancellato**: resta come `failed` (senza txid), così come la transazione in + corso (con il motivo in `failure_reason`); si scrive + `withdrawal_broadcast_failed`, si invia una notifica SSE e la risposta è + `broadcast_failed` (HTTP 502). + *Codice:* `app/withdrawals/service.py:_release_failed_withdrawal`, `app/api/routes/withdrawals.py:create_withdrawal` · + *Test:* `tests/unit/test_withdrawals.py` +- **[WDR-12]** Se l'invio riesce, il prelievo passa a `broadcast`, la + transazione in corso a `pending`, si scrive `withdrawal_sent` (txid, importo + inviato, indirizzo esterno) e si invia una notifica SSE. La risposta è + HTTP 201 con `txid`, `amount_requested_sats`, `amount_sent_sats` e `status`. + Alla prima conferma il prelievo passa a `confirmed` + ([TX-07](transazioni-e-riconciliazione.md#conferma)). + *Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` · + *Test:* `tests/unit/test_withdrawals.py` + +## Scommessa e prelievo insieme + +Scommessa e prelievo spendono le stesse monete, quindi non devono mai sceglierne +una in comune. Questo è garantito da due meccanismi: + +1. un **blocco per-utente** ([BET-02](#quando-è-accettata), + [WDR-01](#prelievo)): la costruzione di una scommessa e quella di un + prelievo dello stesso utente non possono mai essere in corso nello stesso + momento; +2. la selezione delle monete **salta quelle già segnate come spese**, anche se + la transazione che le spende non è ancora confermata + ([BET-07](#quando-è-accettata), [WDR-05](#prelievo)). + +- **[WDR-13]** Non c'è altro vincolo: un prelievo è accettato + anche mentre una scommessa attende conferma (e viceversa), purché le monete + confermate e ancora libere lo coprano. Congelare il resto del saldo per un blocco non + aggiungerebbe alcuna sicurezza. + *Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/bets/service.py:place_bet` · + *Test:* `tests/unit/test_withdrawals.py` + +Il blocco per-utente vive nella memoria del processo: vale finché il server è +un solo processo (vedi [architettura.md](architettura.md) e +[decisioni-e-limiti.md](decisioni-e-limiti.md)). + +Cosa succede a una transazione dopo l'invio — conferma, RBF, abbandono — è +descritto in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md). diff --git a/docs/setup.md b/docs/setup.md index 373e972..cb2a6b2 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -35,8 +35,11 @@ cp .env.example .env | `JWT_SECRET` | Firma i token di sessione degli utenti. | `python -c "import secrets; print(secrets.token_urlsafe(32))"` | | `ADMIN_TOKEN` | Token bearer richiesto sugli endpoint admin (header `X-Admin-Token`). | `python -c "import secrets; print(secrets.token_urlsafe(32))"` | -Le altre chiavi di `.env` (`DATABASE_URL`, `ELECTRUM_HOST`/`PORT`/`USE_SSL`, -`MASTER_KEY_PATH`) hanno default sensati in `.env.example`. +Le chiavi Electrum (`ELECTRUM_HOST`/`PORT`/`USE_SSL`) hanno default sensati in +`.env.example`. `DATABASE_URL` e `MASTER_KEY_PATH` non sono elencate lì: +hanno un default in `app/config.py`, e dentro il container vengono comunque +sovrascritte da `docker-compose.yml` per puntare a `./data/` (elenco completo +delle variabili in [architettura.md](architettura.md)). `ENABLE_API_DOCS` (default `false`) controlla Swagger/ReDoc/l'OpenAPI JSON grezzo su `/docs`, `/redoc` e `/openapi.json`: espongono l'intera superficie dell'API, @@ -119,9 +122,10 @@ pip install -e ".[dev]" ## 6. Impostare l'indirizzo delle fee -Prima che il primo round possa pagare, un admin deve impostare `fee_address` -tramite il pannello admin o l'API — vedi [guida-admin.md](guida-admin.md). I -payout si rifiutano di partire finché non è impostato. +Prima che si possa aprire il primo round, un admin deve impostare +`fee_address` tramite il pannello admin o l'API — vedi +[guida-admin.md](guida-admin.md). Finché non è impostato **nessun round si +apre**, perché il pagamento non si potrebbe costruire. A questo punto l'istanza è pronta per essere avviata — continua con [running-the-server.md](running-the-server.md). diff --git a/docs/sicurezza.md b/docs/sicurezza.md new file mode 100644 index 0000000..139c1c8 --- /dev/null +++ b/docs/sicurezza.md @@ -0,0 +1,515 @@ +# Sicurezza + +Come PLM Lottery protegge le chiavi, le sessioni e l'accesso all'API, e +quali rischi accetta consapevolmente. Il documento descrive il codice così +com'è: dove un meccanismo manca, lo dice. + +Per la difesa contro server Electrum ostili (validazione degli header, +quorum di corroborazione) vedi [connessione-electrum.md](connessione-electrum.md); +per gli endpoint e i codici di errore [api.md](api.md); per l'elenco degli +eventi di audit [audit-log.md](audit-log.md); per backup e procedure +operative [operativita.md](operativita.md); per le decisioni di progetto e i +limiti accettati [decisioni-e-limiti.md](decisioni-e-limiti.md). + +## Modello di fiducia + +La piattaforma è **custodiale**: tutte le chiavi private sono generate e +conservate dal server. L'utente non controlla nulla finché non preleva. + +| Soggetto | Fiducia | Perché | +|---|---|---| +| Operatore (chi ha `ADMIN_TOKEN`) | **Totale** | Può esportare la chiave privata di ogni utente, reimpostare le password, cambiare l'indirizzo delle commissioni. | +| Host del server (chi ha accesso al filesystem o a Docker) | **Totale** | Sullo stesso host stanno la master key cifrata *e* la chiave che la decifra (`.env`). | +| Utenti | Nessuna | Ogni input è validato; ogni azione è legata al proprio account tramite JWT. | +| Server Electrum | Nessuna | Header validati e decisioni che muovono denaro corroborate da un quorum: vedi [connessione-electrum.md](connessione-electrum.md#5-il-quorum). | +| Rete e client HTTP | Nessuna | TLS terminato da Caddy; l'IP del client è ricavato solo dall'hop scritto da Caddy. | +| Caddy (reverse proxy) | Totale | È l'unico proxy davanti all'app, e l'unica fonte di `X-Forwarded-For` di cui l'app si fida. | + +- **[SEC-01]** Tutte le chiavi private (utenti e montepremi) derivano da + un'unica master xprv generata e custodita dal server; nessuna chiave è mai + generata o conservata lato client. + *Codice:* `app/wallet/hd.py:generate_master_key`, `app/wallet/hd.py:_load_account_key` + +## Inventario dei segreti + +| Segreto | Dove vive | Come si genera | Se trapela | Se si perde | Rotazione | +|---|---|---|---|---|---| +| Master xprv | File cifrato `MASTER_KEY_PATH` (in Docker `./data/keys/master.xprv.enc`, montato in `/app/key_data/`) | `scripts/generate_master_key.py` (32 byte da `os.urandom`) oppure importata con `scripts/encrypt_master_key.py` | Il file da solo è inutile senza `XPRV_ENCRYPTION_KEY`; insieme, chi li ha può spendere **tutti** i fondi (utenti e montepremi) | Tutti i fondi diventano irrecuperabili, salvo un backup in chiaro ottenuto con `decrypt_master_key.py` | **Non ruotabile**: gli indirizzi degli utenti sono permanenti e derivati da essa | +| `XPRV_ENCRYPTION_KEY` | `.env` (variabile d'ambiente del container) | `Fernet.generate_key()` (comando in `.env.example`) | Insieme al file cifrato: come sopra | Il file cifrato non è più leggibile: fondi persi senza backup della xprv | Manuale: decifrare con la chiave vecchia, impostare la nuova, `encrypt_master_key.py --overwrite`. Non esiste uno script dedicato | +| `JWT_SECRET` | `.env` | `secrets.token_urlsafe(32)` | Chiunque può firmare un token per qualunque utente (con il `tv` corretto, che è un piccolo intero) e agire come lui, prelievi compresi | Nessuna perdita di dati: se ne genera uno nuovo | Cambiarlo e riavviare invalida **tutte** le sessioni | +| `ADMIN_TOKEN` | `.env`; nel browser dell'operatore in `sessionStorage` (`plm_admin_token`) | `secrets.token_urlsafe(32)` | Accesso completo a `/admin/*`, compreso l'export delle chiavi private | Nessuna: se ne imposta uno nuovo | Cambiarlo e riavviare; l'operatore reinserisce il token | +| Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore | +| JWT dell'utente | Browser, `localStorage` (`plm_token`) | Emesso al login/registrazione | Sessione dell'utente fino alla scadenza (24h) o al cambio password | — | Cambio password o reset dall'operatore | + +- **[SEC-02]** I segreti (`XPRV_ENCRYPTION_KEY`, `JWT_SECRET`, `ADMIN_TOKEN`) + stanno solo in `.env`, mai nel database o nel repository; `.env`, i file + `master.xprv.enc`, i database `*.db`, `data/` e `logs/` sono esclusi sia da + git sia dall'immagine Docker. + *Codice:* `app/config.py:Settings`, `.gitignore`, `.dockerignore` + +- **[SEC-03]** Tutti i parametri operativi stanno nel database + (`round_config`); in `.env` restano solo segreti e collegamenti + infrastrutturali (database, percorso della master key, server Electrum, + documentazione API). Cambiare un valore di `.env` richiede un riavvio. + *Codice:* `app/config.py:Settings` + +`.env.example` documenta le chiavi necessarie e il comando per generarle; +non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque +sovrascritte da `docker-compose.yml`. + +## Ciclo di vita della master key + +```mermaid +flowchart TD + G["generate_master_key.py
seed casuale 32 byte"] --> E + I["encrypt_master_key.py
xprv importata (input nascosto)"] --> E + E["Cifratura Fernet
con XPRV_ENCRYPTION_KEY"] --> F[("master.xprv.enc
./data/keys/")] + F --> L["Primo uso a runtime:
decifratura e derivazione
di m/84'/746'/0'"] + L --> U["Utenti
m/84'/746'/0'/0/indice"] + L --> P["Montepremi
m/84'/746'/0'/1/0"] + F --> D["decrypt_master_key.py
stampa la xprv in chiaro
(solo recupero)"] +``` + +### Generazione + +- **[SEC-04]** La master key si crea solo con uno script manuale, mai tramite + API: `generate_master_key.py` genera un seed casuale di 32 byte, ne ricava + la xprv (versione PLM mainnet), la cifra e la scrive in `MASTER_KEY_PATH`. + Rifiuta di sovrascrivere un file esistente; entrambi gli script di + creazione rifiutano di partire se `XPRV_ENCRYPTION_KEY` è vuota. + *Codice:* `app/wallet/hd.py:generate_master_key`, `scripts/generate_master_key.py` + +- **[SEC-05]** `encrypt_master_key.py` importa una xprv generata altrove: la + legge con input nascosto (`getpass`), verifica che sia una chiave estesa + valida e **privata** (rifiuta una xpub), e la cifra con lo stesso schema. + Non può verificare che sia una chiave PLM: PLM usa gli stessi byte di + versione xprv di Bitcoin. Sovrascrive un file esistente solo con + `--overwrite`. + *Codice:* `scripts/encrypt_master_key.py` + +### Cifratura a riposo + +- **[SEC-06]** Il file della master key contiene la xprv in base58 cifrata con + **Fernet** (AES-128-CBC + HMAC-SHA256) usando `XPRV_ENCRYPTION_KEY`. La xprv + in chiaro non viene mai scritta su disco dal server. + *Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/keystore.py:decrypt_xprv` + +La cifratura protegge da una copia del solo file (un backup di `data/` +finito nel posto sbagliato), **non** da chi ha accesso all'host: la chiave di +decifratura sta nel `.env` accanto. Gli script non impostano permessi +restrittivi sul file scritto: valgono quelli predefiniti del sistema. + +### Derivazione + +- **[SEC-07]** Gli indirizzi utente seguono BIP84 con coin type 746: + `m/84'/746'/0'/0/`, un indice per utente, P2WPKH con HRP + `plm`. + *Codice:* `app/wallet/hd.py:derive_user_key`, `app/wallet/hd.py:derive_user_address`, + `app/wallet/plm_network.py` · + *Test:* `tests/unit/test_hd.py` + +- **[SEC-08]** La chiave del montepremi è `m/84'/746'/0'/1/0` (ramo 1 + dell'account, indice 0): sta nella stessa master key, non è un segreto + separato. Non esiste un endpoint che ne esporti la chiave privata. + *Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address` + +### Caricamento a runtime + +- **[SEC-09]** La master key viene letta e decifrata **al primo uso** (la + prima derivazione, tipicamente una registrazione o il primo pagamento), non + all'avvio. Da quel momento la chiave dell'account `m/84'/746'/0'` resta in + memoria nel processo per tutta la sua durata; la root xprv non viene + conservata. + *Codice:* `app/wallet/hd.py:_load_account_key` + +### Recupero + +- **[SEC-10]** `decrypt_master_key.py` decifra e stampa la xprv in chiaro, solo + dopo una conferma esplicita (`yes`) e un avviso sul terminale. Se il + percorso di `MASTER_KEY_PATH` non esiste usa `./data/keys/master.xprv.enc`. + Non esiste un equivalente via API o pannello. + *Codice:* `scripts/decrypt_master_key.py` + +## Regole di avvio + +- **[SEC-11]** Il container rifiuta di partire se il file della master key non + esiste in `MASTER_KEY_PATH`, prima ancora delle migrazioni. + *Codice:* `Dockerfile` (comando `CMD`) + +- **[SEC-12]** All'avvio il server **rifiuta di servire** se `JWT_SECRET` ha + meno di 32 caratteri o se `XPRV_ENCRYPTION_KEY` è vuota; tutti i problemi + sono riportati insieme in un unico `ConfigError`. Il controllo è nel + lifespan, non nella classe `Settings`, perché test e script importano la + configurazione senza avere segreti reali. + *Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` · + *Test:* `tests/unit/test_config.py` + +- **[SEC-13]** Un `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello + risulta semplicemente chiuso (vedi [SEC-24]). + *Codice:* `app/config.py:validate_runtime_secrets` · + *Test:* `tests/unit/test_config.py` + +Il controllo di avvio verifica che `XPRV_ENCRYPTION_KEY` non sia vuota, non +che sia una chiave Fernet valida né che decifri davvero il file: una chiave +malformata o sbagliata emerge solo al primo uso della master key ([SEC-09]). + +## Autenticazione degli utenti + +Il flusso di registrazione e login è descritto in +[registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione); +qui ci sono le regole di sicurezza. + +### Password + +- **[SEC-14]** Le password sono salvate come hash **Argon2id** con i parametri + predefiniti di `argon2-cffi` (`PasswordHasher()` senza argomenti; con la + versione installata 25.1: time cost 3, memoria 64 MiB, parallelismo 4, sale + 16 byte, hash 32 byte). Mai in chiaro, mai recuperabili. + *Codice:* `app/auth/security.py:hash_password` · + *Test:* `tests/unit/test_security.py` + +- **[SEC-15]** Ogni hash e verifica chiamati da codice asincrono passano da + `hash_password_async`/`verify_password_async`, che eseguono Argon2 in un + thread del threadpool: un login non deve bloccare l'event loop e con esso le + attività in background. Le versioni sincrone restano per test e script. + *Codice:* `app/auth/security.py:hash_password_async`, `app/auth/security.py:verify_password_async` · + *Test:* `tests/unit/test_security.py` + +- **[SEC-16]** Qualunque errore di verifica (password sbagliata, hash + illeggibile) vale come "password errata", mai come errore del server; un + hash illeggibile viene anche registrato nel log. + *Codice:* `app/auth/security.py:verify_password` · + *Test:* `tests/unit/test_security.py` + +- **[SEC-17]** Lunghezza minima della password: 8 caratteri + (`MIN_PASSWORD_LENGTH`), la stessa costante per registrazione e cambio + password. Alla registrazione la password ha anche un massimo di 256 + caratteri; il cambio password e il login non impongono un massimo. + *Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`, + `app/api/routes/users.py:change_password` · + *Test:* `tests/unit/test_users.py` + +- **[SEC-18]** Lo username è di 3–32 caratteri, solo `A-Z a-z 0-9 _ . -`, e + **non distingue maiuscole e minuscole**: l'unicità è garantita da un indice + unico su `lower(username)` e registrazione e login confrontano + `lower(...)`. Il nome resta salvato come digitato. + *Codice:* `app/auth/routes.py:RegisterRequest`, `app/auth/routes.py:register`, + `app/auth/routes.py:login`, `app/db/models.py:User` · + *Test:* `tests/unit/test_auth.py` + +- **[SEC-19]** Il login risponde allo stesso modo (401 `invalid_credentials`) + sia per uno username inesistente sia per una password errata, e i due casi + alimentano lo stesso contatore di tentativi. Nota: se lo username non + esiste la verifica Argon2 non viene eseguita, quindi i due casi hanno tempi + di risposta diversi. La registrazione, invece, rivela per costruzione se uno + username è occupato (409 `username_taken`). + *Codice:* `app/auth/routes.py:login` · + *Test:* `tests/unit/test_auth.py` + +- **[SEC-20]** Non esiste recupero della password né reset self-service + (nessuna email è raccolta). Un utente autenticato può *cambiarla* solo + fornendo quella attuale; altrimenti serve il reset dall'operatore + ([SEC-27]). + *Codice:* `app/api/routes/users.py:change_password` · + *Test:* `tests/unit/test_users.py` + +### Token di sessione (JWT) + +- **[SEC-21]** Login e registrazione restituiscono un JWT **HS256** firmato con + `JWT_SECRET`, valido `jwt_expire_minutes` (predefinito 1440 = 24 ore). Le + claim sono `sub` (id utente), `tv` (versione del token) ed `exp`. La + decodifica accetta solo l'algoritmo configurato. Il client lo invia come + `Authorization: Bearer …` e lo conserva in `localStorage`. + *Codice:* `app/auth/security.py:create_access_token`, `app/auth/security.py:decode_access_token` · + *Test:* `tests/unit/test_security.py` + +- **[SEC-22]** I token sono **revocabili**: ogni richiesta autenticata + confronta la claim `tv` con `User.token_version` e rifiuta (401 + `session_expired`) un token con una versione diversa, un token non valido o + scaduto, o un utente inesistente. Il cambio password self-service e il reset + dell'operatore incrementano `token_version`, invalidando ogni sessione + precedente; il cambio password restituisce un token nuovo, così la sessione + corrente resta attiva. Un token privo di `tv` (emesso prima della sua + introduzione) vale come `tv = 0`. + *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` + +- **[SEC-23]** Gli endpoint raggiungibili anche da non autenticati (ad esempio + `GET /rounds/current`, `POST /bug-reports`) usano l'utente solo per + personalizzare la risposta: un token assente, non valido o superato li fa + trattare come anonimi, senza errore. + *Codice:* `app/auth/dependencies.py:get_optional_user` · + *Test:* `tests/unit/test_rounds_route.py`, `tests/unit/test_bug_reports.py` + +## Autenticazione dell'operatore + +Il pannello `/admin` è una pagina statica pubblica; la protezione sta sugli +endpoint `/admin/*`, che richiedono l'header `X-Admin-Token`. + +- **[SEC-24]** Ogni endpoint `/admin/*` confronta `X-Admin-Token` con + `ADMIN_TOKEN` in tempo costante (`secrets.compare_digest` sui byte UTF-8, + così un token con caratteri non ASCII dà 403 e non 500). Se `ADMIN_TOKEN` è + vuoto **ogni** richiesta è rifiutata con 403, prima del confronto (che su due + stringhe vuote darebbe esito positivo). + *Codice:* `app/api/routes/admin.py:require_admin` · + *Test:* `tests/unit/test_admin.py` + +- **[SEC-25]** Il token è **unico e condiviso**: non esiste un'identità per + singolo operatore, e l'audit log registra *cosa* è cambiato ma non *chi* + l'ha fatto. Non c'è limite di tentativi né lunghezza minima imposta su + `ADMIN_TOKEN`. Nel browser il token è tenuto in `sessionStorage`, quindi si + perde alla chiusura della scheda. + *Codice:* `app/api/routes/admin.py:require_admin`, `app/static/admin.js` + +Il token protegge: configurazione e pausa/ripresa della lotteria, elenco +utenti con saldi, export della chiave privata, reset della password, storico +dei round, transazioni pendenti, audit log, bug report. L'elenco completo +degli endpoint è in [api.md](api.md). + +- **[SEC-26]** `GET /admin/users/{id}/privkey` esporta la chiave privata WIF + dell'utente: è **intenzionale** (il server ha già la master key; l'endpoint + espone solo ciò che l'operatore potrebbe comunque ricavare). Ogni accesso + scrive `admin_privkey_accessed` nell'audit log (con l'id utente, mai la + chiave). Il pannello mostra la WIF con `textContent`, senza conservarla. + *Codice:* `app/api/routes/admin.py:user_privkey`, `app/wallet/hd.py:derive_user_wif` · + *Test:* `tests/unit/test_admin.py` + +- **[SEC-27]** `POST /admin/users/{id}/reset-password` imposta una password + casuale (`secrets.token_urlsafe(12)`, 16 caratteri), la restituisce **una + sola volta** perché l'operatore la comunichi, incrementa `token_version` + ([SEC-22]) e scrive `admin_password_reset` nell'audit log (senza la + password). + *Codice:* `app/api/routes/admin.py:reset_user_password` · + *Test:* `tests/unit/test_admin.py` + +- **[SEC-28]** Ogni azione dell'operatore che cambia lo stato lascia una + traccia nell'audit log: modifica della configurazione (`config_updated`, + con valori prima/dopo, niente se nulla cambia), pausa e ripresa + (`lottery_paused`/`lottery_resumed`, e `paused` non è modificabile da + `PUT /admin/config`), cambio di stato di un bug report + (`bug_report_status_changed`). Il catalogo completo è in + [audit-log.md](audit-log.md). + *Codice:* `app/api/routes/admin.py:update_config`, `app/api/routes/admin.py:pause_lottery`, + `app/api/routes/admin.py:resume_lottery`, `app/api/routes/admin.py:update_bug_report_status` · + *Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py` + +- **[SEC-29]** `fee_address`, se modificato dall'operatore, deve essere un + indirizzo PLM bech32 valido (checksum e HRP `plm`): un indirizzo di un'altra + catena manderebbe ogni commissione su uno script irraggiungibile. Anche gli + altri parametri di `PUT /admin/config` hanno limiti minimi e massimi. + *Codice:* `app/api/routes/admin.py:RoundConfigUpdate` · + *Test:* `tests/unit/test_admin.py` + +## Limiti di frequenza + +Tutti i limitatori sono **in memoria, nel singolo processo**: si azzerano a +ogni riavvio e non funzionerebbero con più worker. + +| Limitatore | Chiave | Regola | Dove | +|---|---|---|---| +| Login per username | `user:` | Dal 5° fallimento: blocco di 2s, raddoppiato a ogni fallimento successivo, massimo 300s. Il contatore si azzera dopo 900s senza fallimenti, o con un login riuscito | `POST /auth/login` | +| Login per IP | `ip:` | Dal 20° fallimento: stessa progressione (2s → 300s), stesso decadimento di 900s. **Non** si azzera con un login riuscito | `POST /auth/login` | +| Registrazione per IP | `ip:` | Al massimo 5 account creati per IP in una finestra mobile di 3600s. Contano solo gli account creati davvero | `POST /auth/register` | +| Stream SSE per IP | IP | Al massimo 5 stream contemporanei per IP: il 6° chiude il più vecchio dello stesso IP | `GET /rounds/stream` | +| Stream SSE globale | — | Al massimo 500 stream contemporanei: oltre, 503 | `GET /rounds/stream` | + +- **[SEC-30]** Il login è bloccato se **uno qualunque** dei due limitatori + (username o IP) è in blocco, anche con la password giusta; la risposta è + 429 `rate_limited` con `retry_after_seconds` nei `params`. Ogni fallimento + alimenta entrambi i limitatori; un successo azzera solo quello dello + username. + *Codice:* `app/auth/routes.py:login`, `app/auth/rate_limit.py:RateLimiter`, + `app/auth/rate_limit.py:AuthRateLimiters` · + *Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py` + +- **[SEC-31]** La registrazione è una **quota**, non un freno sui fallimenti: + 5 account per IP ogni 3600s (finestra mobile). Il controllo avviene prima + dell'hash Argon2; un tentativo che non crea l'account (username occupato, + input non valido) non consuma la quota. Oltre la quota: 429 `rate_limited` + con l'attesa esatta fino all'uscita dalla finestra dell'account più vecchio. + *Codice:* `app/auth/routes.py:register`, `app/auth/rate_limit.py:RollingQuota` · + *Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py` + +- **[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 + chiavi; oltre, si scartano le voci più vicine alla scadenza, mai i blocchi + più recenti. + *Codice:* `app/auth/rate_limit.py:RateLimiter._prune`, `app/auth/rate_limit.py:RollingQuota._prune` · + *Test:* `tests/unit/test_rate_limit.py` + +- **[SEC-33]** Il canale SSE accetta al massimo 5 stream contemporanei per IP + (il sesto chiude il più vecchio dello stesso IP invece di essere + rifiutato) e 500 in totale (oltre, 503; 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` + +- **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi, cambio + password (tentativi sulla password attuale compresi), endpoint admin + (tentativi sul token compresi), invio di bug report (anche anonimi), QR, + `GET /rounds/current` e gli altri endpoint di lettura. Scommesse e prelievi + sono serializzati per utente dal blocco per-utente (vedi + [scommesse-e-prelievi.md](scommesse-e-prelievi.md#scommessa-e-prelievo-insieme)), + che non è un limite di frequenza. + *Codice:* `app/api/routes/bets.py`, `app/api/routes/withdrawals.py`, + `app/api/routes/users.py:change_password`, `app/api/routes/bug_reports.py:create_bug_report` + +## Indirizzo IP del client + +Ogni controllo basato sull'IP (limitatori di login e registrazione, limite +SSE per IP) usa la stessa funzione. + +- **[SEC-35]** L'IP del client è l'**ultimo** elemento di `X-Forwarded-For` + (quello scritto dall'hop più vicino, cioè Caddy), non il primo, che il + client può falsificare. Senza l'header si usa l'indirizzo della connessione, + e in mancanza anche di quello `unknown`. + *Codice:* `app/api/client_ip.py:client_ip` · + *Test:* `tests/unit/test_client_ip.py` + +- **[SEC-36]** Caddy **sovrascrive** `X-Forwarded-For` con l'indirizzo reale + del peer (`header_up X-Forwarded-For {remote_host}`) invece di accodarlo a + quello inviato dal client. Le due difese ([SEC-35] e questa) reggono + ciascuna da sola. + *Codice:* `Caddyfile` · + *Test:* `tests/unit/test_caddyfile_security_headers.py` + +- **[SEC-37]** Il container `app` non è pubblicato sull'host: la porta 8123 + è solo dichiarata con `expose` sulla rete interna di compose, e l'unico ingresso + dall'esterno è Caddy (porte 80 e 443). È questo che rende affidabile + l'ultimo hop di `X-Forwarded-For`. + *Codice:* `docker-compose.yml` + +## TLS e header HTTP + +- **[SEC-38]** Caddy termina TLS: con `SITE_ADDRESS` impostato ottiene e + rinnova un certificato Let's Encrypt; con il valore predefinito + `localhost` usa un certificato autofirmato della propria CA interna. + *Codice:* `Caddyfile`, `docker-compose.yml` + +- **[SEC-39]** Caddy aggiunge a ogni risposta questi header: + + ``` + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'" + ``` + + *Codice:* `Caddyfile` · + *Test:* `tests/unit/test_caddyfile_security_headers.py` + +La CSP è la principale difesa contro l'XSS, rilevante perché il JWT sta in +`localStorage` e uno script iniettato potrebbe leggerlo. `script-src` e +`style-src` richiedono però `'unsafe-inline'`: entrambe le SPA usano gestori +`onclick` e attributi `style=""` inline ovunque, e toglierli è un refactoring +a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in +`style.css`/`admin.css`). I dati provenienti dal server inseriti con +`innerHTML` nei due pannelli passano da una funzione `escapeHtml`. + +- **[SEC-40]** Le pagine HTML (`/`, `/admin`, `/guida`, `/report-bug`) sono + servite con `Cache-Control: no-store`. Non è configurato alcun middleware + CORS: il browser consente le chiamate all'API solo dalla stessa origine. + *Codice:* `app/main.py:index_page`, `app/main.py:admin_panel` + +## Superficie esposta + +- **[SEC-41]** La documentazione interattiva dell'API (Swagger `/docs`, ReDoc + `/redoc`, `/openapi.json`) è **disattivata** salvo `ENABLE_API_DOCS=true`, + da usare solo in sviluppo: elencherebbe l'intera API, admin compreso. + *Codice:* `app/config.py:Settings`, `app/main.py` · + *Test:* `tests/unit/test_docs_disabled.py` + +- **[SEC-42]** Un errore non gestito risponde 500 con il formato di errore + standard (`internal_error`) e **senza** il testo dell'eccezione, che resta + solo in `logs/app.log`. + *Codice:* `app/main.py:log_unhandled_exception` + +Il pannello `/admin` non è collegato da `/` (né viceversa), ma è una scelta +di discrezione, non una protezione: la pagina è pubblica, gli endpoint no. + +## Validazione degli input + +- **[SEC-43]** Un indirizzo fornito dall'esterno (destinazione di un prelievo, + `fee_address`, QR) è accettato solo se inizia con `plm1` **e** supera la + decodifica bech32 con checksum: un indirizzo valido di un'altra catena + (`bc1…`) sarebbe altrimenti uno script valido su cui i fondi andrebbero + persi. Il prelievo verso il proprio indirizzo di deposito è rifiutato. + *Codice:* `app/wallet/address.py:is_valid_plm_address`, + `app/withdrawals/service.py:request_withdrawal` · + *Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_qr.py` + +- **[SEC-44]** `GET /qr/{address}` è pubblico (lo carica un ``, che non + può portare il token) ma limitato: rifiuta con 400 `invalid_address` un + indirizzo più lungo di 100 caratteri o non valido ([SEC-43]) prima di + generare qualunque immagine; il rendering avviene fuori dall'event loop ed è + memorizzato per al più 512 indirizzi. + *Codice:* `app/api/routes/qr.py:address_qr`, `app/api/routes/qr.py:_render_png` · + *Test:* `tests/unit/test_qr.py` + +- **[SEC-45]** Un bug report ha una descrizione obbligatoria di 1–2000 + caratteri (non vuota dopo il trim) e un contatto facoltativo di al più 256 + caratteri. Può essere inviato anche da non autenticati; se il mittente è + autenticato viene associato al suo account, e solo lui vede i propri report + (`GET /bug-reports/mine`, autenticato). + *Codice:* `app/api/routes/bug_reports.py:BugReportCreate`, + `app/api/routes/bug_reports.py:create_bug_report`, + `app/api/routes/bug_reports.py:list_my_bug_reports` · + *Test:* `tests/unit/test_bug_reports.py` + +- **[SEC-46]** Gli endpoint admin che elencano dati (round, audit log, + transazioni pendenti, bug report) accettano `limit` tra 1 e 500. L'elenco + utenti non è paginato. + *Codice:* `app/api/routes/admin.py:list_rounds`, `app/api/routes/admin.py:list_audit_log`, + `app/api/routes/admin.py:list_pending_transactions`, `app/api/routes/admin.py:list_bug_reports`, + `app/api/routes/admin.py:list_users` · + *Test:* `tests/unit/test_admin.py` + +Tutto l'accesso al database passa dall'ORM di SQLAlchemy con parametri, senza +SQL composto da stringhe. + +## Dati conservati e log + +- **[SEC-47]** Di un utente si conservano solo: username (come digitato), hash + Argon2 della password, indice di derivazione, indirizzo, saldo in cache, + `token_version`, data di creazione. **Nessuna email** né altro dato + anagrafico. L'unico campo libero che può contenere un recapito è il + `contact` facoltativo di un bug report. Gli indirizzi IP dei client non + sono salvati nel database: l'app li usa solo nella memoria dei limitatori. + *Codice:* `app/db/models.py:User`, `app/db/models.py:BugReport` + +La struttura completa delle tabelle è in [modello-dati.md](modello-dati.md). + +- **[SEC-48]** Nessun segreto finisce nei log o nell'audit log: né password + (nemmeno quella generata dal reset), né WIF, né token, né la master key. Il + log applicativo (`logs/app.log`, rotazione a 10 MB × 5 file) contiene i + messaggi dell'app e di uvicorn, incluso il log di accesso di uvicorn + (indirizzo del peer diretto, metodo, percorso, esito: nessun percorso + contiene un segreto, e gli header `Authorization`/`X-Admin-Token` non sono + registrati). L'unico script che stampa un segreto è + `decrypt_master_key.py`, per scelta e dopo conferma. WIF e password + generate dal reset compaiono solo nella risposta HTTP all'operatore. + *Codice:* `app/logging_config.py:setup_logging`, `app/api/routes/admin.py:user_privkey`, + `app/api/routes/admin.py:reset_user_password` + +## Rischi accettati + +Limiti noti e voluti, descritti in dettaglio in +[decisioni-e-limiti.md](decisioni-e-limiti.md): + +- **Custodia totale.** Chi controlla l'host o `ADMIN_TOKEN` controlla tutti i + fondi; la cifratura a riposo non protegge da chi ha accesso all'host. +- **Token admin unico e condiviso**, senza identità per operatore né limite + di tentativi: una fuga ha il raggio d'azione massimo. +- **JWT in `localStorage`**, quindi leggibile da un eventuale XSS; la CSP lo + mitiga ma ammette script inline. +- **Limiti di frequenza solo su login, registrazione e SSE**, in memoria e per + singolo processo. +- **Nessuna corroborazione se `ELECTRUM_FALLBACK_SERVERS` è vuoto**: il server + Electrum principale diventa fidato (vedi + [connessione-electrum.md](connessione-electrum.md#5-il-quorum)). +- **Una sola conferma** per ogni transazione, con il rischio di + riorganizzazione accettato e nessuna logica di rollback. +- **La master key non è ruotabile**: gli indirizzi degli utenti sono + permanenti. diff --git a/docs/storico-bug.md b/docs/storico-bug.md new file mode 100644 index 0000000..1f058c3 --- /dev/null +++ b/docs/storico-bug.md @@ -0,0 +1,213 @@ +# Storico dei bug + +Registro di tutti i difetti numerati `B-nn` trovati finora: cosa non andava, +quanto era grave, quale commit lo ha corretto e quale test impedisce che +ritorni. È la memoria storica del progetto; le regole valide oggi stanno negli +altri documenti della specifica, a cui la colonna **Area** rimanda per +prefisso (vedi [processo-di-modifica.md](processo-di-modifica.md#prefissi)). + +Questo file non contiene requisiti e quindi non ha identificativi propri. + +## Convenzione + +- **Un difetto nuovo prende il prossimo numero libero**, senza riusare né + rinumerare quelli esistenti. **Il prossimo numero libero è `B-73`.** +- **Un commit dedicato per ogni difetto**, con il numero nel messaggio (ad + esempio `Snapshot a round's timing when it opens (B-61)`), così + `git log --all --grep 'B-nn'` ritrova sempre come è stato chiuso. +- **Un test di regressione per ogni difetto**, che cita il numero nella sua + docstring o in un commento. Dove un test automatico non è possibile (resa + grafica nel browser, layout dell'immagine Docker) lo si dichiara, come nelle + righe marcate *manuale* qui sotto. +- **Il marcatore `B-nn` va anche nel codice**, in un commento accanto alla + correzione: le centinaia di riferimenti sparsi in codice e test sono puntatori verso + questo registro e verso la storia git. +- **Prima la specifica, se era sbagliata.** Una correzione che riporta il + codice a ciò che i documenti già dicevano è solo un `B-nn`. Se invece il + difetto rivela che la specifica era sbagliata o incompleta, si corregge prima + il requisito in `docs/`, come per qualunque altra modifica + ([processo-di-modifica.md](processo-di-modifica.md#il-ciclo-di-una-modifica)). +- I limiti accettati **per scelta** non sono bug e non prendono un `B-nn`: + stanno in [decisioni-e-limiti.md](decisioni-e-limiti.md). + +### Da dove vengono questi dati + +I difetti sono stati tracciati in un file `BUGS.md` alla radice del +repository, riscritto a ogni audit: + +| Versione | Commit | Contenuto | +|---|---|---| +| Audit 2026-07-26 | `fb734bb`, `9b6a4a2`, `845ba98` | B-01 … B-24, con gravità, correzione proposta e, nell'ultima versione, la nota **Fixed** di ciascuno | +| Audit 2026-07-27 | `43d2321`, `7224ca0` | B-25 … B-49 con gravità | +| Chiusura | `0d2fef6` | file cancellato quando entrambe le liste erano vuote | +| Audit 2026-08-03 | `99d7a1e` e successivi | B-52 … B-72, ogni voce rimossa al momento della correzione | + +Per leggere una versione: `git show :BUGS.md`. Il `BUGS.md` attuale, +presente nel ramo `main`, contiene solo le voci ancora aperte (B-71 e B-72). + +La gravità è quella registrata in `BUGS.md` al momento dell'audit. Il terzo +audit la definiva così, e la stessa scala vale in sostanza anche per i primi +due: **critica** = denaro bloccato o perso, oppure la lotteria si ferma; +**alta** = un controllo di sicurezza che non regge, o un difetto con effetti +seri sul denaro; **media** = comportamento errato con danni circoscritti; +**bassa** = igiene, oppure documentazione non allineata al codice. + +--- + +## Audit del 2026-07-26 (B-01 … B-24) + +Primo audit completo del codice (commit di partenza `d4e0974`, 79 test verdi, +nessuno dei quali copriva questi casi). Le correzioni sono raggruppate per +tema in otto commit, fra `cc88763` e `dc4d576`, e il loro esito è registrato +in `845ba98`. La suite passò da 79 a 139 test. + +La causa comune dei quattro difetti critici principali (B-01 … B-04) era che +**il codice considerava un invio come definitivo e la connessione come +infallibile**: non esisteva alcuna riconciliazione fra database e catena. + +| B-nn | Difetto | Gravità | Commit | Test di regressione | Area | +|---|---|---|---|---|---| +| B-01 | Una connessione Electrum caduta bloccava per sempre l'intero server: nessuna riconnessione, nessun timeout sulle richieste, nessun keepalive | Critica | `7c4e998` Survive a dropped Electrum connection, and fall back to other servers | `test_electrum_client.py::test_request_times_out_instead_of_hanging_forever`, `::test_wait_closed_resolves_when_the_read_loop_dies`; `test_electrum_listener.py::test_rotates_to_the_next_server_after_a_failed_session` | ELC | +| B-02 | Un RBF su una scommessa lasciava `RoundParticipant.bet_txid` sul vecchio txid, e il round restava in `closing` per sempre | Critica | `d528c5b` Let the system recover from a broadcast that never confirms | `test_confirmation.py::test_bet_confirms_after_an_rbf_bump_changed_the_txid`; `test_broadcast.py::test_bump_fee_retargets_every_stored_txid` | TX, BET | +| B-03 | Un solo txid irrisolvibile interrompeva il rilevamento delle conferme per tutte le altre transazioni | Critica | `d528c5b` | `test_confirmation.py::test_one_unresolvable_candidate_does_not_block_the_others` | TX | +| B-04 | Nessuna riconciliazione: una transazione scartata dalla rete congelava per sempre le monete spese e i fondi dell'utente | Critica | `cc88763` Make "one active round" a DB invariant and record why a tx failed; `d528c5b` | `test_reconcile.py` | TX | +| B-05 | `fee_address` e parametri numerici della configurazione non validati: un valore errato bloccava il pagamento o mandava la commissione a uno script non spendibile | Critica | `daf66fd` Fix the round-open race, the advertised jackpot, and payout error handling; `85dce22` Validate admin config and registration input, and log config changes | `test_admin.py::test_config_rejects_unusable_values` | API, RND | +| B-06 | Venivano create uscite di resto sotto la soglia di polvere, rendendo la transazione non inoltrabile | Alta | `b4d7038` Leave dust-sized change to the fee instead of creating it | `test_psbt_builder.py::test_a_below_dust_change_output_is_never_created` | BET, WDR | +| B-07 | Un invio rifiutato dalla rete arrivava al client come HTTP 500, fuori dal contratto d'errore strutturato | Alta | `d528c5b` | `test_bets.py::test_failed_broadcast_reports_the_broadcast_failed_code`; `test_withdrawals.py::test_failed_broadcast_marks_the_withdrawal_failed_and_frees_the_coins` | BET, WDR, API | +| B-08 | La scommessa veniva inviata prima di essere salvata: un errore al commit perdeva il denaro senza lasciare traccia | Alta | `d528c5b` | `test_bets.py::test_bet_is_persisted_before_it_is_broadcast`, `::test_failed_broadcast_leaves_nothing_behind` | BET, TX | +| B-09 | Una corsa permetteva due round `open` contemporanei, che bloccavano per sempre i round successivi | Alta | `cc88763`; `daf66fd` | `test_rounds_service.py::test_losing_the_open_race_reuses_the_winning_round`, `::test_the_database_refuses_a_second_active_round` | RND, DAT | +| B-10 | `PUT /admin/config` non scriveva nulla nel registro di audit | Alta | `85dce22` | `test_admin.py::test_config_update_is_audit_logged`, `::test_pause_cannot_be_toggled_through_the_config_endpoint` | AUD | +| B-11 | Il montepremi mostrato non era l'importo che il vincitore avrebbe ricevuto | Alta | `daf66fd` | `test_rounds_route.py::test_jackpot_comes_from_the_participants_actual_bets` | RND, API | +| B-12 | La registrazione non validava né username né password, a differenza del cambio password | Alta | `85dce22`; `dc4d576` Format amounts, guard the loading state, and translate the new errors | `test_users.py::test_register_rejects_weak_credentials` | REG, SEC | +| B-13 | `verify_password` trasformava un hash salvato malformato in un 500 invece che in un 401 | Media | `85dce22` | `test_security.py::test_verify_password_returns_false_for_an_unparseable_hash` | SEC | +| B-14 | Token admin confrontato con `!=` invece che a tempo costante | Media | `85dce22` | coperto dai test di autenticazione admin esistenti (`test_admin.py::test_admin_rejects_wrong_token`); nessun marcatore B-14 nel codice | SEC | +| B-15 | `JWT_SECRET` o `XPRV_ENCRYPTION_KEY` vuoti non venivano rifiutati all'avvio | Media | `25f4a1c` Refuse to start half-configured, and keep failures machine-readable | `test_config.py` | SEC | +| B-16 | `GET /guida` rispondeva 500 in Docker perché `docs/` non era copiata nell'immagine | Media | `25f4a1c` (crash eliminato, 404 `guide_unavailable`); superato da `447bbba`, che serve un segnaposto statico `app/static/guida.html` | *manuale* (dipende dal layout dell'immagine); nessun marcatore B-16 nel codice | ARC, OPS | +| B-17 | La ricerca dell'uscita di resto nell'RBF poteva ridurre l'uscita del destinatario invece del resto (prelievo verso il proprio indirizzo) | Media | `d528c5b` | `test_withdrawals.py::test_withdrawal_to_own_address_is_rejected` | WDR, TX | +| B-18 | Sessione del database tenuta aperta durante le chiamate di rete del pagamento | Media | `daf66fd` | coperto dai test dello scheduler esistenti (`test_scheduler.py`) | RND, TX | +| B-19 | `tip_height` assegnato senza controllo di monotonia (reorg o cambio server lo facevano regredire) | Media | `7c4e998` | `test_electrum_listener.py::test_tip_never_moves_backwards` | ELC | +| B-20 | `PendingTransaction.replaced_by_txid` mai scritto: colonna morta mostrata in `/admin` | Bassa | `d528c5b` | `test_broadcast.py::test_bump_fee_retargets_every_stored_txid` | TX, DAT | +| B-21 | Variabile locale inutilizzata e accesso ad attributi di oggetti ORM staccati dalla sessione nel poller delle conferme | Bassa | `d528c5b` | coperto da `test_confirmation.py` | TX | +| B-22 | Importi mostrati con una divisione in virgola mobile: artefatti visibili agli utenti | Bassa | `dc4d576` | *manuale* (resa nel browser) | ARC | +| B-23 | Due `withLoading` concorrenti sullo stesso pulsante potevano lasciarlo bloccato sull'etichetta di caricamento | Bassa | `dc4d576` | *manuale* (interazione nel browser) | ARC | +| B-24 | Il gestore globale delle eccezioni rispondeva con un `detail` stringa invece che strutturato | Bassa | `25f4a1c` | `test_rounds_route.py::test_unhandled_errors_use_the_structured_detail_shape` | API | + +Note: + +- **B-09** fu corretto diversamente dal piano: invece di rendere lo scheduler + l'unico a poter aprire un round, entrambi i chiamanti restano e il conflitto + sull'indice unico viene recuperato con un numero limitato di tentativi. +- **B-15** fu corretto con una validazione all'avvio (`validate_runtime_secrets` + chiamata dal lifespan) invece che con un validatore Pydantic, per non + rendere ogni import e l'intera suite di test dipendenti da un `.env`. +- **B-16** era stato dichiarato «parzialmente corretto per scelta»: la copia di + `docs/` nell'immagine fu scritta e poi ritirata su richiesta del + proprietario. Oggi `/guida` serve un segnaposto (vedi + [decisioni-e-limiti.md](decisioni-e-limiti.md)). + +--- + +## Audit del 2026-07-27 (B-25 … B-49) + +Secondo audit completo: 25 difetti (4 critici, 6 alti, 7 medi, 8 bassi), con +139 test verdi al momento dell'audit. Corretti nel ramo `audit-2026-07-27`, +unito con `5cfe2d6`; ognuno ha il proprio commit. La suite passò da 139 a 253 +test. + +L'osservazione finale di quell'audit resta valida: il codice era rigoroso sui +guasti già capitati e silenzioso su quelli mai visti. + +| B-nn | Difetto | Gravità | Commit | Test di regressione | Area | +|---|---|---|---|---|---| +| B-25 | Il pagamento non aveva la scrittura in due fasi (salvare prima, inviare poi) che avevano scommesse e prelievi | Critica | `f13f685` Persist the payout before broadcasting it | `test_scheduler.py::test_trigger_payout_persists_before_broadcasting`, `::test_trigger_payout_broadcast_failure_leaves_a_recoverable_row` | RND, TX | +| B-26 | Un errore transitorio al momento del pagamento, o un riavvio, lasciava il round in `paying_out` per sempre | Critica | `50a43ae` Retry a stuck payout automatically, and log every failure | `test_scheduler.py::test_tick_retries_a_stuck_paying_out_round_with_no_recent_failure`, `::test_trigger_payout_logs_a_failure_when_not_connected` | RND | +| B-27 | Ogni RBF azzerava l'orologio di abbandono del riconciliatore, che quindi non scattava mai | Critica | `933760e` Decouple the RBF abandon clock from the bump clock | `test_reconcile.py::test_abandons_a_repeatedly_bumped_tx_despite_a_recent_last_broadcast`; `test_broadcast.py::test_should_bump_measures_from_last_broadcast_not_first` | TX | +| B-28 | Un server Electrum ostile (o un attacco man-in-the-middle) poteva scegliere da solo il vincitore del round | Critica | `0ce0562` Validate Electrum headers and corroborate the draw's block | `test_electrum_listener.py::test_apply_header_rejects_one_that_fails_its_own_pow_target`; `test_scheduler.py::test_wait_for_next_block_retries_after_a_failed_corroboration`; `test_draw.py::test_header_meets_its_own_target_accepts_a_real_mined_header` | ELC, RND | +| B-29 | Una moneta assente da un solo `listunspent` veniva segnata come spesa all'esterno subito e irreversibilmente | Alta | `e8fdea0` Corroborate an external spend before marking a UTXO gone | `test_electrum_listener.py::test_refresh_user_does_not_mark_when_corroboration_fails`; `test_deposits.py::test_find_utxos_missing_from_skips_a_totally_empty_response` | DEP, ELC | +| B-30 | Nessuna riconciliazione dei depositi: una sottoscrizione persa significava depositi mai accreditati | Alta | `63df38d` Add a periodic deposit reconciler, and stop losing subscribe tasks | `test_deposit_reconcile.py`; `test_electrum_listener.py::test_address_for_new_user_retains_and_logs_a_failed_subscribe_task` | DEP | +| B-31 | Alla riconnessione la risottoscrizione di tutti gli utenti era seriale e bloccava l'altezza della catena, e con essa un'estrazione in corso | Alta | `12df041` Resubscribe concurrently and in the background on reconnect | `test_electrum_listener.py::test_subscribe_all_users_bounds_concurrency`, `::test_run_once_keeps_consuming_headers_while_resubscribing` | ELC | +| B-32 | `bump_fee` poteva ritentare per sempre sotto il minimo di commissione imposto da BIP125, senza tetto al fee rate | Alta | `17c557b` Meet BIP125's relay minimum on every RBF bump, and cap the fee rate | `test_broadcast.py::test_bump_fee_meets_bip125_minimum_when_old_fee_already_exceeds_target`, `::test_bump_fee_refuses_once_at_the_max_fee_rate` | TX | +| B-33 | Nessuna protezione dai tentativi ripetuti sul login di un portafoglio custodiale | Alta | `16802ca` Throttle login and registration with exponential backoff | `test_auth.py::test_login_locks_out_after_repeated_failures`, `::test_login_failures_against_one_account_do_not_lock_out_another` | SEC | +| B-34 | Cambio password e reset admin non invalidavano le sessioni già emesse | Alta | `739fc9f` Invalidate existing sessions on password change/reset | `test_users.py::test_change_password_invalidates_the_old_token_but_not_the_new_one`; `test_admin.py::test_admin_resets_user_password`; `test_security.py::test_jwt_decode_defaults_token_version_for_tokens_issued_before_it_existed` | SEC | +| B-35 | Ogni orario dell'API era senza fuso, e il frontend lo interpretava come ora locale | Media | `bb8b712` Stamp UTC on naive API timestamps before serializing | `test_api_timeutil.py::test_naive_datetime_is_stamped_utc`; `test_users.py::test_me_created_at_is_utc_stamped` | API | +| B-36 | L'attesa del blocco per l'estrazione non aveva né timeout né visibilità | Media | `7fa26df` Make a stalled draw wait observable | `test_scheduler.py::test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold`; `test_rounds_route.py::test_draw_waiting_since_is_exposed_only_while_drawing` | RND, AUD | +| B-37 | Saldo mostrato e saldo spendibile divergevano, e l'errore «saldo insufficiente» non lo spiegava | Media | `0b44fe6` Distinguish a pending-only balance from a truly insufficient one | `test_withdrawals.py::test_withdrawal_distinguishes_pending_from_truly_insufficient_balance` | WDR, DEP | +| B-38 | Il tetto globale di 500 abbonati SSE era un modo gratuito per negare il canale in tempo reale a tutti | Media | `702b37b` Cap SSE subscribers per client IP instead of only globally | `test_round_events.py::test_subscribe_evicts_the_same_ips_oldest_connection_past_its_cap` | SEC, ARC | +| B-39 | SQLite senza WAL né `busy_timeout`, con più attività che scrivono in parallelo | Media | `97545ad` Run SQLite in WAL mode with a busy_timeout | `test_db_base.py` | ARC, DAT | +| B-40 | `bump_fee` teneva aperta una sessione del database durante N chiamate di rete, e calcolava gli importi da un valore in virgola mobile | Media | `08c566d` Restructure bump_fee into three phases, drop float fee math | `test_broadcast.py::test_bump_fee_holds_no_session_open_during_network_calls` | TX | +| B-41 | Conferme e riconciliazione dipendevano da `blockchain.transaction.get` in modalità verbose, rifiutata da molti server, e l'abbandono si basava sul confronto testuale del messaggio d'errore | Media | `4124dc0` Check confirmation/existence via scripthash history, not verbose replies | `test_confirmation.py::test_one_unresolvable_candidate_does_not_block_the_others`; `test_reconcile.py`; `test_pending_address.py` | TX, ELC | +| B-42 | `/docs`, ReDoc e lo schema OpenAPI esposti in produzione, endpoint admin compresi | Bassa | `22e3cfb` Disable Swagger/ReDoc/OpenAPI JSON by default | `test_docs_disabled.py` | SEC | +| B-43 | Nessun header di sicurezza HTTP (CSP, HSTS, `X-Frame-Options`…) | Bassa | `d60da11` Add baseline HTTP security headers in Caddy | `test_caddyfile_security_headers.py` | SEC | +| B-44 | README e `docs/running-the-server.md` documentavano un `uvicorn --reload` diretto, contro la regola «solo Docker» | Bassa | `a574db0` Align README and running-the-server.md with the Docker-only policy | `test_docs_no_bare_uvicorn.py` | OPS | +| B-45 | Endpoint admin di elenco senza limiti validati né paginazione | Bassa | `6045c89` Bound admin list endpoint limits, add status filter to pending-transactions | `test_admin.py::test_admin_list_endpoints_reject_out_of_range_limit`, `::test_admin_pending_transactions_status_filter` | API | +| B-46 | `secrets.compare_digest` su una stringa con caratteri non ASCII sollevava `TypeError` (500 invece di 403) | Bassa | `31bc9a3` Compare admin token as UTF-8 bytes to avoid TypeError on non-ASCII input | `test_admin.py::test_admin_rejects_non_ascii_token_with_403_not_500` | SEC | +| B-47 | Colonne `String` senza lunghezza per testi lunghi (`raw_tx_hex`, `payload_json`) | Bassa | `6a90136` Widen raw_tx_hex and payload_json from String to Text | `test_models_text_columns.py` | DAT | +| B-48 | Nessun limite al numero di monete in ingresso: un indirizzo frammentato erodeva la propria scommessa o il proprio prelievo in commissioni | Bassa | `4c80c1c` Cap the number of inputs a transaction may spend | `test_psbt_builder.py::test_select_utxos_never_exceeds_the_input_cap`; `test_bets.py::test_place_bet_reports_a_too_fragmented_balance_distinctly` | BET, WDR | +| B-49 | I percorsi di annullamento di scommessa e prelievo non pubblicavano un aggiornamento SSE | Bassa | `e7f844b` Publish an SSE update from the bet/withdrawal rollback paths | `test_bets.py::test_failed_broadcast_publishes_an_sse_update`; `test_withdrawals.py::test_failed_broadcast_publishes_an_sse_update` | BET, WDR, ARC | + +--- + +## Fuori audit (B-50, B-51) + +Trovati il 2026-07-31 osservando il sistema in funzione, fra il secondo e il +terzo audit; non sono mai comparsi in un `BUGS.md` e la gravità non è stata +registrata. Il terzo audit riparte «dall'ultimo difetto corretto, B-51». + +| B-nn | Difetto | Gravità | Commit | Test di regressione | Area | +|---|---|---|---|---|---| +| B-50 | Il banner di vincita appariva prima che `winner_amount_sats` fosse noto, mostrando «Hai vinto! +— PLM» | — | `9207bbc` Don't reveal the win banner before winner_amount_sats is known | *manuale* (solo `app/static/app.js`); nessun marcatore B-50 nel codice | RND, ARC | +| B-51 | Il saldo in attesa contava due volte il resto di una scommessa o di un prelievo già accreditato dal listener | — | `fe909be` Don't double-count a bet/withdrawal's own change in pending balance | `test_balance.py::test_pending_balance_does_not_double_count_change_already_credited` (senza marcatore B-51) | DEP | + +--- + +## Audit del 2026-08-03 (B-52 … B-72) + +Terzo audit completo (264 test verdi al momento dell'audit), aperto con +`99d7a1e` nel ramo `audit-2026-08-03`, poi confluito in `main`. Ogni difetto è +stato rimosso da `BUGS.md` nel suo stesso commit di correzione. Due voci +restano aperte. + +L'audit ricorda anche un problema **non numerato**, perché già elencato fra i +limiti accettati: lo stato `drawing` non riprende dopo un riavvio (vedi +[decisioni-e-limiti.md](decisioni-e-limiti.md)). + +| B-nn | Difetto | Gravità | Commit | Test di regressione | Area | +|---|---|---|---|---|---| +| B-52 | Un round con più di ~50 partecipanti bloccava la piattaforma per sempre: il pagamento usava il limite di 50 monete in ingresso pensato per le transazioni utente | Critica | `025754c` Cap participants per round and give the payout its own input limit | `test_bets.py::test_place_bet_rejects_the_bet_past_the_participant_cap`; `test_payout_builder.py::test_payout_spends_more_utxos_than_a_user_transaction_may`; `test_scheduler.py::test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_inputs` | RND, BET | +| B-53 | Una scommessa poteva finire nel montepremi ed essere comunque esclusa dall'estrazione | Critica | `64f6229` Close the window where a bet pays into a round it was left out of | `test_bets.py::test_place_bet_refuses_when_the_round_closed_between_the_check_and_the_commit`, `::test_place_bet_rechecks_the_deadline_after_building_the_transaction`; `test_scheduler.py::test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check` | BET, RND | +| B-54 | `X-Forwarded-For` accettato alla cieca: ogni controllo basato sull'IP era aggirabile | Alta | `907e32e` Make X-Forwarded-For trustworthy instead of attacker-controlled | `test_client_ip.py::test_client_ip_takes_the_last_hop_of_a_forwarded_chain`, `::test_client_ip_ignores_a_client_supplied_prefix`; `test_caddyfile_security_headers.py::test_forwarded_for_is_overwritten_with_the_real_peer` | SEC | +| B-55 | L'hashing Argon2 girava sull'event loop, bloccando l'intero processo a ogni login | Alta | `9c7befe` Hash and verify passwords off the event loop | `test_security.py::test_hash_password_async_keeps_the_event_loop_free`, `::test_verify_password_async_keeps_the_event_loop_free` | SEC | +| B-56 | Il dizionario dei contatori del limitatore di tentativi non veniva mai sfoltito | Alta | `ab65728` Bound the rate limiter's bucket dict | `test_rate_limit.py::test_the_dict_is_capped_even_within_one_sweep_interval`, `::test_spent_buckets_are_swept_on_the_next_failure` | SEC | +| B-57 | Username confrontati distinguendo maiuscole e minuscole, mentre la chiave del limitatore no | Alta | `5772135` Make usernames one case-insensitive namespace | `test_auth.py::test_registration_refuses_a_username_differing_only_by_case`, `::test_login_accepts_the_username_in_any_case`, `::test_the_database_itself_rejects_a_case_variant` | REG, SEC | +| B-58 | Il limite sulle registrazioni contava i successi come fallimenti ed era solo per IP | Alta | `0aac73e` Bound registrations with a per-IP quota instead of failure backoff | `test_auth.py::test_registration_is_quota_limited_per_ip`, `::test_failed_registrations_do_not_consume_the_quota` | SEC, REG | +| B-59 | L'accredito dei depositi non era corroborato dagli altri server, a differenza del rilevamento delle spese esterne | Alta | `6246b13` Corroborate deposit credits, not just external spends | `test_electrum_listener.py::test_refresh_user_does_not_credit_a_utxo_only_our_own_server_reports`, `::test_refresh_user_credits_once_the_others_corroborate` | DEP, ELC | +| B-60 | `POST /admin/bug-reports/{id}/status` non scriveva nulla nel registro di audit | Alta | `77e07e8` Audit-log bug report status changes | `test_bug_reports.py::test_status_change_is_audit_logged`, `::test_setting_the_status_it_already_has_logs_nothing` | AUD | +| B-61 | Le modifiche alla configurazione si applicavano retroattivamente al round in corso | Media | `37cc5ee` Snapshot a round's timing when it opens | `test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`; `test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing` | RND | +| B-62 | «Preleva tutto» produceva sempre una transazione senza resto, quindi impossibile da accelerare con RBF | Media | `8dd913e` Always keep a change output, so every tx stays fee-bumpable | `test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`; `test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output` | TX, WDR, BET | +| B-63 | Subito dopo la connessione, con `tip_height == 0`, un'estrazione poteva usare un blocco precedente alla chiusura | Media | `8a0ebec` Never let a draw be seeded by a block that predates the close | `test_scheduler.py::test_wait_for_next_block_never_seeds_the_draw_from_a_pre_close_block`; `test_electrum_listener.py::test_run_once_publishes_the_client_only_once_the_tip_is_known` | RND, ELC | +| B-64 | `_apply_header` accettava un header alla stessa altezza senza controllo di concatenazione | Media | `c0314e2` Never swap the tip's hash sideways, and never split it from its height | `test_electrum_listener.py::test_apply_header_ignores_a_competing_header_at_the_current_tip_height`, `::test_apply_header_ignores_one_carrying_no_hex` | ELC | +| B-65 | `/rounds/current` contava i partecipanti non confermati, che estrazione e pagamento invece escludono | Media | `c4b2dc3` Advertise the jackpot that will actually be paid | `test_rounds_route.py::test_advertised_jackpot_covers_only_the_bets_that_will_be_paid` | RND, API | +| B-66 | Nulla impediva di aprire round senza `fee_address` configurato, rendendoli impagabili | Media | `23d5879` Refuse to open a round that could not pay its winner | `test_rounds_service.py::test_withholds_new_round_while_no_fee_address_is_configured`, `::test_a_round_in_progress_survives_the_fee_address_being_cleared`; `test_rounds_route.py::test_lottery_configured_flags_a_missing_fee_address` | RND | +| B-67 | `/qr/{address}` non autenticato, sincrono e validato solo nella forma | Media | `5f6abe5` Validate and bound the QR endpoint | `test_qr.py` | API, SEC | +| B-68 | CLAUDE.md e README descrivevano uno stato che il codice aveva superato | Bassa | `162ceed` Describe the code the docs actually ship with | `test_docs_current.py` | ARC | +| B-69 | Commenti nel codice non più veri | Bassa | `666cb1a` Retire in-code comments that outlived what they described | `test_code_comments.py` | ARC | +| B-70 | Il diagramma diceva che prelievo e scommessa in corso non potevano coesistere, ma il codice non lo impone (e non deve) | Bassa | `526a649` Say what bets and withdrawals actually exclude | `test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed`, `::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive` | WDR | +| B-71 | `.env` punta `MASTER_KEY_PATH` a una seconda copia della chiave master (e `DATABASE_URL` a un secondo database) invece che ai file in `./data/` letti dal container | Bassa | **aperto** | — | OPS, SEC | +| B-72 | `docs/setup.md` presenta ancora l'installazione come «in locale o via Docker», contro la regola «solo Docker» | Bassa | **aperto** | — | OPS | + +Note: + +- `8f3cdcb` e `421fe72` sono commit di sola contabilità di `BUGS.md` (registrano + gli hash delle correzioni di B-53 e B-54), non correzioni. +- Il test di B-70 sulla documentazione legge la sezione «Scommessa e prelievo + insieme» di [scommesse-e-prelievi.md](scommesse-e-prelievi.md): quel titolo e + le frasi che il test cerca vanno mantenuti. + +## Stato attuale + +- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il + crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera + come limite accettato. +- **Aperti:** B-71, B-72 (entrambi di gravità bassa, entrambi operativi o di + documentazione). +- **Prossimo numero libero:** `B-73`. diff --git a/docs/transazioni-e-riconciliazione.md b/docs/transazioni-e-riconciliazione.md new file mode 100644 index 0000000..21aa5d9 --- /dev/null +++ b/docs/transazioni-e-riconciliazione.md @@ -0,0 +1,230 @@ +# Transazioni e riconciliazione + +Come ogni movimento di denaro — scommessa, prelievo, pagamento del round — +viene registrato, inviato, accelerato e infine confrontato con la catena. +L'obiettivo è che il sistema si rimetta in ordine da solo dopo un crash o un +problema di rete, senza interventi manuali sul database. + +I requisiti usano il prefisso `TX`, secondo la convenzione di +[processo-di-modifica.md](processo-di-modifica.md). Le colonne della tabella +`pending_transactions` sono descritte in [modello-dati.md](modello-dati.md), +gli eventi di audit in [audit-log.md](audit-log.md), le attività in +background e le loro cadenze in [architettura.md](architettura.md). + +## Il principio: prima si scrive, poi si invia + +Nessuna transazione viene inviata alla rete senza che il database ne abbia già +traccia. Ogni transazione in uscita ha una riga `PendingTransaction` che +attraversa questi stati: + +```mermaid +stateDiagram-v2 + [*] --> building: transazione firmata,
monete segnate come spese,
salvato PRIMA dell'invio + building --> pending: la rete accetta l'invio + building --> [*]: scommessa rifiutata dalla rete
(riga cancellata, tutto annullato) + building --> failed: prelievo rifiutato dalla rete
(tutto annullato) + building --> pending: il riconciliatore la trova
in rete (dopo 120s) + building --> failed: il riconciliatore non la
trova in rete (dopo 120s) + pending --> pending: nessuna conferma
entro il tempo limite → RBF + pending --> confirmed: 1 conferma + pending --> failed: il riconciliatore non la
trova in rete (dopo 6h dal primo invio) + confirmed --> [*] + failed --> [*] +``` + +- **[TX-01]** Ogni transazione in uscita ha una riga `PendingTransaction` di + tipo `bet`, `withdrawal` o `payout`, con stato `building`, `pending`, + `confirmed` o `failed`. La riga (stato `building`, con la transazione + firmata completa) viene salvata e committata **prima** dell'invio, nella + stessa transazione del database in cui le monete spese vengono segnate come + tali e si registra l'oggetto collegato (partecipante, prelievo, dati di + pagamento del round). + *Codice:* `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · + *Test:* `tests/unit/test_bets.py`, `tests/unit/test_scheduler.py` +- **[TX-02]** Il database non viene mai tenuto aperto durante una chiamata di + rete: la scrittura di `building` è committata, poi si invia, poi si aggiorna + (in una nuova sessione per il pagamento). Se il processo muore proprio in + mezzo, resta la prova di cosa stava succedendo, non monete spese in catena + senza alcun record. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/tx/broadcast.py:bump_fee` · + *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py` +- **[TX-03]** `broadcast_at` è l'orario in cui la riga è stata scritta, cioè + del **primo** invio, e non viene mai riscritto; `last_broadcast_at` è + quello dell'ultimo invio, ed è l'unico che l'RBF aggiorna. + *Codice:* `app/db/models.py:PendingTransaction`, `app/tx/broadcast.py:bump_fee` · + *Test:* `tests/unit/test_broadcast.py` + +### Invio rifiutato + +- **[TX-04]** Se l'invio di una scommessa o di un prelievo fallisce, tutto + torna com'era: monete liberate, saldo ripristinato, evento scritto nel + registro di audit (`bet_broadcast_failed`, `withdrawal_broadcast_failed`), + notifica SSE. Per una scommessa partecipante e riga vengono **cancellati**; + per un prelievo prelievo e riga restano, in stato `failed`. L'utente riceve + `broadcast_failed` con codice HTTP **502**, perché è stata la rete a + rifiutare, non la sua richiesta a essere sbagliata. Qualunque errore + dell'invio è trattato così, anche la scadenza del tempo di risposta. + *Codice:* `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal` · + *Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py` +- **[TX-05]** Se l'invio di un pagamento fallisce, la riga resta `building` + e si scrive `payout_failed` (`broadcast rejected`): è il riconciliatore a + decidere, interrogando la rete, se la transazione è partita o no + ([TX-16], [TX-17]). + *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · + *Test:* `tests/unit/test_scheduler.py` + +## Conferma + +- **[TX-06]** `ConfirmationPoller` controlla ogni 10 secondi le transazioni + in stato `pending` (non quelle `building`), e salta il giro se non c'è + connessione. Per ciascuna legge la cronologia + (`blockchain.scripthash.get_history`) dell'indirizzo che la spende — + l'indirizzo dell'utente, o quello del montepremi per un pagamento — e la + considera confermata quando il suo txid corrente vi compare con altezza + maggiore di 0. La cronologia è letta una volta per indirizzo a ogni giro; + un indirizzo che non si riesce a leggere non blocca gli altri. + *Codice:* `app/tx/confirmation.py:poll_once`, `app/tx/pending_address.py:own_address_for` · + *Test:* `tests/unit/test_confirmation.py` +- **[TX-07]** Alla prima conferma la riga passa a `confirmed` e, nella stessa + transazione del database, viene chiamato il gestore del suo tipo; poi si + invia una notifica SSE: + + | Tipo | Cosa succede alla conferma | + |---|---| + | Scommessa | Il partecipante (se `building` o `broadcast`) diventa `confirmed`, con `confirmed_at`, ed entra nell'estrazione | + | Prelievo | Il prelievo (se `building` o `broadcast`) diventa `confirmed`, con `confirmed_at` | + | Pagamento | Il round (se `paying_out`) passa a `closed`, `closed_at` diventa l'orario della conferma e parte la pausa prima del successivo | + + *Codice:* `app/tx/confirmation.py:poll_once`, `app/bets/confirmation.py:_on_bet_confirmed`, `app/withdrawals/confirmation.py:_on_withdrawal_confirmed`, `app/rounds/confirmation.py:_on_payout_confirmed` · + *Test:* `tests/unit/test_confirmation.py` +- **[TX-08]** I gestori ritrovano l'oggetto a cui la transazione si riferisce + tramite identificativi fissi (round e utente per la scommessa, prelievo, + round per il pagamento), **mai principalmente tramite il txid**, perché il + txid cambia a ogni RBF; il txid serve solo come ripiego per righe prive di + quei collegamenti. + *Codice:* `app/bets/confirmation.py:_on_bet_confirmed`, `app/rounds/confirmation.py:_on_payout_confirmed` · + *Test:* `tests/unit/test_confirmation.py` + +I gestori sono registrati all'avvio importando i moduli `confirmation` di +scommesse, round e prelievi (vedi [architettura.md](architettura.md)). + +## RBF: accelerare una transazione lenta + +Se una transazione resta senza conferma oltre `rbf_timeout_seconds` (900s +predefiniti), `RbfBumper` la ricostruisce con una commissione più alta e la +reinvia (Replace-By-Fee). Tutte le transazioni della piattaforma segnalano la +sostituibilità fin dal primo invio. + +- **[TX-09]** `RbfBumper` controlla ogni 30 secondi le righe in stato + `pending` (salta il giro senza connessione) e accelera quelle il cui + `last_broadcast_at` è più vecchio di `rbf_timeout_seconds`: dopo ogni + accelerazione la transazione ha di nuovo l'intero tempo limite. Un errore + su una riga viene registrato nei log e non ferma le altre. + *Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:should_bump` · + *Test:* `tests/unit/test_broadcast.py` +- **[TX-10]** La nuova commissione punta a 1 sat/vB in più della tariffa + registrata (`_FEE_RATE_INCREMENT`), senza superare 10.000 sat/vB + (`MAX_FEE_RATE_SAT_VB`); l'aumento assoluto non è mai inferiore alla + dimensione stimata della transazione per 1 sat/vB, il minimo richiesto da + BIP125 per una sostituzione. Una riga già a 10.000 sat/vB non viene più + accelerata. Gli importi delle monete spese sono letti dalle transazioni + precedenti come interi in satoshi. + *Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_prevout_amount` · + *Test:* `tests/unit/test_broadcast.py` +- **[TX-11]** **Paga chi riceve il resto**: la sostituzione spende le stesse + monete e riduce del solo aumento l'uscita di resto verso il mittente — + l'utente per scommesse e prelievi, il montepremi per i pagamenti. Le uscite + verso la controparte (montepremi, indirizzo esterno, vincitore, indirizzo + commissioni) non vengono mai toccate. Se non c'è un'uscita di resto verso il + mittente, o non supera l'aumento, l'accelerazione non è possibile: l'errore + viene registrato nei log, la transazione resta in attesa (il tentativo si + ripete a ogni giro) e prima o poi viene abbandonata dal riconciliatore, che + ne libera le monete. + *Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_find_change_output` · + *Test:* `tests/unit/test_broadcast.py` +- **[TX-12]** Dopo l'invio della sostituzione la riga registra il nuovo txid + (`current_txid`), la nuova transazione (`raw_tx_hex`), il txid sostituito + (`replaced_by_txid`), la tariffa effettivamente pagata, il numero di + tentativi e `last_broadcast_at`. Il nuovo txid viene propagato ovunque sia + registrato: monete spese (`spent_txid`), partecipante (`bet_txid`), + prelievo (`txid`), round (`payout_txid`). + *Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_retarget_txid_references` · + *Test:* `tests/unit/test_broadcast.py` +- **[TX-13]** Se al momento dell'accelerazione la riga non esiste più o non è + più `pending` (ad esempio è appena stata confermata), non si fa nulla. + *Codice:* `app/tx/broadcast.py:bump_fee` · + *Test:* `tests/unit/test_broadcast.py` + +Scommesse e prelievi nascono sempre con un'uscita di resto di almeno 294 sat +proprio perché l'RBF abbia qualcosa da ridurre +([scommesse-e-prelievi.md](scommesse-e-prelievi.md)). Il pagamento del round +invece ha un resto verso il montepremi solo quando le monete spese superano il +montepremi di almeno 294 sat: senza resto non è accelerabile. + +## Riconciliazione + +- **[TX-14]** `PendingTransactionReconciler` gira all'avvio e poi ogni 120 + secondi (salta il giro senza connessione), e considera ogni transazione + ancora `building` o `pending` trascorso il suo periodo di tolleranza, + misurato da `broadcast_at` (il primo invio, che l'RBF non sposta): 120s per + `building`, 6 ore per `pending`, così l'RBF ha il tempo di fare i suoi + tentativi. + *Codice:* `app/tx/reconcile.py:PendingTransactionReconciler.run`, `app/tx/reconcile.py:_is_due` · + *Test:* `tests/unit/test_reconcile.py` +- **[TX-15]** L'esistenza di una transazione si decide cercando il suo txid + corrente nella cronologia (`blockchain.scripthash.get_history`) + dell'indirizzo che la spende, in mempool o in un blocco. Un **errore di + rete** non porta a nulla: la riga resta com'è fino al giro successivo. Solo + un server che risponde con una cronologia priva del txid porta + all'abbandono, mai un server irraggiungibile. + *Codice:* `app/tx/reconcile.py:reconcile_once` · + *Test:* `tests/unit/test_reconcile.py` +- **[TX-16]** **Presente in rete**: una riga `building` viene promossa a + `pending` (e partecipante o prelievo da `building` a `broadcast`), con un + evento `pending_tx_recovered`; 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` +- **[TX-17]** **Sconosciuta alla rete**: la riga passa a `failed` con motivo + `not found on chain`; le monete che risultano ancora impegnate da *questa* + transazione vengono liberate (non quelle già spese da altro); il + partecipante viene rimosso, o il prelievo segnato `failed` senza txid; il + saldo dell'utente viene ricalcolato; si scrive `pending_tx_abandoned` + (con il numero di monete liberate) e si invia una notifica SSE. + *Codice:* `app/tx/reconcile.py:_abandon`, `app/tx/reconcile.py:_release_inputs` · + *Test:* `tests/unit/test_reconcile.py` + +È questo che rende finita l'attesa del round in `closing`: una scommessa che +non confermerà mai viene prima o poi abbandonata e rimossa. + +## Pagamenti del round: riprovare senza pagare due volte + +Il pagamento ha bisogno di più protezioni degli altri tipi, perché è +l'operazione che lo scheduler ritenta da solo. Le regole sono in +[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento): +ogni tentativo fallito scrive un evento `payout_failed` con il motivo, così +`/admin` mostra **perché** un round è fermo, e i tentativi sono limitati a +uno ogni 60 secondi usando proprio l'ultimo `payout_failed` come orologio +([RND-25](ciclo-di-vita-del-round.md#4--pagamento)); non si costruisce un +pagamento se ne esiste già uno `building` o `pending` per quel round, e le +monete del montepremi già impegnate da un pagamento non risolto sono escluse +dalla selezione ([RND-26](ciclo-di-vita-del-round.md#4--pagamento)). Senza +queste ultime due, un nuovo tentativo potrebbe pagare il vincitore due volte. + +- **[TX-18]** Un pagamento abbandonato non libera monete utente (quelle del + montepremi non sono registrate): azzera `payout_txid` del round, che resta + in `paying_out` senza transazione, 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` + +## Dove guardare + +Nel pannello `/admin` (vedi [guida-admin.md](guida-admin.md) e +[operativita.md](operativita.md)): + +- **Transazioni pendenti** — tutto ciò che è `building` o `pending`, con + orari di invio e candidati all'RBF; +- **Audit log** — `payout_sent`, `payout_failed`, `pending_tx_recovered`, + `pending_tx_abandoned`, `draw_stalled` e tutti gli altri eventi; +- **Round** — stato di ogni round, vincitore e txid del pagamento. diff --git a/flowchart/platform-overview.mmd b/flowchart/platform-overview.mmd deleted file mode 100644 index 41dbf88..0000000 --- a/flowchart/platform-overview.mmd +++ /dev/null @@ -1,51 +0,0 @@ -flowchart LR - - subgraph REG["FASE 1 - Registrazione"] - direction TB - A1["L'utente si registra\n(username + password)"] --> A2["Il server genera un indirizzo\ndedicato e permanente per l'utente\n(chiave segreta cifrata,\ncustodita dal server)"] - A2 --> A3["L'indirizzo viene collegato\nal profilo utente\n(sara' sia l'indirizzo di deposito\nche quello che ricevera' vincite\ne prelievi)"] - end - - subgraph DEP["FASE 2 - Deposito"] - direction TB - B1["L'utente invia PLM\nal proprio indirizzo dedicato"] --> B2["Il sistema monitora\nl'indirizzo sulla blockchain"] - B2 --> B3{"Transazione\nconfermata?"} - B3 -- "No" --> B2 - B3 -- "Si'" --> B4["Saldo dell'utente\naccreditato nel sistema"] - end - - subgraph PLAY["FASE 3 - Scommessa"] - direction TB - C1{"L'utente vuole\nscommettere?\n(costo fisso, es. 10 PLM;\nal massimo una scommessa\nattiva alla volta)"} - C1 -- "Si', saldo sufficiente" --> C2["Si prepara la transazione:\ndal suo indirizzo verso\nil conto comune del montepremi\n(con resto che torna a lui)"] - C2 --> C3["Transazione firmata\ne inviata alla rete"] - C3 --> C4{"Confermata?"} - C4 -- "No, troppo tempo" --> C5["Si aumenta la commissione\ne si reinvia"] - C5 --> C3 - C4 -- "Si'" --> C6["L'utente e' ufficialmente\npartecipante al round in corso"] - end - - subgraph DRAW["FASE 4 - Round ed estrazione"] - direction TB - D0["(dettaglio completo in\nround-lifecycle.mmd)"] -.-> D1["Il round ha un tempo limite\nper accettare scommesse"] - D1 --> D2["Allo scadere, si aspettano\nle scommesse gia' in corso\ne poi il round si chiude"] - D2 --> D3["Si estrae un vincitore\nin modo casuale e verificabile\n(hash del primo blocco\ndopo la chiusura)"] - D3 --> D4["Il montepremi viene diviso:\n70% al vincitore\n30% alla piattaforma"] - D4 --> D5["Pagamento inviato e confermato\nsulla rete\n(stesso schema di riprova\ncon commissione aumentata\nin caso di ritardo)"] - end - - subgraph WITHDRAW["FASE 5 - Prelievo"] - direction TB - E1["L'utente richiede un prelievo:\nindirizzo esterno + importo\n(spende solo saldo confermato\ne non ancora impegnato: una scommessa\nin attesa di conferma non lo blocca,\nma le due operazioni non vengono\nmai preparate nello stesso momento)"] --> E2["Si prepara e firma la transazione:\ndal suo indirizzo verso\nl'indirizzo esterno indicato\n(con resto che torna a lui)"] - E2 --> E3["Transazione inviata\nalla rete"] - E3 --> E4{"Confermata?"} - E4 -- "No, troppo tempo" --> E5["Si aumenta la commissione\ne si reinvia"] - E5 --> E3 - E4 -- "Si'" --> E6["Saldo dell'utente aggiornato"] - end - - A3 --> B1 - B4 --> C1 - B4 --> E1 - C6 --> D1 - D5 -.->|"round successivo"| C1 diff --git a/flowchart/render-pdf.sh b/flowchart/render-pdf.sh deleted file mode 100755 index d1a04b7..0000000 --- a/flowchart/render-pdf.sh +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env bash -# Regenerates professional-looking A4 and A3 landscape PDFs from a Mermaid -# .mmd flowchart: consistent color theme, legible fonts, a title (read from -# the .mmd's own YAML frontmatter) and a footer with the generation date. -# -# Usage: -# ./render-pdf.sh [path/to/file.mmd] -# -# Defaults to round-lifecycle.mmd in this same directory. -# Produces -A4.pdf and -A3.pdf next to the .mmd file. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -MMD_FILE="${1:-$SCRIPT_DIR/round-lifecycle.mmd}" - -if [[ ! -f "$MMD_FILE" ]]; then - echo "Errore: file non trovato: $MMD_FILE" >&2 - exit 1 -fi - -OUT_DIR="$(cd "$(dirname "$MMD_FILE")" && pwd)" -BASE="$(basename "$MMD_FILE" .mmd)" -SVG_TMP="$OUT_DIR/.${BASE}.tmp.svg" -CONFIG_TMP="$OUT_DIR/.${BASE}.tmp-config.json" - -cleanup() { - rm -f "$SVG_TMP" "$CONFIG_TMP" "$OUT_DIR/.${BASE}.tmp-A4.html" "$OUT_DIR/.${BASE}.tmp-A3.html" -} -trap cleanup EXIT - -# Consistent, print-friendly color theme (indigo nodes/edges, warm amber phase -# clusters, generous font size) instead of mermaid's flat default palette. -cat > "$CONFIG_TMP" <<'EOF' -{ - "theme": "base", - "themeVariables": { - "fontFamily": "\"Segoe UI\", Helvetica, Arial, sans-serif", - "fontSize": "17px", - "primaryColor": "#c9d6f7", - "primaryBorderColor": "#3949ab", - "primaryTextColor": "#1a1a2e", - "lineColor": "#3949ab", - "secondaryColor": "#fff8e1", - "tertiaryColor": "#ffffff", - "clusterBkg": "#fff8e1", - "clusterBorder": "#c9a227", - "edgeLabelBackground": "#c9d6f7", - "titleColor": "#1a1a2e" - }, - "flowchart": { - "curve": "basis", - "padding": 16, - "htmlLabels": true, - "nodeSpacing": 100, - "rankSpacing": 25 - } -} -EOF - -echo "-> Rendering diagram to SVG..." -npx -y @mermaid-js/mermaid-cli -i "$MMD_FILE" -o "$SVG_TMP" -b white -c "$CONFIG_TMP" - -echo "-> Building print-ready A4/A3 PDFs..." - -# mermaid-cli pulls in puppeteer as a transitive dependency; reuse that install -# instead of adding a separate one just for this script. -PUPPETEER_DIR="$(dirname "$(find "$HOME/.npm/_npx" -maxdepth 3 -type d -name puppeteer 2>/dev/null | head -n1)")" -if [[ -z "$PUPPETEER_DIR" || ! -d "$PUPPETEER_DIR" ]]; then - echo "Errore: modulo puppeteer non trovato (serve mermaid-cli gia' eseguito almeno una volta)." >&2 - exit 1 -fi -export NODE_PATH="$PUPPETEER_DIR" - -GENERATED_AT="$(date '+%d/%m/%Y %H:%M')" - -# Human title for the header banner; falls back to a prettified filename for -# any .mmd this script hasn't been told about explicitly. -case "$BASE" in - round-lifecycle) TITLE="Ciclo di vita di un round" ;; - platform-overview) TITLE="Flusso completo della piattaforma" ;; - *) TITLE="$(echo "$BASE" | tr '-' ' ' | sed 's/\b\(.\)/\u\1/g')" ;; -esac - -node -e ' -const fs = require("fs"); -const path = require("path"); -const puppeteer = require("puppeteer"); - -const outDir = process.argv[1]; -const base = process.argv[2]; -const svgPath = process.argv[3]; -const generatedAt = process.argv[4]; -const title = process.argv[5]; - -const svg = fs.readFileSync(svgPath, "utf-8"); - -function htmlFor(size) { - return ` - - - -
- PLM Lottery - ${title} -
-
${svg}
- -`; -} - -(async () => { - const browser = await puppeteer.launch({ args: ["--no-sandbox"] }); - const page = await browser.newPage(); - for (const fmt of ["A4", "A3"]) { - const htmlPath = path.join(outDir, `.${base}.tmp-${fmt}.html`); - fs.writeFileSync(htmlPath, htmlFor(fmt)); - await page.goto("file://" + htmlPath, { waitUntil: "networkidle0" }); - const pdfPath = path.join(outDir, `${base}-${fmt}.pdf`); - await page.pdf({ - path: pdfPath, - format: fmt, - landscape: true, - printBackground: true, - margin: { top: "6mm", bottom: "6mm", left: "6mm", right: "6mm" }, - }); - console.log(" " + pdfPath); - } - await browser.close(); -})(); -' "$OUT_DIR" "$BASE" "$SVG_TMP" "$GENERATED_AT" "$TITLE" - -echo "Fatto." diff --git a/flowchart/round-lifecycle.mmd b/flowchart/round-lifecycle.mmd deleted file mode 100644 index ebb06c1..0000000 --- a/flowchart/round-lifecycle.mmd +++ /dev/null @@ -1,50 +0,0 @@ -flowchart LR - - A["Il round precedente\nsi e' chiuso\n(pagamento vincitore confermato)"] --> B{"Lotteria in pausa\nmanutenzione?"} - B -- "Si'" --> B_WAIT["Si attende"] - B_WAIT --> B - B -- "No" --> C["Breve attesa\n'di raffreddamento'\n(cosi' i giocatori vedono\nil risultato precedente)"] - C --> D["Si apre un nuovo round\n(stato: APERTO)\ncon un limite di tempo\nper scommettere"] - - subgraph OPEN["FASE 1 - Round aperto (accetta scommesse)"] - direction TB - D --> F["Un giocatore\npiazza una scommessa"] - F --> G{"E' arrivata prima\ndella scadenza\ndel round?"} - G -- "Si'" --> H["Accettata:\ngiocatore aggiunto\nai partecipanti"] - G -- "No, troppo tardi" --> F2["Rifiutata:\nil round non e' piu'\nin tempo per accettarla"] - H --> F - F2 --> F - end - - D -.->|"scade il tempo"| I - - subgraph CLOSING["FASE 2 - Chiusura"] - direction TB - I["Il round raggiunge la sua scadenza\n(indipendentemente dalle scommesse\ngia' in corso, che restano valide):\nda questo momento nessuna\nnuova scommessa e' accettata"] --> J{"Ci sono scommesse\ngia' inviate ma non\nancora confermate?"} - J -- "Si'" --> J_WAIT["Si attende qualche secondo\ne si ricontrolla"] - J_WAIT --> J - J -- "No, tutte confermate" --> K["Il round si chiude:\nsi fotografa lo stato\nattuale della blockchain"] - end - - subgraph DRAWING["FASE 3 - Estrazione vincitore"] - direction TB - K --> L{"C'e' almeno\nun partecipante?"} - L -- "No" --> M["Round concluso\nsenza vincitore"] - L -- "Si'" --> N["Si attende il primo\nnuovo blocco dopo\nla chiusura del round"] - N --> O["L'hash del blocco\nsceglie il vincitore\nin modo casuale e verificabile\n(stessa probabilita' per tutti)"] - end - - subgraph PAYING["FASE 4 - Pagamento"] - direction TB - O --> P["Si calcola\nil montepremi totale"] - P --> Q["Si divide:\n70% al vincitore\n30% alla piattaforma"] - Q --> R["Si prepara e firma\nla transazione di pagamento"] - R --> S["La transazione\nviene inviata"] - S --> T{"Confermata\nsulla rete?"} - T -- "No, troppo tempo" --> T2["Si aumenta la\ncommissione e si reinvia"] - T2 --> S - T -- "Si'" --> U["Vincitore registrato\nnel registro di controllo"] - end - - U --> V["Round CHIUSO\n(il ciclo ricomincia\ndall'inizio per\nil round successivo)"] - M --> V diff --git a/tests/unit/test_withdrawals.py b/tests/unit/test_withdrawals.py index b00d764..47954e0 100644 --- a/tests/unit/test_withdrawals.py +++ b/tests/unit/test_withdrawals.py @@ -304,7 +304,7 @@ async def test_a_bet_with_a_dust_limit_of_headroom_is_accepted(session_factory): async def test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed(session_factory): - """B-70: the flowchart's WITHDRAW node used to state that a withdrawal cannot + """B-70: the (since removed) flowchart's WITHDRAW node used to state that a withdrawal cannot happen together with a bet in progress. It can, and should: the hazard is the two picking the *same* UTXO, which is already excluded twice over — the per-user lock (app/tx/locks.py) keeps the two builds from ever being in flight at once, and @@ -345,13 +345,13 @@ async def test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed(session_fa assert pending_kinds == {"bet", "withdrawal"} -def test_the_flowchart_no_longer_claims_bets_and_withdrawals_are_exclusive(): +def test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive(): from pathlib import Path - diagram = ( - Path(__file__).resolve().parents[2] / "flowchart" / "platform-overview.mmd" + doc = ( + Path(__file__).resolve().parents[2] / "docs" / "scommesse-e-prelievi.md" ).read_text(encoding="utf-8") - node = [line for line in diagram.splitlines() if line.strip().startswith("E1[")] - assert len(node) == 1 - assert "non puo' avvenire insieme" not in node[0] - assert "saldo confermato" in node[0] + section = doc.split("## Scommessa e prelievo insieme", 1)[1] + assert "non puo' avvenire insieme" not in doc + assert "blocco per-utente" in section + assert "anche mentre una scommessa" in section