# 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` | `/rounds/{round_id}/proof` | nessuna | [API-45](#get-roundsround_idproof) — *da implementare (B-73)* | | `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` | `/admin/electrum` | admin | [API-47](#get-adminelectrum) — *da implementare (B-86, B-92)* | | `GET` | `/health` | nessuna | [API-41](#get-health) | | `GET` | `/health/ready` | nessuna | [API-46](#get-healthready) — *da implementare (B-92)* | | `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`) risponde `401` con codice `not_authenticated`, nel formato strutturato di [API-12], con header `WWW-Authenticate: Bearer`. Il frontend tratta i due codici allo stesso modo: torna alla schermata di login. *Codice:* `app/auth/dependencies.py:get_current_user` · *Test:* `tests/unit/test_users.py`, `tests/unit/test_bug_reports.py` *Stato:* da implementare (B-94), per l'header assente · *Oggi:* l'assenza dell'header è intercettata da FastAPI prima del codice applicativo e risponde con il corpo non strutturato `{"detail": "Not authenticated"}`. - **[API-07]** Autenticazione **facoltativa**: senza header, con un header che non inizia con `Bearer `, con un token invalido o superato, la richiesta è 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 codice `invalid_admin_token`, nel formato strutturato di [API-12]. 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` *Stato:* da implementare (B-94) · *Oggi:* `403` con il corpo non strutturato `{"detail": "invalid admin token"}`. - **[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`; - ogni file servito dal mount dei file statici (`/app.js`, `/style.css`, `/i18n.js`, `/admin.js`, `/admin.css`, `/index.html`…) risponde con `Cache-Control: no-cache`, oltre a `ETag`/`Last-Modified`: il browser può tenerne una copia ma la rivalida a ogni uso, così dopo un aggiornamento del server non esegue un `app.js` vecchio contro l'API nuova; - le risposte JSON non hanno `Cache-Control`. *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`, `app/main.py` (`app.mount`) *Stato:* da implementare (B-94), per i file statici · *Oggi:* i file serviti dal mount non hanno `Cache-Control`, solo `ETag`/`Last-Modified`, e il browser può riusarli senza rivalidarli. - **[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 e, da implementare con B-94, quota per IP di `POST /bug-reports`), 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. `broadcast_failed` si usa **solo** per un rifiuto esplicito del server (un errore JSON-RPC in risposta al broadcast), e significa che tutto è stato annullato. Un esito **ambiguo** del broadcast (timeout, connessione caduta dopo l'invio) non è un errore: la richiesta risponde `202 Accepted` con lo stesso corpo del successo e `status: "building"`, perché la transazione può essere già sulla rete e la riga resta al riconciliatore ([scommesse-e-prelievi.md](scommesse-e-prelievi.md) B-76). Un rifiuto "già noto / già nel mempool" vale come successo (`201`). *Codice:* `app/api/routes/bets.py:create_bet`, `app/api/routes/withdrawals.py:create_withdrawal` · *Test:* `tests/unit/test_bets.py` *Stato:* da implementare (B-76) · *Oggi:* qualunque eccezione del broadcast, compresi il timeout di 15 s e la caduta della connessione, annulla tutto e risponde `502 broadcast_failed`. - **[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 strutturato di [API-12], con codice `invalid_request`, `message` `"invalid request"` e in `params.errors` la lista degli errori di validazione, uno per campo invalido, ciascuno `{"loc", "msg", "type"}` in inglese (anche il JSON malformato arriva così, con `type: "json_invalid"`). Il valore ricevuto (`input`) **non** è ripetuto nella risposta, così una password troppo lunga non torna indietro in chiaro. *Codice:* `app/main.py` (gestore di `RequestValidationError`, da scrivere), `app/static/app.js:apiErrorMessage` · *Test:* `tests/unit/test_users.py`, `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py` *Stato:* da implementare (B-94) · *Oggi:* `422` nel formato **nativo** di FastAPI: `detail` è una **lista** di oggetti `{type, loc, msg, input, ctx?}`, senza `code`; il frontend riconosce la lista e mostra il messaggio generico tradotto `error.invalid_request`. - **[API-16]** **Nessuna** risposta d'errore ha `detail` come semplice stringa: anche gli errori generati dagli endpoint admin, dal flusso SSE e dal framework seguono il formato strutturato di [API-12], con questi codici: | Stato | Codice atteso | Dove | Oggi (`detail` stringa) | |---|---|---|---| | `401` | `not_authenticated` | header `Authorization` assente su un endpoint Bearer ([API-06]) | `"Not authenticated"` | | `403` | `invalid_admin_token` | ogni endpoint `/admin/*` ([API-08]) | `"invalid admin token"` | | `404` | `user_not_found` | `GET /admin/users/{id}/privkey`, `POST /admin/users/{id}/reset-password` | `"user not found"` | | `404` | `bug_report_not_found` | `POST /admin/bug-reports/{id}/status` | `"bug report not found"` | | `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) | `"Not Found"` | | `405` | `method_not_allowed` | metodo diverso da `GET`/`HEAD` su un percorso non definito da un router | `"Method Not Allowed"` | | `503` | `too_many_streams` | `GET /rounds/stream` oltre il tetto globale | `"too many concurrent update streams"` | I `404`/`405` del framework si ottengono con un gestore di `StarletteHTTPException` che traduce lo stato nel codice della tabella. *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`, `app/main.py` · *Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py` *Stato:* da implementare (B-94) · *Oggi:* tutte le risposte della tabella hanno `detail` come stringa inglese (colonna "Oggi"), e il frontend la mostra così com'è. - **[API-17]** Il client traduce `code` con la chiave `error.` di `app/static/i18n.js`, passando `params` all'interpolazione e aggiungendo per 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); `503 registration_suspended` (all'avvio sono stati trovati movimenti sulla catena per indici di derivazione successivi all'ultimo assegnato, cioè il database potrebbe essere un backup più vecchio della realtà; la registrazione resta sospesa finché l'operatore non riconcilia, [operativita.md](operativita.md) B-90 — *da implementare*). - **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, e scrive `user_registered` con indice e indirizzo nella stessa transazione; avvia (senza attenderla, *best effort*) la sottoscrizione Electrum dell'indirizzo. 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` *Stato:* da implementare (B-92, B-90) · *Oggi:* non scrive audit, e non esiste la sospensione `registration_suspended`. #### POST /auth/login - **[API-19]** Apre una sessione. - **Autenticazione:** nessuna. - **Corpo:** `{"username": stringa, "password": stringa}`, ciascuno di al massimo 256 caratteri (oltre: `422 invalid_request`, prima di qualunque hash Argon2 e senza consumare i contatori di fallimento). Lo username è confrontato senza distinzione tra maiuscole e minuscole. *Stato:* da implementare (B-94) · *Oggi:* nessun vincolo di lunghezza. - **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}`, ciascuno di al massimo 256 caratteri. - **Risposta `200`:** `{"access_token": }` — un **nuovo** token per la sessione corrente. - **Errori**, nell'ordine in cui sono verificati: `401` di autenticazione; `422 invalid_request` (un campo oltre 256 caratteri); `429 rate_limited` (`params.retry_after_seconds`); `401 current_password_incorrect`; `400 password_too_short` (`params.minimum` = 8). La lunghezza minima è verificata nel gestore (quindi `400`, non `422` come alla registrazione). - **Limite di frequenza:** un contatore di fallimenti di `current_password` per utente, con attesa esponenziale come quello del login; il blocco è verificato prima dell'hash Argon2. Soglie in [sicurezza.md](sicurezza.md) (B-94). - **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`. Scrive `password_changed` nella stessa transazione. Non notifica l'SSE. *Codice:* `app/api/routes/users.py:change_password`, `app/auth/rate_limit.py:RateLimiter` · *Test:* `tests/unit/test_users.py` *Stato:* da implementare (B-94) · *Oggi:* nessuna lunghezza massima, nessun limite di frequenza (la password attuale si può provare senza limiti) e nessuna voce di audit. #### 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`** (il server attivo ha accettato la transazione) o **`202`** (esito del broadcast ambiguo, [API-13](#contratto-degli-errori)): | Campo | Tipo | Significato | |---|---|---| | `round_id` | intero | round a cui la scommessa è iscritta | | `bet_txid` | stringa | txid della transazione (trasmessa con `201`, forse trasmessa con `202`) | | `bet_amount_sats` | intero | importo che arriva al montepremi, cioè il costo del round **meno** la commissione di rete | | `status` | stringa | stato del partecipante: `"broadcast"` con `201`, `"building"` con `202` | - **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`; anche quando il round ha raggiunto `max_round_pool_sats`, B-95), `balance_pending_confirmation` (`params.pending_sats`; il costo sarebbe coperto dal saldo comprensivo del resto non ancora confermato, ma non da quello confermato, B-96), `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` (solo rifiuto esplicito). 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 (lo stesso controllo dello scheduler, fatto dentro la richiesta); segna le monete spese, crea partecipante e transazione in sospeso, trasmette al server attivo e poi, *best effort*, a tutti i server di riserva. Audit `bet_placed` in caso di successo, `bet_broadcast_failed` se la rete rifiuta; notifica SSE in tutti e tre i casi (successo, rifiuto, esito ambiguo) 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` *Stato:* da implementare (B-76, B-95, B-96) · *Oggi:* ogni esito diverso dal successo è `502 broadcast_failed` con tutto annullato; la trasmissione va solo al server attivo; un costo coperto solo da resto non confermato risponde `insufficient_balance`; non c'è tetto al montepremi; il costo è quello configurato al momento della scommessa, non quello del round. #### 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`** (il server attivo ha accettato la transazione) o **`202`** (esito del broadcast ambiguo, [API-13](#contratto-degli-errori)): | Campo | Tipo | Significato | |---|---|---| | `txid` | stringa | txid della transazione (trasmessa con `201`, forse trasmessa con `202`) | | `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"` con `201`, `"building"` con `202` | - **Errori:** `422`; `503 network_unavailable` (verificato **prima** della validazione dell'indirizzo, quindi un indirizzo invalido riceve `503` finché la rete non torna); poi `400`: `invalid_address`, `withdrawal_to_own_address`, `withdrawal_to_platform_address` (destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni, B-80), `amount_below_minimum` (`params.minimum_sats` = costo attuale della scommessa; verificato anche sull'importo ridotto di un prelievo dell'intero saldo, B-96), `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` (solo rifiuto esplicito). - **Serializzazione:** stesso blocco per-utente di `POST /bets`. - **Effetti:** segna le monete spese, crea prelievo e transazione in sospeso, trasmette al server attivo e poi, *best effort*, a tutti i server di riserva. Audit `withdrawal_sent`, oppure `withdrawal_broadcast_failed` con il prelievo lasciato in stato `failed` per un rifiuto esplicito; con un esito ambiguo il prelievo resta `building`. Notifica SSE in tutti e tre i casi. *Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/withdrawals/service.py:request_withdrawal` · *Test:* `tests/unit/test_withdrawals.py` *Stato:* da implementare (B-76, B-80, B-96) · *Oggi:* ogni esito diverso dal successo è `502 broadcast_failed` con il prelievo `failed`; la trasmissione va solo al server attivo; un prelievo verso l'indirizzo del pool o delle commissioni è accettato; l'importo ridotto di un prelievo dell'intero saldo non è riconfrontato con il minimo. ### 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 che pagherebbe una nuova scommessa: con un round attivo il costo **fissato all'apertura del round** (`rounds.bet_amount_sats`), senza round attivo quello configurato — *da implementare (B-95); oggi sempre quello configurato* | | `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; è **uno** degli ingressi del seme, non il seme (vedi [API-45](#get-roundsround_idproof)) | | `draw_waiting_since` | stringa ISO o `null` | inizio dell'attesa del blocco; valorizzato **solo** mentre `status` è `drawing` | | `server_seed_hash` | stringa o `null` | impegno del server sul segreto del round, pubblicato dall'apertura; `null` per un round aperto prima di B-73 — *da implementare (B-73)* | | `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` — *da implementare (B-73)* | | `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` — *da implementare (B-73)* | | `draw_confirmations` | intero o `null` | conferme richieste sul blocco di estrazione, fissate all'apertura del round — *da implementare (B-73, B-95)* | | `pool_address` | stringa o `null` | indirizzo del montepremi del round, verificabile su un esploratore — *da implementare (B-80)* | | `chain_tip_height` | intero o `null` | ultima altezza nota della catena; `null` finché non è nota | | `lottery_paused` | booleano | lotteria in pausa, per qualunque motivo | | `paused_reason` | stringa o `null` | `operator`, `reorg`, `payout_blocked`, `resurrected_tx`, oppure `null` se non in pausa ([modello-dati.md](modello-dati.md), DAT-69) — *da implementare (B-78, B-82, B-85)* | | `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 | Il segreto del round (`server_seed`) **non** compare mai in questa risposta: si legge solo da [API-45](#get-roundsround_idproof), dopo che il vincitore è stato scritto. Senza round attivo sono valorizzati solo `server_time`, `bet_amount_sats`, `draw_animation_seconds`, `chain_tip_height`, `lottery_paused`, `paused_reason` 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` *Stato:* da implementare (B-73, B-78, B-80, B-82, B-85, B-95), per i campi marcati · *Oggi:* quei campi non esistono, `bet_amount_sats` è sempre il costo configurato, e `draw_block_hash` è il seme dell'estrazione. #### 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 too_many_streams` (formato strutturato, [API-16](#contratto-degli-errori)) quando i flussi aperti in totale sono già **500**; il client `EventSource` riprova da solo e intanto resta il polling. - **Chiusura all'arresto del server:** allo spegnimento ogni flusso aperto viene chiuso dal server, così l'arresto non attende i client ([architettura.md](architettura.md) B-93). - **Effetti:** nessuno. - **Cosa non notifica:** `PUT /admin/config`, `POST /admin/pause` e `POST /admin/resume` non pubblicano sul canale; `/` vede `lottery_paused`/`paused_reason` e la nuova configurazione al polling successivo di `GET /rounds/current`. *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` *Stato:* da implementare (B-94, B-93) · *Oggi:* il `503` ha il corpo non strutturato `{"detail": "too many concurrent update streams"}`, e un flusso aperto non viene chiuso all'arresto: uvicorn lo attende finché Docker non termina il processo. #### GET /rounds/{round_id}/proof - **[API-45]** La prova pubblica dell'estrazione di un round: tutto ciò che serve a chiunque per ricalcolare il vincitore senza fidarsi del server. L'algoritmo e la codifica esatta dei byte sono definiti in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73); qui è specificata la forma della risposta. - **Autenticazione:** nessuna. - **Parametro di percorso:** `round_id` intero (non intero: `422`). - **Risposta `200`**, per un round in qualunque stato; i campi non ancora determinati valgono `null` (o lista vuota): | Campo | Tipo | Valorizzato da | Significato | |---|---|---|---| | `round_id` | intero | sempre | — | | `status` | stringa | sempre | stato del round | | `algorithm` | stringa | sempre | `"commit-reveal-v1"` per i round aperti con B-73; `"block-hash-v0"` per quelli precedenti, per cui la prova è incompleta (vedi sotto) | | `pool_address` | stringa | sempre | indirizzo del montepremi del round | | `server_seed_hash` | stringa | apertura | `sha256(server_seed)`, in esadecimale | | `server_seed` | stringa o `null` | **solo dopo** che il vincitore è stato scritto (`paying_out` e `closed`) | il segreto del round, 32 byte in esadecimale | | `draw_height` | intero o `null` | ingresso in `drawing` | altezza del blocco di estrazione | | `draw_confirmations` | intero | apertura | conferme richieste su quel blocco prima di leggerne l'hash | | `participants` | lista | ingresso in `drawing` | elenco in ordine di `draw_position`: `{"draw_position", "txid", "vout", "address", "status"}`, dove `txid:vout` è l'output della scommessa verso `pool_address`, `address` l'indirizzo del partecipante (dove andrebbe la vincita) e `status` `confirmed` o `reorged_out` | | `participants_hash` | stringa o `null` | ingresso in `drawing` | impronta dell'elenco `participants` | | `draw_block_hash` | stringa o `null` | estrazione | hash del blocco a `draw_height` | | `seed` | stringa o `null` | estrazione | il seme in decimale (`rounds.seed_int`) | | `winner_index` | intero o `null` | estrazione | `seed mod` numero di elementi di `participants` | | `winner_address` | stringa o `null` | estrazione | `participants[winner_index].address` | - **Come si verifica** (round `commit-reveal-v1`): 1. prima della chiusura, annotare `server_seed_hash` da `GET /rounds/current` o da questo endpoint; 2. dopo la rivelazione, controllare che `sha256(server_seed)` sia uguale a quel valore: il segreto non è stato cambiato dopo l'apertura; 3. ricalcolare `participants_hash` da `participants`, e controllare su un esploratore o un nodo che ogni `txid:vout` sia un output confermato verso `pool_address`; 4. controllare su un esploratore o un nodo che il blocco a `draw_height` abbia hash `draw_block_hash` e che la catena abbia almeno `draw_confirmations` blocchi da lì; 5. ricalcolare `seed` da `server_seed`, `draw_block_hash` e `participants_hash`, poi `winner_index = seed mod n` con `n` il numero di elementi di `participants`, e confrontare `winner_address`. Per un round `block-hash-v0` la risposta ha `server_seed_hash`, `server_seed`, `draw_height` e `participants_hash` a `null` e `participants` vuota, perché quei round non hanno registrato né un impegno né l'ordine dei partecipanti: `seed` e `draw_block_hash` si possono confrontare, il vincitore no. - **Riservatezza:** `server_seed` non compare in nessuna risposta, di nessun endpoint (admin compresi), finché `rounds.winner_user_id` è `NULL` ([modello-dati.md](modello-dati.md), DAT-64). Gli indirizzi dei partecipanti sono già pubblici sulla catena: le scommesse li collegano a `pool_address`. - **Errori:** `404 round_not_found`; `422`. - **Effetti:** nessuno. *Codice:* `app/api/routes/rounds.py` (endpoint da scrivere), `app/rounds/draw.py:draw_winner` · *Test:* — *Stato:* da implementare (B-73) · *Oggi:* l'endpoint non esiste; l'unico dato pubblico dell'estrazione è `draw_block_hash` in `GET /rounds/current`, finché il round è attivo. ### 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`; `429 rate_limited` (`params.retry_after_seconds`). - **Limite di frequenza:** una quota di segnalazioni per IP ([API-11](#convenzioni-generali)) in una finestra mobile, verificata prima dell'inserimento e consumata solo da un inserimento riuscito, anche per un chiamante autenticato; soglie in [sicurezza.md](sicurezza.md) (B-94). - **Effetti:** inserisce la segnalazione in stato `open`. Nessun audit, nessuna notifica SSE. *Codice:* `app/api/routes/bug_reports.py:create_bug_report`, `app/api/routes/bug_reports.py:BugReportCreate`, `app/auth/rate_limit.py:RollingQuota` · *Test:* `tests/unit/test_bug_reports.py` *Stato:* da implementare (B-94) · *Oggi:* nessun limite di frequenza: un anonimo può inserire segnalazioni senza limite. #### 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 invalid_admin_token` senza di esso. Nessuno restituisce `rounds.server_seed` prima che il vincitore del round sia stato scritto ([API-45](#get-roundsround_idproof)). 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", "deposit_confirmations", "draw_confirmations", "max_round_pool_sats", "paused", "paused_reason"}`. *Codice:* `app/api/routes/admin.py:read_config` · *Test:* `tests/unit/test_admin.py` *Stato:* da implementare (B-73, B-82, B-85, B-95) · *Oggi:* mancano `deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats` e `paused_reason`. #### 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 | | `deposit_confirmations` | 1 – 100 — *da implementare (B-82)* | | `draw_confirmations` | 1 – 20 — *da implementare (B-73)* | | `max_round_pool_sats` | `0` (disattivato), oppure da `bet_amount_sats` a 4 000 000 000 000 000 — *da implementare (B-95)* | I limiti sono quelli di [modello-dati.md](modello-dati.md) (tabella `round_config`), che ne è il proprietario. `paused` e `paused_reason` **non** sono accettati qui (sono ignorati come campi sconosciuti): 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, pausa tra round, costo della scommessa, `fee_address` e `draw_confirmations` si applicano dal round successivo (per costo e `fee_address`: *da implementare*, B-95; oggi subito). Nessuna notifica SSE. *Codice:* `app/api/routes/admin.py:update_config`, `app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py` *Stato:* da implementare (B-73, B-82, B-95), per i tre campi nuovi e la copia all'apertura · *Oggi:* sono accettati solo i primi sette campi. #### POST /admin/pause e POST /admin/resume - **[API-32]** Attivano e disattivano la pausa: impedisce l'apertura del **prossimo** round, il round in corso prosegue fino al pagamento (salvo un pagamento trattenuto, vedi sotto). La semantica di `paused_reason` è in [modello-dati.md](modello-dati.md) (DAT-69). - **`POST /admin/pause` — corpo:** facoltativo, `{"note": stringa}` di al massimo 256 caratteri, un promemoria libero dell'operatore che finisce solo nel payload di `lottery_paused`. Imposta `paused = true` e `paused_reason = 'operator'`, tranne quando è già attivo un motivo automatico (`reorg`, `payout_blocked`, `resurrected_tx`), che resta. - **`POST /admin/resume` — corpo:** nessuno. È l'unico modo di uscire da **qualunque** pausa, anche automatica: imposta `paused = false`, `paused_reason = NULL` e azzera `rounds.payout_blocked_at` del round attivo, se valorizzato (il pagamento viene poi riverificato, DAT-68). Il payload di `lottery_resumed` riporta il `paused_reason` che è stato tolto, così il registro dice che cosa l'operatore ha accettato di riprendere. - **Risposta `200`** (entrambi): la configurazione completa, come [API-30](#get-adminconfig), compreso `paused_reason`. - **Errori:** `422` (nota oltre 256 caratteri). - **Effetti:** 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` *Stato:* da implementare (B-78, B-82, B-85) · *Oggi:* nessun corpo, nessun `paused_reason`, e la pausa è solo manuale. #### GET /admin/users - **[API-33]** Elenco degli utenti, per `id` crescente, a pagine. - **Query:** `limit` intero 1–500, predefinito 100; `after_id` intero facoltativo: restituisce solo gli utenti con `id` maggiore. La pagina successiva si chiede con `after_id` uguale all'`id` dell'ultimo elemento ricevuto; una lista più corta di `limit` è l'ultima pagina. Fuori intervallo: `422`. - **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` *Stato:* da implementare (B-94) · *Oggi:* restituisce **tutti** gli utenti in una sola risposta, senza paginazione né limite. #### 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` (oggi non strutturato, `"user not found"`, [API-16](#contratto-degli-errori)); `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` (oggi non strutturato, [API-16](#contratto-degli-errori)); `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", "pool_address", "server_seed_hash", "draw_height", "bump_reserve_sats", "payout_blocked_at"}`; i campi non ancora valorizzati sono `null`. `server_seed` non è tra i campi: la prova completa di un round è in [API-45](#get-roundsround_idproof). *Codice:* `app/api/routes/admin.py:list_rounds` · *Test:* `tests/unit/test_admin.py` *Stato:* da implementare (B-73, B-79, B-80, B-82), per gli ultimi cinque campi · *Oggi:* la lista si ferma a `payout_txid`. #### GET /admin/audit-log - **[API-37]** Registro di audit, dalla voce più recente, filtrabile e a pagine. - **Query**, tutti facoltativi e combinati in **and**: | Parametro | Tipo | Filtro | |---|---|---| | `limit` | intero 1–500, predefinito 200 | numero massimo di voci | | `before_id` | intero | solo voci con `id` minore: la pagina successiva si chiede con l'`id` dell'ultima voce ricevuta; una lista più corta di `limit` è l'ultima pagina | | `event_type` | stringa, ripetibile | solo voci di uno dei tipi indicati, confrontati alla lettera (un tipo inesistente dà una lista vuota) | | `round_id` | intero | solo voci con quel `round_id` | | `user_id` | intero | solo voci con quel `user_id` | | `from` | stringa ISO 8601 con offset | solo voci con `created_at` ≥ questo istante | | `to` | stringa ISO 8601 con offset | solo voci con `created_at` < questo istante | Un valore non interpretabile (un intero non valido, un istante senza offset) risponde `422 invalid_request`. - **Risposta `200`:** lista di `{"id", "event_type", "payload" (oggetto), "user_id", "round_id", "created_at"}`, per `id` decrescente. *Codice:* `app/api/routes/admin.py:list_audit_log` · *Test:* `tests/unit/test_admin.py` *Stato:* da implementare (B-92), per filtri e paginazione · *Oggi:* accetta solo `limit`, quindi oltre le ultime 500 voci il registro non è consultabile dall'API. #### 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` (oggi non strutturato, `"bug report not found"`, [API-16](#contratto-degli-errori)). - **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).