place_bet and request_withdrawal released everything on any exception from the broadcast, including the 15s timeout and a connection dropped after the write. The tx may have propagated: a bet's sats landed in the pool with no participant, a withdrawal left while its row said failed and its coins were spendable again. - client: ElectrumRpcError (subclass of ElectrumError) is raised only for a reply carrying a JSON-RPC `error`; timeouts, drops and write errors stay plain ElectrumError. - tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused / uncertain. A refusal is verified first: ElectrumListener.transaction_known asks blockchain.transaction.get of the active server and every other one (10s each) — any server returning the tx means accepted; the active server plus MIN_QUORUM_RESPONSES others all denying it means refused (ALLOW_NO_QUORUM keeps the reduced rule); too few answers is uncertain. "already known" wording is only a shortcut that skips the check. With no network to ask, a refusal is uncertain. - place_bet / request_withdrawal / _trigger_payout use it: only a verified refusal releases; uncertain leaves the rows building for the reconciler, publishes SSE and POST /bets and POST /withdrawals answer 202 with status building; "already known" promotes a payout (payout_sent) like any accepted broadcast. - An accepted transaction is fanned out to every other server (ElectrumListener.fan_out_transaction, best effort, off the caller's path). The RBF replacement in bump_fee is left for B-77, which rewrites that function. Tests changed because the spec changed: the existing "refused broadcast" tests raised a bare RuntimeError (now an uncertain outcome) and are made explicit refusals (rpc_error) with a network that denies the tx; direct route calls pass a Response; FakePayoutListener gains the network surface. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1083 lines
64 KiB
Markdown
1083 lines
64 KiB
Markdown
# 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://<SITE_ADDRESS>/…` 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 <JWT>` | `401` |
|
||
| **facoltativa** (`get_optional_user`) | lo stesso header, se c'è | la richiesta prosegue come anonima |
|
||
| **admin** (`require_admin`) | header `X-Admin-Token: <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), e
|
||
`service_busy`, che risponde `503` (il database non ha concesso il lock di
|
||
scrittura entro il timeout, [DAT-74](modello-dati.md); nulla è stato
|
||
costruito né trasmesso).
|
||
`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`, `tests/unit/test_broadcast_outcome.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 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.<code>` di
|
||
`app/static/i18n.js`, passando `params` all'interpolazione e aggiungendo per
|
||
ogni parametro numerico `<nome>_sats` un gemello `<nome>_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.<code>` 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": <JWT>, "address": <indirizzo plm1…>}` —
|
||
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": <JWT>, "address": <indirizzo>}`.
|
||
- **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": <JWT>}` — 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`, `503 service_busy`, `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-95, B-96) · *Oggi:* 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), `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`; `503 service_busy`; `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-96) · *Oggi:* 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 |
|
||
| `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-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 `<img>`.
|
||
- **Autenticazione:** nessuna (un `<img>` 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
|
||
[limiti-noti.md](limiti-noti.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-82), per gli ultimi quattro
|
||
campi · *Oggi:* la lista si ferma a `pool_address`.
|
||
|
||
#### 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/electrum
|
||
|
||
- **[API-47]** Stato della connessione alla rete, per l'indicatore Electrum di
|
||
`/admin`. Risponde `200` con: `connected` (booleano), `endpoint` (server
|
||
attivo come `host:port`, `null` se non connesso), `tip_height`,
|
||
`last_header_age_seconds`, `fallback_count` (server di riserva configurati),
|
||
`quorum_enforced` (falso solo con `ALLOW_NO_QUORUM=true`,
|
||
[connessione-electrum.md](connessione-electrum.md) ELC-19) e
|
||
`last_rotation` (motivo e ora dell'ultimo cambio di server, ad esempio punta
|
||
ferma, [connessione-electrum.md](connessione-electrum.md) ELC-21).
|
||
*Codice:* `app/api/routes/admin.py`, `app/electrum/listener.py:ElectrumListener.current_endpoint` ·
|
||
*Test:* —
|
||
*Stato:* da implementare (B-86, B-92) · *Oggi:* l'endpoint non esiste;
|
||
`/admin` mostra solo l'altezza della catena letta da `GET /rounds/current`.
|
||
|
||
#### 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`.
|
||
È il controllo di *vitalità* (il processo risponde); quello di *prontezza*
|
||
è [API-46].
|
||
*Codice:* `app/main.py:health`
|
||
|
||
#### GET /health/ready
|
||
|
||
- **[API-46]** Risponde `200 {"status": "ready", "checks": {...}}` solo se
|
||
**tutte** queste condizioni sono vere, altrimenti `503` con lo stesso corpo e
|
||
`"status": "not_ready"`:
|
||
- la connessione Electrum è attiva (`listener.client` non nullo);
|
||
- l'ultimo header è stato accettato da meno di 360 secondi
|
||
([connessione-electrum.md](connessione-electrum.md) ELC-29);
|
||
- lo scheduler dei round ha battuto da meno di 30 secondi (il battito si
|
||
aggiorna a ogni giro e a ogni iterazione dell'attesa del blocco di
|
||
estrazione, [architettura.md](architettura.md) ARC-61).
|
||
|
||
`checks` riporta ogni condizione con il suo valore: `electrum_connected`
|
||
(booleano), `last_header_age_seconds` (intero o `null`),
|
||
`scheduler_heartbeat_age_seconds` (intero o `null`). Nessuna autenticazione,
|
||
nessun dato sensibile: non espone né il server attivo né l'altezza. È il
|
||
`healthcheck` del container.
|
||
*Codice:* `app/main.py:health` · *Test:* —
|
||
*Stato:* da implementare (B-92) · *Oggi:* l'endpoint non esiste; esiste solo
|
||
`GET /health` ([API-41]).
|
||
|
||
#### 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.<code>` 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.<code>` (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`; anche `app/api/routes/users.py:change_password` e `app/api/routes/bug_reports.py` *da implementare (B-94)* | `retry_after_seconds` | quota di registrazione, blocco per tentativi falliti, o quota di segnalazioni | 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`; anche `app/bets/service.py:place_bet` *da implementare (B-96)* | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
|
||
| `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ì |
|
||
| `withdrawal_to_platform_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni | sì |
|
||
| `service_busy` | 503 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/auth/routes.py:register` | — | il database non ha concesso il lock di scrittura entro il timeout; nulla è stato trasmesso, si può riprovare | sì |
|
||
| `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) — *da implementare* | no — da aggiungere |
|
||
| `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||
| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||
| `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||
| `bug_report_not_found` | 404 | `app/api/routes/admin.py` | — | segnalazione inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||
| `round_not_found` | 404 | `app/api/routes/rounds.py` (`GET /rounds/{id}/proof`) | — | round inesistente — *da implementare (B-73)* | no — da aggiungere |
|
||
| `not_found` | 404 | gestore delle eccezioni HTTP in `app/main.py` | — | percorso inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||
| `method_not_allowed` | 405 | gestore delle eccezioni HTTP in `app/main.py` | — | metodo non ammesso — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||
| `too_many_streams` | 503 | `app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||
|
||
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).
|