2026-09-28 12:16:01 +02:00
# 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 ) |
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
| `GET` | `/rounds/{round_id}/proof` | nessuna | [API-45 ](#get-roundsround_idproof ) |
2026-09-28 12:16:01 +02:00
| `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 ) |
Readiness, Electrum status, audit browsing and logs (B-92, API-37, API-46, API-47, AUD-34, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29, SEC-48)
2026-10-18 01:15:22 +02:00
| `GET` | `/admin/electrum` | admin | [API-47 ](#get-adminelectrum ) |
2026-09-28 12:16:01 +02:00
| `GET` | `/health` | nessuna | [API-41 ](#get-health ) |
Readiness, Electrum status, audit browsing and logs (B-92, API-37, API-46, API-47, AUD-34, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29, SEC-48)
2026-10-18 01:15:22 +02:00
| `GET` | `/health/ready` | nessuna | [API-46 ](#get-healthready ) |
2026-09-28 12:16:01 +02:00
| `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
2026-09-28 15:51:13 +02:00
(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.
2026-09-28 12:16:01 +02:00
*Codice:* `app/auth/dependencies.py:get_current_user` ·
*Test:* `tests/unit/test_users.py` , `tests/unit/test_bug_reports.py`
- **[API-07]** Autenticazione **facoltativa** : senza header, con un header che
non inizia con `Bearer ` , con un token invalido o superato, la richiesta è
trattata come anonima — **mai** un errore. Serve agli endpoint raggiungibili
anche da non loggati che personalizzano la risposta se l'utente è noto
(`GET /rounds/current` , `POST /bug-reports` ).
*Codice:* `app/auth/dependencies.py:get_optional_user` ·
*Test:* `tests/unit/test_rounds_route.py` , `tests/unit/test_bug_reports.py`
- **[API-08]** Autenticazione **admin** : l'header `X-Admin-Token` è confrontato
a tempo costante (sui byte UTF-8, così un valore non ASCII non provoca un
`500` ) con `ADMIN_TOKEN` del `.env` . Un `ADMIN_TOKEN` vuoto nega **tutto** ,
2026-09-28 15:51:13 +02:00
anche un header vuoto. Il rifiuto è `403` con codice `invalid_admin_token` ,
nel formato strutturato di [API-12]. Non c'è identità per singolo operatore
2026-09-28 12:16:01 +02:00
(vedi [sicurezza.md ](sicurezza.md )).
*Codice:* `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_admin.py`
- **[API-09]** Intestazioni di cache:
- le quattro pagine (`/` , `/admin` , `/guida` , `/report-bug` ) rispondono con
`Cache-Control: no-store` ;
- il flusso SSE risponde con `Cache-Control: no-store` e
`X-Accel-Buffering: no` ;
- il QR risponde con `Cache-Control: private, max-age=86400, immutable` ;
2026-09-28 15:51:13 +02:00
- 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` .
2026-09-28 12:16:01 +02:00
*Codice:* `app/main.py:index_page` , `app/main.py:admin_panel` ,
`app/main.py:user_guide` , `app/main.py:report_bug_page` ,
2026-09-28 15:51:13 +02:00
`app/api/routes/rounds.py:round_stream` , `app/api/routes/qr.py:address_qr` ,
`app/main.py` (`app.mount` )
serviti dal mount non hanno `Cache-Control` , solo `ETag` /`Last-Modified` ,
e il browser può riusarli senza rivalidarli.
2026-09-28 12:16:01 +02:00
- **[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
Bound credentials, brake password changes, cap bug reports, page the user list, structure every error (B-94, SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12, AUD-42)
2026-10-18 00:59:25 +02:00
di login e registrazione, tetto per IP del flusso SSE e quota per IP di
`POST /bug-reports` ), l'IP è l'**ultimo**
2026-09-28 12:16:01 +02:00
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
2026-09-29 16:26:01 +02:00
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).
2026-09-28 15:51:13 +02:00
` 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`).
2026-09-28 12:16:01 +02:00
*Codice:* ` app/api/routes/bets.py:create_bet`,
` app/api/routes/withdrawals.py:create_withdrawal` ·
Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)
2026-10-14 14:22:20 +02:00
*Test:* ` tests/unit/test_bets.py`, ` tests/unit/test_broadcast_outcome.py`
2026-09-28 12:16:01 +02:00
- **[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
2026-09-28 15:51:13 +02:00
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.
Bound credentials, brake password changes, cap bug reports, page the user list, structure every error (B-94, SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12, AUD-42)
2026-10-18 00:59:25 +02:00
*Codice:* ` app/main.py` (gestore di ` RequestValidationError`),
2026-09-28 12:16:01 +02:00
` app/static/app.js:apiErrorMessage` ·
*Test:* ` tests/unit/test_users.py`, ` tests/unit/test_admin.py`,
` tests/unit/test_bug_reports.py`
2026-09-28 15:51:13 +02:00
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`.
2026-09-28 12:16:01 +02:00
2026-09-28 15:51:13 +02:00
- **[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:
2026-09-28 12:16:01 +02:00
2026-09-28 15:51:13 +02:00
| 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"` |
2026-09-28 12:16:01 +02:00
2026-09-28 15:51:13 +02:00
I ` 404`/` 405` del framework si ottengono con un gestore di
` StarletteHTTPException` che traduce lo stato nel codice della tabella.
2026-09-28 12:16:01 +02:00
*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`,
2026-09-28 15:51:13 +02:00
` app/api/routes/rounds.py:round_stream`, ` app/main.py` ·
2026-09-28 12:16:01 +02:00
*Test:* ` tests/unit/test_admin.py`, ` tests/unit/test_bug_reports.py`
- **[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
2026-10-18 00:43:45 +02:00
assegnare un indice di derivazione libero); ` 503 registration_unavailable`
2026-09-28 15:51:13 +02:00
(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,
2026-10-18 00:43:45 +02:00
[operativita.md](operativita.md) B-90).
2026-09-28 12:16:01 +02:00
- **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
2026-09-28 15:51:13 +02:00
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
2026-09-28 12:16:01 +02:00
[registrazione-e-depositi.md](registrazione-e-depositi.md).
*Codice:* ` app/auth/routes.py:register`, ` app/auth/routes.py:RegisterRequest` ·
*Test:* ` tests/unit/test_auth.py`, ` tests/unit/test_users.py`
#### POST /auth/login
- **[API-19]** Apre una sessione.
- **Autenticazione:** nessuna.
2026-09-28 15:51:13 +02:00
- **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.
2026-09-28 12:16:01 +02:00
- **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.
2026-09-28 15:51:13 +02:00
- **Corpo:** ` {"current_password": stringa, "new_password": stringa}`,
ciascuno di al massimo 256 caratteri.
2026-09-28 12:16:01 +02:00
- **Risposta ` 200`:** ` {"access_token": <JWT>}` — un **nuovo** token per la
sessione corrente.
- **Errori**, nell'ordine in cui sono verificati: ` 401` di autenticazione;
2026-09-28 15:51:13 +02:00
` 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).
2026-09-28 12:16:01 +02:00
- **Effetti:** sostituisce l'hash e incrementa ` User.token_version`: ogni
token emesso prima, compreso quello usato per questa richiesta, da quel
2026-09-28 15:51:13 +02:00
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` ·
2026-09-28 12:16:01 +02:00
*Test:* ` tests/unit/test_users.py`
#### GET /users/me/last-round-result
- **[API-22]** Esito dell'ultimo round **chiuso** a cui l'utente ha
partecipato, letto dal database. È la rete di sicurezza per un dispositivo
che ha perso la finestra in cui ` GET /rounds/current` mostrava il vincitore.
- **Autenticazione:** Bearer.
- **Risposta ` 200`:**
| Campo | Tipo | Significato |
|---|---|---|
| ` round_id` | intero o ` null` | l'ultimo round in stato ` closed` con una riga di partecipazione dell'utente; ` null` se non ce n'è nessuno |
| ` won` | booleano | vero se l'utente è il vincitore di quel round |
| ` amount_sats` | intero o ` null` | importo effettivamente pagato al vincitore (` Round.winner_amount_sats`, già al netto della commissione del pagamento), solo se ` won` |
Senza round chiusi giocati la risposta è
` {"round_id": null, "won": false, "amount_sats": null}`.
- **Errori:** ` 401`.
- **Effetti:** nessuno.
*Codice:* ` app/api/routes/users.py:last_round_result`
### Scommesse e prelievi
#### POST /bets
- **[API-23]** Piazza la scommessa dell'utente sul round in corso, al costo
fisso attuale. Regole, sequenza e rollback sono in
[scommesse-e-prelievi.md](scommesse-e-prelievi.md).
- **Autenticazione:** Bearer.
- **Corpo:** nessuno.
2026-09-28 15:51:13 +02:00
- **Risposta ` 201`** (il server attivo ha accettato la transazione) o
**` 202`** (esito del broadcast ambiguo, [API-13](#contratto-degli-errori)):
2026-09-28 12:16:01 +02:00
| Campo | Tipo | Significato |
|---|---|---|
| ` round_id` | intero | round a cui la scommessa è iscritta |
2026-09-28 15:51:13 +02:00
| ` 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` |
2026-09-28 12:16:01 +02:00
- **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`,
2026-09-28 15:51:13 +02:00
` 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`,
2026-09-29 16:26:01 +02:00
` amount_below_dust_limit`, ` 503 service_busy`, ` 502 broadcast_failed`
(solo rifiuto esplicito). Il significato di ciascuno è nel
2026-09-28 12:16:01 +02:00
[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
2026-09-28 15:51:13 +02:00
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.
2026-09-28 12:16:01 +02:00
*Codice:* ` app/api/routes/bets.py:create_bet`,
` app/bets/service.py:place_bet` ·
Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
2026-10-16 15:12:00 +02:00
*Test:* ` tests/unit/test_bets.py`, ` tests/unit/test_round_snapshot.py`
2026-09-28 12:16:01 +02:00
#### 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 |
2026-09-28 15:51:13 +02:00
- **Risposta ` 201`** (il server attivo ha accettato la transazione) o
**` 202`** (esito del broadcast ambiguo, [API-13](#contratto-degli-errori)):
2026-09-28 12:16:01 +02:00
| Campo | Tipo | Significato |
|---|---|---|
2026-09-28 15:51:13 +02:00
| ` txid` | stringa | txid della transazione (trasmessa con ` 201`, forse trasmessa con ` 202`) |
2026-09-28 12:16:01 +02:00
| ` 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 |
2026-09-28 15:51:13 +02:00
| ` status` | stringa | stato del prelievo: ` "broadcast"` con ` 201`, ` "building"` con ` 202` |
2026-09-28 12:16:01 +02:00
- **Errori:** ` 422`; ` 503 network_unavailable` (verificato **prima** della
2026-09-28 15:51:13 +02:00
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
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
commissioni), ` amount_below_minimum` (` params.minimum_sats` = costo
2026-09-28 15:51:13 +02:00
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`,
2026-09-29 16:26:01 +02:00
` amount_below_dust_limit`; ` 503 service_busy`; ` 502 broadcast_failed`
(solo rifiuto esplicito).
2026-09-28 12:16:01 +02:00
- **Serializzazione:** stesso blocco per-utente di ` POST /bets`.
- **Effetti:** segna le monete spese, crea prelievo e transazione in
2026-09-28 15:51:13 +02:00
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.
2026-09-28 12:16:01 +02:00
*Codice:* ` app/api/routes/withdrawals.py:create_withdrawal`,
` app/withdrawals/service.py:request_withdrawal` ·
*Test:* ` tests/unit/test_withdrawals.py`
### Round
#### GET /rounds/current
- **[API-25]** Stato del round attivo, pensato per essere interrogato a ciclo
dalle due interfacce (anche la pillola Electrum/altezza di ` /admin` legge
` chain_tip_height` da qui).
- **Autenticazione:** facoltativa (serve solo per ` user_played`).
- **Risposta ` 200`** — tutti i campi sono sempre presenti:
| Campo | Tipo | Significato |
|---|---|---|
| ` server_time` | stringa ISO | ora del server, per allineare i conti alla rovescia del client |
| ` round_id` | intero o ` null` | round attivo; ` null` se non ce n'è |
| ` status` | stringa o ` null` | ` open`, ` closing`, ` drawing` o ` paying_out`; un round ` closed` non compare mai |
| ` opened_at` | stringa ISO o ` null` | apertura del round |
| ` closes_at` | stringa ISO o ` null` | scadenza per le scommesse: ` opened_at` + la durata **fissata all'apertura del round** (non quella attuale di configurazione) |
| ` participant_count` | intero | partecipanti **confermati**, cioè quelli tra cui si estrae |
Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
2026-10-16 15:12:00 +02:00
| ` 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 |
2026-09-28 12:16:01 +02:00
| ` 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 |
2026-09-28 15:51:13 +02:00
| ` draw_block_hash` | stringa o ` null` | hash di quel blocco; è **uno** degli ingressi del seme, non il seme (vedi [API-45](#get-roundsround_idproof)) |
2026-09-28 12:16:01 +02:00
| ` draw_waiting_since` | stringa ISO o ` null` | inizio dell'attesa del blocco; valorizzato **solo** mentre ` status` è ` drawing` |
Close the smaller state inconsistencies (B-96, BET-02, BET-07, WDR-01, WDR-15, TX-16, TX-18, TX-27, TX-29, DEP-15, DEP-16, AUD-30, AUD-46, DAT-22)
2026-10-18 01:08:14 +02:00
| ` closing_waiting_since` | stringa ISO o ` null` | da quando il round attende in ` closing` le scommesse in volo ([TX-29](transazioni-e-riconciliazione.md#riconciliazione)); valorizzato **solo** mentre ` status` è ` closing` |
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
| ` 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 |
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
| ` pool_address` | stringa o ` null` | indirizzo del montepremi del round, verificabile su un esploratore |
2026-09-28 12:16:01 +02:00
| ` chain_tip_height` | intero o ` null` | ultima altezza nota della catena; ` null` finché non è nota |
2026-09-28 15:51:13 +02:00
| ` lottery_paused` | booleano | lotteria in pausa, per qualunque motivo |
Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
2026-10-14 15:07:09 +02:00
| ` 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) |
2026-09-28 12:16:01 +02:00
| ` 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 |
2026-09-28 15:51:13 +02:00
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.
2026-09-28 12:16:01 +02:00
Senza round attivo sono valorizzati solo ` server_time`, ` bet_amount_sats`,
2026-09-28 15:51:13 +02:00
` 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
2026-09-28 12:16:01 +02:00
[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.
2026-09-28 15:51:13 +02:00
- **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).
2026-09-28 12:16:01 +02:00
- **Effetti:** nessuno.
2026-09-28 15:51:13 +02:00
- **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`.
2026-09-28 12:16:01 +02:00
*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`
Bound credentials, brake password changes, cap bug reports, page the user list, structure every error (B-94, SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12, AUD-42)
2026-10-18 00:59:25 +02:00
*Stato:* da implementare (B-93), per la chiusura dei flussi all'arresto
2026-09-28 15:51:13 +02:00
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 |
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
| ` 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` |
2026-09-28 15:51:13 +02:00
| ` 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.
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
*Codice:* ` app/api/routes/rounds.py:round_proof`,
` app/rounds/draw.py:draw_winner` ·
*Test:* ` tests/unit/test_draw_proof.py`
2026-09-28 12:16:01 +02:00
### 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}`.
2026-09-28 15:51:13 +02:00
- **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).
2026-09-28 12:16:01 +02:00
- **Effetti:** inserisce la segnalazione in stato ` open`. Nessun audit,
2026-09-28 15:51:13 +02:00
nessuna notifica SSE.
2026-09-28 12:16:01 +02:00
*Codice:* ` app/api/routes/bug_reports.py:create_bug_report`,
2026-09-28 15:51:13 +02:00
` app/api/routes/bug_reports.py:BugReportCreate`,
` app/auth/rate_limit.py:RollingQuota` ·
2026-09-28 12:16:01 +02:00
*Test:* ` tests/unit/test_bug_reports.py`
#### GET /bug-reports/mine
- **[API-29]** Le segnalazioni inviate dall'utente mentre era autenticato, per
seguirne lo stato. Quelle anonime non vi compaiono mai.
- **Autenticazione:** Bearer.
- **Risposta ` 200`:** lista, dalla più recente, di
` {"id", "description", "status", "created_at"}`; ` status` è ` open`,
` read` o ` resolved`. Il campo ` contact` non è restituito.
- **Errori:** ` 401`.
*Codice:* ` app/api/routes/bug_reports.py:list_my_bug_reports` ·
*Test:* ` tests/unit/test_bug_reports.py`
### Amministrazione
Tutti gli endpoint ` /admin/*` richiedono ` X-Admin-Token`
2026-09-28 15:51:13 +02:00
([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
2026-09-28 12:16:01 +02:00
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",
2026-09-28 15:51:13 +02:00
"rbf_timeout_seconds", "draw_animation_seconds", "deposit_confirmations",
"draw_confirmations", "max_round_pool_sats", "paused", "paused_reason"}`.
2026-09-28 12:16:01 +02:00
*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 |
2026-10-18 00:32:24 +02:00
| ` fee_rate_sat_vb` | 1 – 5 000 (` MAX_FEE_RATE_SAT_VB`) |
2026-09-28 12:16:01 +02:00
| ` rbf_timeout_seconds` | 60 – 604 800 |
| ` draw_animation_seconds` | 0 – 600 |
Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
2026-10-16 14:25:35 +02:00
| ` deposit_confirmations` | 1 – 100 |
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
| ` draw_confirmations` | 1 – 20 |
Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
2026-10-16 15:12:00 +02:00
| ` max_round_pool_sats` | ` 0` (disattivato), oppure da ` bet_amount_sats` a 4 000 000 000 000 000 |
2026-09-28 12:16:01 +02:00
2026-09-28 15:51:13 +02:00
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
2026-09-28 12:16:01 +02:00
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
Give every payout a bump reserve output and refuse configs that cannot pay a round (B-79, RND-21, RND-23, RND-24, RND-27, RND-38, RND-40, DAT-73, API-31, API-36, TX-11, ADR-17, ADR-29)
2026-10-16 13:55:51 +02:00
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
Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
2026-10-16 15:12:00 +02:00
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"`.
2026-09-28 12:16:01 +02:00
- **Effetti:** scrive ` config_updated` con i valori prima/dopo dei soli
2026-09-28 15:51:13 +02:00
campi davvero cambiati; nessuna scrittura se nulla cambia. Durata, pausa
tra round, costo della scommessa, ` fee_address` e ` draw_confirmations` si
Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
2026-10-16 15:12:00 +02:00
applicano dal round successivo, come il tetto ` max_round_pool_sats`. Nessuna
notifica SSE.
2026-09-28 12:16:01 +02:00
*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
2026-09-28 15:51:13 +02:00
- **[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.
2026-09-28 12:16:01 +02:00
*Codice:* ` app/api/routes/admin.py:pause_lottery`,
` app/api/routes/admin.py:resume_lottery` ·
Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
2026-10-14 15:07:09 +02:00
*Test:* ` tests/unit/test_admin.py`, ` tests/unit/test_pause_reason.py`
2026-09-28 12:16:01 +02:00
#### GET /admin/users
2026-09-28 15:51:13 +02:00
- **[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`.
2026-09-28 12:16:01 +02:00
- **Risposta ` 200`:** lista di ` {"id", "username", "address",
"balance_sats", "created_at"}`; ` balance_sats` è il saldo confermato.
*Codice:* ` app/api/routes/admin.py:list_users` ·
*Test:* ` tests/unit/test_admin.py`
#### GET /admin/users/{user_id}/privkey
- **[API-34]** Esporta la chiave privata WIF dell'indirizzo di un utente, per
interventi manuali. È una scelta voluta (vedi
2026-10-14 12:05:57 +02:00
[limiti-noti.md](limiti-noti.md)).
2026-09-28 12:16:01 +02:00
- **Parametro:** ` user_id` intero.
- **Risposta ` 200`:** ` {"address", "wif"}`.
2026-09-28 15:51:13 +02:00
- **Errori:** ` 404 user_not_found` (oggi non strutturato, ` "user not
found"`, [API-16](#contratto-degli-errori)); ` 422` se ` user_id` non è un
intero.
2026-09-28 12:16:01 +02:00
- **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.
2026-09-28 15:51:13 +02:00
- **Errori:** ` 404 user_not_found` (oggi non strutturato,
[API-16](#contratto-degli-errori)); ` 422`.
2026-09-28 12:16:01 +02:00
- **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",
2026-09-28 15:51:13 +02:00
"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).
2026-09-28 12:16:01 +02:00
*Codice:* ` app/api/routes/admin.py:list_rounds` ·
*Test:* ` tests/unit/test_admin.py`
Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
2026-10-16 14:25:35 +02:00
2026-09-28 12:16:01 +02:00
#### GET /admin/audit-log
2026-09-28 15:51:13 +02:00
- **[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`.
2026-09-28 12:16:01 +02:00
- **Risposta ` 200`:** lista di ` {"id", "event_type", "payload" (oggetto),
2026-09-28 15:51:13 +02:00
"user_id", "round_id", "created_at"}`, per ` id` decrescente.
2026-09-28 12:16:01 +02:00
*Codice:* ` app/api/routes/admin.py:list_audit_log` ·
Readiness, Electrum status, audit browsing and logs (B-92, API-37, API-46, API-47, AUD-34, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29, SEC-48)
2026-10-18 01:15:22 +02:00
*Test:* ` tests/unit/test_admin.py`, ` tests/unit/test_observability.py`
2026-09-28 12:16:01 +02:00
#### 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`
2026-09-29 09:17:52 +02:00
#### 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` ·
Readiness, Electrum status, audit browsing and logs (B-92, API-37, API-46, API-47, AUD-34, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29, SEC-48)
2026-10-18 01:15:22 +02:00
*Test:* ` tests/unit/test_observability.py`
2026-09-29 09:17:52 +02:00
2026-09-28 12:16:01 +02:00
#### 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).
2026-09-28 15:51:13 +02:00
- **Errori:** ` 422` (stato sconosciuto); ` 404 bug_report_not_found` (oggi
non strutturato, ` "bug report not found"`,
[API-16](#contratto-degli-errori)).
2026-09-28 12:16:01 +02:00
- **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`.
2026-09-29 09:17:52 +02:00
È il controllo di *vitalità* (il processo risponde); quello di *prontezza*
è [API-46].
2026-09-28 12:16:01 +02:00
*Codice:* ` app/main.py:health`
2026-09-29 09:17:52 +02:00
#### 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);
2026-10-18 00:32:24 +02:00
- l'ultimo header è stato accettato da meno di 1800 secondi
2026-09-29 09:17:52 +02:00
([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.
Readiness, Electrum status, audit browsing and logs (B-92, API-37, API-46, API-47, AUD-34, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29, SEC-48)
2026-10-18 01:15:22 +02:00
*Codice:* ` app/main.py:health` · *Test:* ` tests/unit/test_observability.py`
2026-09-29 09:17:52 +02:00
2026-09-28 12:16:01 +02:00
#### 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ì |
Bound credentials, brake password changes, cap bug reports, page the user list, structure every error (B-94, SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12, AUD-42)
2026-10-18 00:59:25 +02:00
| ` 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` | ` retry_after_seconds` | quota di registrazione, blocco per tentativi falliti, o quota di segnalazioni | sì |
2026-09-28 12:16:01 +02:00
| ` 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ì |
Close the smaller state inconsistencies (B-96, BET-02, BET-07, WDR-01, WDR-15, TX-16, TX-18, TX-27, TX-29, DEP-15, DEP-16, AUD-30, AUD-46, DAT-22)
2026-10-18 01:08:14 +02:00
| ` balance_pending_confirmation` | 400 | ` app/withdrawals/service.py:request_withdrawal`; anche ` app/bets/service.py:place_bet` | ` pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
2026-09-28 12:16:01 +02:00
| ` 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ì |
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
| ` withdrawal_to_platform_address` | 400 | ` app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni | sì |
2026-09-29 16:26:01 +02:00
| ` 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ì |
2026-10-18 00:43:45 +02:00
| ` 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ì |
Bound credentials, brake password changes, cap bug reports, page the user list, structure every error (B-94, SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12, AUD-42)
2026-10-18 00:59:25 +02:00
| ` not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header ` Authorization` assente | sì |
| ` invalid_admin_token` | 403 | ` app/api/routes/admin.py:require_admin` | — | token admin assente o errato | sì |
| ` user_not_found` | 404 | ` app/api/routes/admin.py` | — | utente inesistente | sì |
| ` bug_report_not_found` | 404 | ` app/api/routes/admin.py` | — | segnalazione inesistente | sì |
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
| ` round_not_found` | 404 | ` app/api/routes/rounds.py` (` GET /rounds/{id}/proof`) | — | round inesistente | sì |
Bound credentials, brake password changes, cap bug reports, page the user list, structure every error (B-94, SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12, AUD-42)
2026-10-18 00:59:25 +02:00
| ` not_found` | 404 | gestore delle eccezioni HTTP in ` app/main.py` | — | percorso inesistente | sì |
| ` method_not_allowed` | 405 | gestore delle eccezioni HTTP in ` app/main.py` | — | metodo non ammesso | sì |
| ` too_many_streams` | 503 | ` app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto | sì |
2026-09-28 12:16:01 +02:00
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).