Files
plm-lottery/docs/api.md
T
davideandClaude Sonnet 5.5 db24287ea5 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)
The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.

- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
  master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
  `draw_confirmations` is copied from round_config. The plaintext column is written only by
  the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
  bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
  lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
  `closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
  reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
  `draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
  asks every server, the active one included, for the header at that exact height (valid
  work, >= 2 other answers, strict majority of all); no agreement means the same height is
  asked again and `draw_header_corroboration_failed` is written once per distinct reported
  hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
  to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
  one transaction; a round closing with no participants reveals too. A round opened before
  this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
  the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
  /admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
  draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
  (RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.

Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:29:09 +02:00

64 KiB
Raw Blame History

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
Scommesse e prelievi scommesse-e-prelievi.md
Round, estrazione, pagamento ciclo-di-vita-del-round.md
Ciclo di vita delle transazioni, RBF transazioni-e-riconciliazione.md
Chiavi, JWT, limiti di frequenza, header di sicurezza sicurezza.md
Eventi del registro di audit audit-log.md
Tabelle e campi modello-dati.md
Frontend, canale SSE lato architettura, i18n 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 /auth/login nessuna API-19
GET /users/me Bearer API-20
POST /users/me/change-password Bearer API-21
GET /users/me/last-round-result Bearer API-22
POST /bets Bearer API-23
POST /withdrawals Bearer API-24
GET /rounds/current facoltativa API-25
GET /rounds/stream nessuna API-26
GET /rounds/{round_id}/proof nessuna API-45
GET /qr/{address} nessuna API-27
POST /bug-reports facoltativa API-28
GET /bug-reports/mine Bearer API-29
GET /admin/config admin API-30
PUT /admin/config admin API-31
POST /admin/pause admin API-32
POST /admin/resume admin API-32
GET /admin/users admin API-33
GET /admin/users/{user_id}/privkey admin API-34
POST /admin/users/{user_id}/reset-password admin API-35
GET /admin/rounds admin API-36
GET /admin/audit-log admin API-37
GET /admin/pending-transactions admin API-38
GET /admin/bug-reports admin API-39
POST /admin/bug-reports/{report_id}/status admin API-40
GET /admin/electrum admin API-47 — da implementare (B-86, B-92)
GET /health nessuna API-41
GET /health/ready nessuna API-46 — da implementare (B-92)
GET /, /admin, /guida, /report-bug nessuna API-42
GET qualunque altro percorso (file statici) nessuna API-43

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; 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). 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. 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:

    {
      "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; 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 B-76). Un rifiuto "già noto / già nel mempool" vale come successo (201). Codice: app/api/routes/bets.py:create_bet, app/api/routes/withdrawals.py:create_withdrawal · Test: tests/unit/test_bets.py, tests/unit/test_broadcast_outcome.py

  • [API-14] Qualunque eccezione non gestita risponde 500 con lo stesso formato strutturato, codice internal_error, messaggio "internal server error", params vuoto. Il testo e lo stack dell'eccezione finiscono solo nel log (logs/app.log), mai nella risposta. Codice: app/main.py:log_unhandled_exception · Test: tests/unit/test_rounds_route.py

  • [API-15] Un corpo, un parametro di percorso o di query che non supera la validazione risponde 422 nel formato strutturato di [API-12], con codice invalid_request, message "invalid request" e in params.errors la lista degli errori di validazione, uno per campo invalido, ciascuno {"loc", "msg", "type"} in inglese (anche il JSON malformato arriva così, con type: "json_invalid"). Il valore ricevuto (input) non è ripetuto nella risposta, così una password troppo lunga non torna indietro in chiaro. Codice: app/main.py (gestore di RequestValidationError, da scrivere), app/static/app.js:apiErrorMessage · Test: tests/unit/test_users.py, tests/unit/test_admin.py, tests/unit/test_bug_reports.py Stato: da implementare (B-94) · Oggi: 422 nel formato nativo di FastAPI: detail è una lista di oggetti {type, loc, msg, input, ctx?}, senza code; il frontend riconosce la lista e mostra il messaggio generico tradotto error.invalid_request.

  • [API-16] Nessuna risposta d'errore ha detail come semplice stringa: anche gli errori generati dagli endpoint admin, dal flusso SSE e dal framework seguono il formato strutturato di [API-12], con questi codici:

    Stato Codice atteso Dove Oggi (detail stringa)
    401 not_authenticated header Authorization assente su un endpoint Bearer ([API-06]) "Not authenticated"
    403 invalid_admin_token ogni endpoint /admin/* ([API-08]) "invalid admin token"
    404 user_not_found GET /admin/users/{id}/privkey, POST /admin/users/{id}/reset-password "user not found"
    404 bug_report_not_found POST /admin/bug-reports/{id}/status "bug report not found"
    404 not_found percorso inesistente (anche un metodo GET su un percorso che esiste solo in POST, perché lo intercetta il mount dei file statici) "Not Found"
    405 method_not_allowed metodo diverso da GET/HEAD su un percorso non definito da un router "Method Not Allowed"
    503 too_many_streams GET /rounds/stream oltre il tetto globale "too many concurrent update streams"

    I 404/405 del framework si ottengono con un gestore di StarletteHTTPException che traduce lo stato nel codice della tabella. Codice: app/api/routes/admin.py:require_admin, app/api/routes/admin.py:user_privkey, app/api/routes/admin.py:reset_user_password, app/api/routes/admin.py:update_bug_report_status, app/api/routes/rounds.py:round_stream, app/main.py · Test: tests/unit/test_admin.py, tests/unit/test_bug_reports.py Stato: da implementare (B-94) · Oggi: tutte le risposte della tabella hanno detail come stringa inglese (colonna "Oggi"), e il frontend la mostra così com'è.

  • [API-17] Il client traduce code con la chiave error.<code> di app/static/i18n.js, passando params all'interpolazione e aggiungendo per ogni parametro numerico <nome>_sats un gemello <nome>_plm già formattato in PLM; se la chiave non esiste mostra message. Ogni codice che l'API può restituire deve avere la sua chiave error.<code> in tutte e sette le lingue (en, it, es, fr, de, ru, zh). Un 401 con codice session_expired riporta inoltre il frontend alla schermata di login. Codice: app/static/app.js:apiErrorMessage, app/static/app.js:errorParams, app/static/i18n.js:TRANSLATIONS

Endpoint

Autenticazione

POST /auth/register

  • [API-18] Crea un account e restituisce subito una sessione.
    • Autenticazione: nessuna.

    • Corpo:

      Campo Tipo Vincoli
      username stringa 3–32 caratteri, solo A-Z a-z 0-9 _ . -
      password stringa 8–256 caratteri (MIN_PASSWORD_LENGTH = 8)
    • Risposta 201: {"access_token": <JWT>, "address": <indirizzo plm1…>} — l'indirizzo di deposito permanente dell'utente.

    • Errori: 422 (validazione del corpo); 429 rate_limited (quota IP esaurita, params.retry_after_seconds); 409 username_taken (nome già usato, senza distinzione tra maiuscole e minuscole, anche se l'altro account si è registrato nello stesso istante); 409 derivation_index_conflict (dopo 5 tentativi non è stato possibile assegnare un indice di derivazione libero); 503 registration_suspended (all'avvio sono stati trovati movimenti sulla catena per indici di derivazione successivi all'ultimo assegnato, cioè il database potrebbe essere un backup più vecchio della realtà; la registrazione resta sospesa finché l'operatore non riconcilia, operativita.md B-90 — da implementare).

    • Limite di frequenza: al massimo 5 account creati per IP in una finestra mobile di un'ora; la quota è verificata prima dell'hash Argon2 e consumata solo da una registrazione riuscita (un nome già preso o un errore di validazione non la consumano). Dettagli in sicurezza.md.

    • 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. Codice: app/auth/routes.py:register, app/auth/routes.py:RegisterRequest · Test: tests/unit/test_auth.py, tests/unit/test_users.py Stato: da implementare (B-92, B-90) · Oggi: non scrive audit, e non esiste la sospensione registration_suspended.

POST /auth/login

  • [API-19] Apre una sessione.
    • Autenticazione: nessuna.
    • Corpo: {"username": stringa, "password": stringa}, ciascuno di al massimo 256 caratteri (oltre: 422 invalid_request, prima di qualunque hash Argon2 e senza consumare i contatori di fallimento). Lo username è confrontato senza distinzione tra maiuscole e minuscole. Stato: da implementare (B-94) · Oggi: nessun vincolo di lunghezza.
    • Risposta 200: {"access_token": <JWT>, "address": <indirizzo>}.
    • Errori: 422; 429 rate_limited (params.retry_after_seconds, arrotondato per eccesso al secondo); 401 invalid_credentials — identico per username inesistente e password sbagliata.
    • Limite di frequenza: due contatori di fallimenti con attesa esponenziale, uno per username (dal 5° fallimento) e uno per IP (dal 20°). Il blocco è verificato prima della password, quindi durante un blocco anche la password giusta riceve 429. Un login riuscito azzera solo il contatore dello username. Valori e motivazioni in sicurezza.md.
    • 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.

    • Errori: 401 (vedi API-06).

    • 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 (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.
    • Autenticazione: Bearer.

    • Corpo: nessuno.

    • Risposta 201 (il server attivo ha accettato la transazione) o 202 (esito del broadcast ambiguo, API-13):

      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.

    • Serializzazione: la richiesta tiene il blocco per-utente condiviso con POST /withdrawals per tutta la costruzione e la trasmissione.

    • Effetti: può aprire un round se nessuno è attivo e le condizioni lo permettono (lo stesso controllo dello scheduler, fatto dentro la richiesta); segna le monete spese, crea partecipante e transazione in sospeso, trasmette al server attivo e poi, best effort, a tutti i server di riserva. Audit bet_placed in caso di successo, bet_broadcast_failed se la rete rifiuta; notifica SSE in tutti e tre i casi (successo, rifiuto, esito ambiguo) e all'apertura del round. Codice: app/api/routes/bets.py:create_bet, app/bets/service.py:place_bet · Test: tests/unit/test_bets.py Stato: da implementare (B-95, B-96) · Oggi: un costo coperto solo da resto non confermato risponde insufficient_balance; non c'è tetto al montepremi; il costo è quello configurato al momento della scommessa, non quello del round.

POST /withdrawals

  • [API-24] Invia fondi a un indirizzo esterno. Regole e sequenza in scommesse-e-prelievi.md.
    • 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):

      Campo Tipo Significato
      txid stringa txid della transazione (trasmessa con 201, forse trasmessa con 202)
      amount_requested_sats intero importo chiesto
      amount_sent_sats intero importo che arriva al destinatario: richiesto meno commissione, e meno quanto serve a lasciare un resto minimo se si preleva tutto
      status stringa stato del prelievo: "broadcast" con 201, "building" con 202
    • Errori: 422; 503 network_unavailable (verificato prima della validazione dell'indirizzo, quindi un indirizzo invalido riceve 503 finché la rete non torna); poi 400: invalid_address, withdrawal_to_own_address, withdrawal_to_platform_address (destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni), amount_below_minimum (params.minimum_sats = costo attuale della scommessa; verificato anche sull'importo ridotto di un prelievo dell'intero saldo, B-96), balance_pending_confirmation (params.pending_sats), insufficient_balance (params.required_sats), too_many_inputs (params.max_inputs), amount_below_network_fee, amount_below_dust_limit; 503 service_busy; 502 broadcast_failed (solo rifiuto esplicito).

    • Serializzazione: stesso blocco per-utente di POST /bets.

    • Effetti: segna le monete spese, crea prelievo e transazione in sospeso, trasmette al server attivo e poi, best effort, a tutti i server di riserva. Audit withdrawal_sent, oppure withdrawal_broadcast_failed con il prelievo lasciato in stato failed per un rifiuto esplicito; con un esito ambiguo il prelievo resta building. Notifica SSE in tutti e tre i casi. Codice: app/api/routes/withdrawals.py:create_withdrawal, app/withdrawals/service.py:request_withdrawal · Test: tests/unit/test_withdrawals.py Stato: da implementare (B-96) · Oggi: l'importo ridotto di un prelievo dell'intero saldo non è riconfrontato con il minimo.

Round

GET /rounds/current

  • [API-25] Stato del round attivo, pensato per essere interrogato a ciclo dalle due interfacce (anche la pillola Electrum/altezza di /admin legge chain_tip_height da qui).
    • Autenticazione: facoltativa (serve solo per user_played).

    • Risposta 200 — tutti i campi sono sempre presenti:

      Campo Tipo Significato
      server_time stringa ISO ora del server, per allineare i conti alla rovescia del client
      round_id intero o null round attivo; null se non ce n'è
      status stringa o null open, closing, drawing o paying_out; un round closed non compare mai
      opened_at stringa ISO o null apertura del round
      closes_at stringa ISO o null scadenza per le scommesse: opened_at + la durata fissata all'apertura del round (non quella attuale di configurazione)
      participant_count intero partecipanti confermati, cioè quelli tra cui si estrae
      bet_amount_sats intero costo che pagherebbe una nuova scommessa: con un round attivo il costo fissato all'apertura del round (rounds.bet_amount_sats), senza round attivo quello configurato — da implementare (B-95); oggi sempre quello configurato
      jackpot_sats intero 70% (arrotondato per difetto) della somma degli importi effettivi delle scommesse confermate; limite superiore, perché la commissione del pagamento è ancora da sottrarre
      pending_participant_count intero tutti i partecipanti, confermati e in volo (totale, non differenza)
      pending_jackpot_sats intero 70% della somma di tutte le scommesse, confermate e in volo
      has_pending_bets booleano vero se pending_participant_count > participant_count
      draw_animation_seconds intero durata minima dell'animazione dell'estrazione, dalla configurazione
      winner_user_id intero o null vincitore, valorizzato dall'estrazione in poi (paying_out)
      winner_amount_sats intero o null importo al vincitore, valorizzato solo quando la transazione di pagamento è stata costruita
      draw_block_height intero o null altezza del blocco usato per l'estrazione
      draw_block_hash stringa o null hash di quel blocco; è uno degli ingressi del seme, non il seme (vedi API-45)
      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, 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, 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.

    • Errori: nessuno previsto (un token invalido non è un errore, API-07).

    • Effetti: non apre round (a differenza di POST /bets); crea la riga di configurazione con i valori predefiniti se non esiste ancora. Codice: app/api/routes/rounds.py:current_round, app/rounds/service.py:get_active_round, app/rounds/service.py:round_deadline, app/rounds/service.py:winner_share, app/rounds/service.py:rounds_can_open · Test: tests/unit/test_rounds_route.py Stato: da implementare (B-82, B-95), per i campi marcati · Oggi: bet_amount_sats è sempre il costo configurato.

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.
    • 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) 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 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 (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, 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) in una finestra mobile, verificata prima dell'inserimento e consumata solo da un inserimento riuscito, anche per un chiamante autenticato; soglie in 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) 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). Gli eventi di audit citati sono descritti in audit-log.md; il significato dei parametri operativi in ciclo-di-vita-del-round.md e modello-dati.md.

GET /admin/config

  • [API-30] Legge la configurazione operativa (la riga unica di round_config, creata con i valori predefiniti se manca).
    • Risposta 200: {"fee_address", "bet_amount_sats", "round_duration_seconds", "round_cooldown_seconds", "fee_rate_sat_vb", "rbf_timeout_seconds", "draw_animation_seconds", "deposit_confirmations", "draw_confirmations", "max_round_pool_sats", "paused", "paused_reason"}. Codice: app/api/routes/admin.py:read_config · Test: tests/unit/test_admin.py Stato: da implementare (B-82, B-95) · Oggi: mancano deposit_confirmations e max_round_pool_sats.

PUT /admin/config

  • [API-31] Modifica a caldo uno o più parametri operativi.
    • Corpo: tutti i campi facoltativi; quelli assenti o null non cambiano.

      Campo Vincoli
      fee_address spazi rimossi; bech32 PLM valido (plm1…). Non può essere svuotato tramite l'API
      bet_amount_sats > 0 e ≤ 10 000 000 000 000 (100 000 PLM)
      round_duration_seconds 30 – 604 800 (7 giorni)
      round_cooldown_seconds 0 – 86 400
      fee_rate_sat_vb 1 – 10 000 (MAX_FEE_RATE_SAT_VB)
      rbf_timeout_seconds 60 – 604 800
      draw_animation_seconds 0 – 600
      deposit_confirmations 1 – 100 — da implementare (B-82)
      draw_confirmations 1 – 20
      max_round_pool_sats 0 (disattivato), oppure da bet_amount_sats a 4 000 000 000 000 000 — da implementare (B-95)

      I limiti sono quelli di modello-dati.md (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.

    • Errori: 422 per qualunque vincolo violato (nessun campo viene applicato).

    • Effetti: scrive config_updated con i valori prima/dopo dei soli campi davvero cambiati; nessuna scrittura se nulla cambia. Durata, pausa tra round, costo della scommessa, fee_address e draw_confirmations si applicano dal round successivo (per costo e fee_address: da implementare, B-95; oggi subito). Nessuna notifica SSE. Codice: app/api/routes/admin.py:update_config, app/api/routes/admin.py:RoundConfigUpdate · Test: tests/unit/test_admin.py Stato: da implementare (B-82, B-95), per i due campi nuovi e la copia all'apertura di costo ed fee_address · Oggi: sono accettati i primi otto campi.

POST /admin/pause e POST /admin/resume

  • [API-32] Attivano e disattivano la pausa: impedisce l'apertura del prossimo round, il round in corso prosegue fino al pagamento (salvo un pagamento trattenuto, vedi sotto). La semantica di paused_reason è in modello-dati.md (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, 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 Stato: da implementare (B-82), per l'azzeramento di payout_blocked_at · Oggi: rounds.payout_blocked_at non esiste ancora; le pause automatiche esistenti sono solo resurrected_tx.

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).
    • Parametro: user_id intero.
    • Risposta 200: {"address", "wif"}.
    • Errori: 404 user_not_found (oggi non strutturato, "user not found", API-16); 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); 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. Codice: app/api/routes/admin.py:list_rounds · Test: tests/unit/test_admin.py Stato: da implementare (B-79, B-82), per gli ultimi due campi · Oggi: la lista si ferma a draw_height.

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. 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 ELC-19) e last_rotation (motivo e ora dell'ultimo cambio di server, ad esempio punta ferma, connessione-electrum.md ELC-21). Codice: app/api/routes/admin.py, app/electrum/listener.py:ElectrumListener.current_endpoint · Test: — Stato: da implementare (B-86, B-92) · Oggi: l'endpoint non esiste; /admin mostra solo l'altezza della catena letta da GET /rounds/current.

GET /admin/bug-reports

  • [API-39] Tutte le segnalazioni, dalla più recente.
    • Query: limit intero 1–500, predefinito 200.
    • Risposta 200: lista di {"id", "description", "contact", "user_id", "username", "status", "created_at"}; user_id e username sono null per le segnalazioni anonime. Codice: app/api/routes/admin.py:list_bug_reports · Test: tests/unit/test_bug_reports.py

POST /admin/bug-reports/{report_id}/status

  • [API-40] Cambia lo stato di una segnalazione.
    • Corpo: {"status": "open" | "read" | "resolved"}; qualunque transizione è ammessa, anche all'indietro.
    • Risposta 200: la segnalazione aggiornata, come API-39.
    • Errori: 422 (stato sconosciuto); 404 bug_report_not_found (oggi non strutturato, "bug report not found", API-16).
    • Effetti: scrive bug_report_status_changed (con stato precedente e nuovo) solo se lo stato cambia davvero. Codice: app/api/routes/admin.py:update_bug_report_status · Test: tests/unit/test_bug_reports.py

Servizio

GET /health

  • [API-41] Risponde sempre 200 {"status": "ok"} finché il processo serve richieste. Non verifica né il database né la connessione Electrum: per lo stato della rete si usa chain_tip_height di GET /rounds/current. È il controllo di vitalità (il processo risponde); quello di prontezza è [API-46]. Codice: app/main.py:health

GET /health/ready

  • [API-46] Risponde 200 {"status": "ready", "checks": {...}} solo se tutte queste condizioni sono vere, altrimenti 503 con lo stesso corpo e "status": "not_ready":

    • la connessione Electrum è attiva (listener.client non nullo);
    • l'ultimo header è stato accettato da meno di 360 secondi (connessione-electrum.md 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 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, B-90) — da implementare no — da aggiungere
not_authenticated 401 dipendenza Bearer di FastAPI — header Authorization assente — oggi detail stringa — da implementare (B-94) no — da aggiungere
invalid_admin_token 403 app/api/routes/admin.py:require_admin — token admin assente o errato — oggi detail stringa — da implementare (B-94) no — da aggiungere
user_not_found 404 app/api/routes/admin.py — utente inesistente — oggi detail stringa — da implementare (B-94) no — da aggiungere
bug_report_not_found 404 app/api/routes/admin.py — segnalazione inesistente — oggi detail stringa — da implementare (B-94) no — da aggiungere
round_not_found 404 app/api/routes/rounds.py (GET /rounds/{id}/proof) — round inesistente 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) 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), invalid_amount (validazione lato client del modulo di prelievo) e guide_unavailable (attualmente non usata da nessun file).