START_PAUSED=true pauses the lottery as the operator before the scheduler exists. On the first Electrum session the 20 derivation indexes past the highest assigned one are looked up on every server; if any has history, registration stays closed (503 registration_unavailable) and restore_gap_detected is written, so a restored database cannot hand a lost user's address to a new one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1076 lines
64 KiB
Markdown
1076 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) |
|
||
| `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-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_unavailable`
|
||
(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).
|
||
- **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) · *Oggi:* non scrive audit.
|
||
|
||
#### 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`, `tests/unit/test_round_snapshot.py`
|
||
*Stato:* da implementare (B-96) · *Oggi:* un costo coperto solo da resto non
|
||
confermato risponde `insufficient_balance`.
|
||
|
||
#### POST /withdrawals
|
||
|
||
- **[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 |
|
||
| `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 |
|
||
| `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` |
|
||
| `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` |
|
||
| `draw_confirmations` | intero o `null` | conferme richieste sul blocco di estrazione, fissate all'apertura del round |
|
||
| `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) |
|
||
| `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`
|
||
|
||
#### 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", "amount_sats", "status"}`, dove `txid:vout` è l'output della scommessa verso `pool_address`, `address` l'indirizzo del partecipante (dove andrebbe la vincita), `amount_sats` l'importo netto della scommessa 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:round_proof`,
|
||
`app/rounds/draw.py:draw_winner` ·
|
||
*Test:* `tests/unit/test_draw_proof.py`
|
||
|
||
### 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`
|
||
|
||
#### 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 – 5 000 (`MAX_FEE_RATE_SAT_VB`) |
|
||
| `rbf_timeout_seconds` | 60 – 604 800 |
|
||
| `draw_animation_seconds` | 0 – 600 |
|
||
| `deposit_confirmations` | 1 – 100 |
|
||
| `draw_confirmations` | 1 – 20 |
|
||
| `max_round_pool_sats` | `0` (disattivato), oppure da `bet_amount_sats` a 4 000 000 000 000 000 |
|
||
|
||
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). Una modifica di `bet_amount_sats` o `fee_rate_sat_vb` con cui
|
||
un round non si potrebbe pagare ([RND-40](ciclo-di-vita-del-round.md#4--pagamento))
|
||
dà `422` con `detail = {"code": "config_not_payable", "message", "params":
|
||
{"reason"}}`, dove `reason` è `winner_share_below_network_fee` o
|
||
`commission_below_dust_limit`; il controllo usa i valori risultanti dopo
|
||
la modifica e, se cambia `fee_rate_sat_vb`, anche il costo con cui si è
|
||
aperto il round in corso. Un tetto `max_round_pool_sats` diverso da 0 e
|
||
minore del costo della scommessa (dopo la modifica) dà `422` con
|
||
`detail.code = "max_round_pool_below_bet_amount"`.
|
||
- **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, come il tetto `max_round_pool_sats`. Nessuna
|
||
notifica SSE.
|
||
*Codice:* `app/api/routes/admin.py:update_config`,
|
||
`app/api/routes/admin.py:RoundConfigUpdate` ·
|
||
*Test:* `tests/unit/test_admin.py`
|
||
|
||
#### POST /admin/pause e POST /admin/resume
|
||
|
||
- **[API-32]** Attivano e disattivano la pausa: 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`, `tests/unit/test_pause_reason.py`
|
||
|
||
#### 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`
|
||
|
||
|
||
#### 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-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 1800 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) | sì |
|
||
| `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 | sì |
|
||
| `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).
|