Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto the round when it opened. The cost of a bet and the fee address were read live, so an edit from /admin in the middle of a round gave the players of that round different stakes for the same chance and redirected the commission of the round in progress. - `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column, existing rounds backfilled with the current configuration, i.e. what they were using; the expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the round's fee address (an empty one on the row still stops the payout, as before), `GET /rounds/current` advertises the round's cost while one is active and the configured one otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10). - `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included, has reached the round's cap, before anything moves; the pool can exceed it by one bet at most. A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`. - PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the bet cost the round in progress opened with, which can be lower than the configured one. - `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the seven translations are generic. Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and "applies immediately, also to the round in progress" in the round_config table of modello-dati; the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line). Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and the old "fee address missing" payout test now empties the round's address (clearing it in the configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open, edits reaching only the next round, every bet of a round paying the same stake whether the cost is raised or lowered meanwhile, the payout following the round's address (and surviving it being cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap, a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route, the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the migration backfill. Mutation-checked: charging the live cost, paying the live address, not copying the cap and `>` instead of `>=` each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
57 KiB
Architettura
Come è fatto il software dall'interno: quali processi girano, come è organizzato il codice, cosa succede all'avvio, quali attività lavorano in background, dove sta la configurazione, come le pagine web restano aggiornate e come è strutturata la suite di test.
Questo documento non descrive i flussi di dominio (scommesse, round, depositi…): per quelli rimanda ai documenti che li possiedono, elencati in come-funziona.md. Le tabelle del database sono in modello-dati.md, gli endpoint in api.md, le difese di sicurezza in sicurezza.md.
Panoramica del deployment
flowchart LR
B["Browser<br/>(/, /admin, /report-bug)"] -- "HTTPS 443<br/>(80 → redirect)" --> C["Container caddy<br/>reverse proxy + TLS"]
C -- "HTTP app:8123<br/>(rete interna compose)" --> A["Container app<br/>1 processo uvicorn<br/>FastAPI + 6 task asyncio"]
A -- "aiosqlite" --> D[("./data/db/plm_lottery.db<br/>SQLite, WAL")]
A -- "legge" --> K["./data/keys/master.xprv.enc"]
A -- "scrive" --> L["./data/logs/app.log<br/>(e stdout, B-92)"]
A -- "Electrum (TCP/SSL)<br/>una connessione attiva" --> E["Server Electrum PLM<br/>primario + riserve"]
-
[ARC-01] Il server gira sempre in Docker, con lo stesso
docker-compose.ymlin sviluppo e in produzione: due container,app(questo codice) ecaddy(reverse proxy con TLS automatico). Cambia soloSITE_ADDRESS, che sta in.enved è obbligatorio: il compose lo legge come${SITE_ADDRESS:?}e si rifiuta di partire se manca.localhostproduce un certificato autofirmato dalla CA interna di Caddy, un dominio un certificato Let's Encrypt. Non esiste un modo supportato di lanciareuvicornfuori da Docker. Codice:docker-compose.yml·Caddyfile· Test:tests/unit/test_docs_no_bare_uvicorn.pyStato: da implementare (B-93) · Oggi:SITE_ADDRESSsi passa sul comando e non impostato valelocalhost, quindi undocker compose upsenza variabile riporta un server di produzione sulocalhost. -
[ARC-02] Il container
appesegue un solo processo uvicorn (nessun--workers), in ascolto su0.0.0.0:8123, con--proxy-headerse--forwarded-allow-ipslimitato all'indirizzo di Caddy: la rete di compose ha una sottorete fissa e Caddy un indirizzo IPv4 fisso in essa. Così il log di accesso registra l'IP del client invece di quello di Caddy. Tutto lo stato in memoria descritto in Assunzioni di processo singolo dipende da questo. Codice:Dockerfile(CMD) ·docker-compose.ymlStato: da implementare (B-92) · Oggi: uvicorn non legge gli header del proxy e il log di accesso registra l'IP di Caddy. -
[ARC-03] All'avvio il container
app, nell'ordine: rifiuta di partire se il file indicato daMASTER_KEY_PATHnon esiste (stampando il comando per generarlo), poi eseguealembic upgrade head, poi avvia uvicorn conexec. Le migrazioni non si lanciano mai a mano. Il processo gira come utente non root, su un'immagine di base fissata per digest e con dipendenze installate da un file di lock con gli hash (sicurezza.md SEC-51, SEC-52, SEC-53 (B-91)). I controlli che richiedono il codice applicativo (chiave, quorum, ripristino) sono nel lifespan ([ARC-60]). Codice:Dockerfile(CMD) ·migrations/env.pyStato: da implementare (B-91) · Oggi: il container gira come root,python:3.12-slimnon è fissato per digest e le dipendenze sono>=. -
[ARC-04]
appè soloexposed sulla rete interna di compose (porta 8123), mai pubblicato sull'host: l'unico ingresso è Caddy, che pubblica 80 e 443. Su questo si basa la lettura dell'IP del client (vedi sicurezza.md). Caddy non comprime/rounds/stream, perché la compressione accumulerebbe gli eventi SSE invece di consegnarli subito. Codice:docker-compose.yml·Caddyfile· Test:tests/unit/test_caddyfile_security_headers.py -
[ARC-05] I dati persistenti stanno in
./data/sull'host, montata nel container:./data/db→/app/db_data,./data/keys→/app/key_data,./data/logs→/app/logs. Il compose sovrascriveDATABASE_URLeMASTER_KEY_PATHperché puntino a quei percorsi; tutte le altre variabili arrivano da.env(env_file). Entrambi i container hannorestart: unless-stopped. La chiave di cifratura della master key è un file a parte,./secrets/xprv_encryption.key(permessi0600, fuori da./data/così un backup dei dati non la contiene), montato in sola lettura come secret di compose in/run/secrets/xprv_encryption_keye indicato daXPRV_ENCRYPTION_KEY_FILE([ARC-30]). Le directory di./data/hanno permessi0700, i file di chiave0600. Le immagini sono etichettate per rilascio, così un rollback sa quale immagine rimettere (operativita.md (B-93)). Codice:docker-compose.ymlStato: da implementare (B-91, B-93) · Oggi: la chiave di cifratura è la variabileXPRV_ENCRYPTION_KEYin.env, i permessi sono quelli di default (.enve file di chiave leggibili da tutti) e l'immagine non ha etichetta. -
[ARC-61] Salute del container. Il container
appha unhealthcheckche interrogahttp://127.0.0.1:8123/health/readyogni 30s (timeout 5s, 3 tentativi,start_period60s)./health/readyrisponde 200 solo se la connessione Electrum è attiva, l'ultimo header è recente e lo scheduler ha battuto da poco; le soglie sono di api.md (B-92). Lo scheduler aggiorna il battito a ogni giro e a ogni iterazione dell'attesa del blocco di estrazione, perché quell'attesa gira dentro il giro ([ARC-27]) e dura più blocchi. Docker non riavvia un containerunhealthy: lo stato serve al monitoraggio (operativita.md), e il riavvio resta affidato arestart: unless-stoppedquando il processo esce. Codice:docker-compose.yml·app/main.py:health· Test: — Stato: da implementare (B-92) · Oggi: nessunhealthcheck; esiste solo/health, che risponde sempreok([ARC-17]).
Database
- [ARC-06] Il database è SQLite, usato tramite SQLAlchemy 2 in modalità
asincrona (
create_async_enginesuaiosqlite). Una solaenginee una sola fabbrica di sessioni (AsyncSessionLocal, conexpire_on_commit=False) servono sia le richieste HTTP (dipendenzaget_session) sia le attività in background, che aprono ciascuna le proprie sessioni. Codice:app/db/base.py·app/db/session.py:get_session - [ARC-07] A ogni nuova connessione SQLite vengono impostati
journal_mode=WAL,synchronous=FULLebusy_timeout=5000ms: in WAL lettori e scrittori non si bloccano a vicenda, uno scrittore che trova il database occupato aspetta fino a 5 secondi invece di fallire subito, eFULLfa sì che un commit riuscito sopravviva a una mancanza di corrente (conNORMALsi potevano perdere gli ultimi commit, per esempio una registrazione, riusandone poi l'indice di derivazione). Ogni transazione che sposta denaro si apre conBEGIN IMMEDIATE, così le letture su cui decide sono isolate e l'attesa del lock avviene all'inizio, non alla prima scrittura; undatabase is lockeddopo un invio è gestito esplicitamente (la riga resta al riconciliatore). Con un dialetto diverso da SQLite le pragma non vengono applicate. Codice:app/db/base.py:_register_sqlite_pragmas·app/db/base.py:begin_immediate· Test:tests/unit/test_db_base.py·tests/unit/test_sqlite_transactions.py - [ARC-08] Lo schema è definito in
app/db/models.pyed evolve solo tramite migrazioni Alembic inmigrations/versions/(generate conalembic revision --autogenerate);migrations/env.pyprende l'URL del database dasettings.database_url. I campi delle tabelle sono descritti in modello-dati.md. Codice:migrations/env.py·app/db/models.py
Log
- [ARC-09] Tutti i log dell'applicazione e di uvicorn (
uvicorn.error,uvicorn.access) finiscono inlogs/app.log(nel container/app/logs/app.log, cioè./data/logs/app.logsull'host), a rotazione da 10 MB con 5 file di storico, livelloINFO, e anche su stdout, cosìdocker compose logsli mostra senza entrare nel volume. La configurazione avviene all'import diapp/main.py, prima di qualunque altro modulo applicativo. Codice:app/logging_config.py:setup_loggingStato: da implementare (B-92) · Oggi: i log dell'applicazione vanno solo nel file; su console arrivano solo quelli di uvicorn, per la sua configurazione predefinita.
Mappa del codice
Ogni file sotto app/, raggruppato per pacchetto. I file __init__.py
sono vuoti e non sono elencati.
Radice e infrastruttura
| File | Responsabilità |
|---|---|
app/main.py |
Punto d'ingresso: lifespan (validazione segreti, avvio e arresto dei sei task), inclusione dei router, gestore degli errori non previsti, pagine HTML, /health, mount dei file statici |
app/config.py |
Settings (variabili d'ambiente / .env), istanza settings, validate_runtime_secrets, ConfigError |
app/logging_config.py |
setup_logging: file di log a rotazione |
app/db/base.py |
engine, AsyncSessionLocal, pragma SQLite, Base dichiarativa |
app/db/session.py |
get_session, dipendenza FastAPI che apre una sessione per richiesta |
app/db/models.py |
Tutte le tabelle (User, UtxoEvent, Round, RoundParticipant, RoundConfig, PendingTransaction, Withdrawal, BugReport, AuditLog) e l'indice del round attivo unico — vedi modello-dati.md |
app/audit/log.py |
write_audit_log: aggiunge una riga al registro di audit nella sessione del chiamante (il commit resta al chiamante) — catalogo eventi in audit-log.md |
API HTTP
| File | Responsabilità |
|---|---|
app/api/errors.py |
ApiError (codice + messaggio inglese + parametri), http_error, from_api_error: il formato strutturato di detail — vedi api.md |
app/api/client_ip.py |
client_ip: IP reale del chiamante (ultimo elemento di X-Forwarded-For), usato da tutti i controlli basati sull'IP |
app/api/timeutil.py |
isoformat_utc: serializza le date come UTC esplicito (SQLite le restituisce senza fuso) |
app/api/routes/admin.py |
/admin/*: require_admin (token condiviso), configurazione, pausa/ripresa, utenti, export WIF, reset password, round, transazioni pendenti, audit log, bug report |
app/api/routes/bets.py |
POST /bets: controllo connessione, lock per utente, chiamata a place_bet |
app/api/routes/withdrawals.py |
POST /withdrawals: stessa struttura, chiama request_withdrawal |
app/api/routes/rounds.py |
GET /rounds/current e il canale SSE GET /rounds/stream |
app/api/routes/users.py |
GET /users/me, cambio password, GET /users/me/last-round-result |
app/api/routes/qr.py |
GET /qr/{address}: PNG del QR, indirizzo validato, rendering memorizzato e fuori dall'event loop |
app/api/routes/bug_reports.py |
POST /bug-reports, GET /bug-reports/mine |
Autenticazione
| File | Responsabilità |
|---|---|
app/auth/routes.py |
/auth/register, /auth/login; notifica al listener il nuovo indirizzo da osservare |
app/auth/security.py |
Argon2 (versioni sincrone e *_async su threadpool), creazione e decodifica JWT, MIN_PASSWORD_LENGTH |
app/auth/dependencies.py |
get_current_user, get_optional_user (controllo del claim tv) |
app/auth/rate_limit.py |
RateLimiter, RollingQuota, AuthRateLimiters (tenuti su app.state) — vedi sicurezza.md |
Wallet e chiavi
| File | Responsabilità |
|---|---|
app/wallet/plm_network.py |
Parametri della rete PLM per embit (PLM_MAINNET), coin type 746, ACCOUNT_PATH |
app/wallet/keystore.py |
Cifratura/decifratura Fernet della master xprv |
app/wallet/hd.py |
Generazione della master key, derivazione di indirizzi/chiavi utente (0/i), del montepremi di ogni round (2/<round_id>, B-80) e dell'indirizzo di riserva (1/0), derive_round_pool_key che sceglie la chiave che controlla l'indirizzo salvato sul round, export WIF; la chiave d'account decifrata resta in cache nel processo |
app/wallet/address.py |
is_valid_plm_address: checksum bech32 + HRP plm |
app/wallet/balance.py |
recompute_balance (saldo confermato), compute_pending_balance (saldo mostrato) — vedi registrazione-e-depositi.md |
app/wallet/psbt_builder.py |
Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti MAX_TX_INPUTS, MAX_PAYOUT_TX_INPUTS, MAX_PARTICIPANTS_PER_ROUND, DUST_LIMIT_SATS; l'uscita di riserva per l'RBF sul pagamento (payout_bump_reserve, B-79) |
Rete Electrum
| File | Responsabilità |
|---|---|
app/electrum/client.py |
ElectrumClient (JSON-RPC, timeout per richiesta), ElectrumEndpoint, parse_endpoints; limite di lettura di 16 MiB per riga, risposta troppo grande o malformata come errore della richiesta e non della connessione. Da implementare: pin del certificato per endpoint (B-84) |
app/electrum/listener.py |
ElectrumListener: l'unica connessione, rotazione, keepalive, validazione header, corroborazione, sottoscrizioni e accredito — vedi connessione-electrum.md. Il docstring della classe cita un metodo add_address che non esiste: l'indirizzo di un nuovo utente si aggiunge con address_for_new_user |
app/electrum/scripthash.py |
address_to_scripthash: scripthash Electrum di un indirizzo |
Dominio
| File | Responsabilità |
|---|---|
app/deposits/service.py |
Accredito dei depositi, UTXO spesi fuori piattaforma e loro ripristino |
app/deposits/reconcile.py |
DepositReconciler: ricontrollo periodico di tutti gli indirizzi |
app/bets/service.py |
place_bet, BetError, rilascio di una scommessa fallita |
app/bets/confirmation.py |
Gestore di conferma per il tipo bet |
app/withdrawals/service.py |
request_withdrawal, WithdrawalError, rilascio di un prelievo fallito |
app/withdrawals/confirmation.py |
Gestore di conferma per il tipo withdrawal |
app/rounds/service.py |
Round attivo, scadenza, accettazione scommesse, rounds_can_open, apertura del round, quota del vincitore |
app/rounds/scheduler.py |
RoundScheduler: chiusura, estrazione, pagamento e suoi tentativi |
app/rounds/draw.py |
Matematica degli header (hash, prev-hash, target) e scelta del vincitore. Seme commit-reveal e impronta dei partecipanti |
app/rounds/config.py |
get_round_config: la riga unica di round_config, creata se manca |
app/rounds/confirmation.py |
Gestore di conferma per il tipo payout |
app/rounds/events.py |
RoundEventBroadcaster e il singleton broadcaster (SSE) |
Flussi descritti in scommesse-e-prelievi.md, ciclo-di-vita-del-round.md e registrazione-e-depositi.md.
Transazioni
| File | Responsabilità |
|---|---|
app/tx/confirmation.py |
ConfirmationPoller, poll_once, registro dei gestori (register_handler) |
app/tx/broadcast.py |
RbfBumper, should_bump, bump_fee |
app/tx/reconcile.py |
PendingTransactionReconciler, reconcile_once: promozione o abbandono delle righe building/pending |
app/tx/pending_address.py |
own_address_for: indirizzo proprietario degli input di una transazione (utente o pool) |
app/tx/locks.py |
UserLocks: lock asincrono per utente |
Dettagli in transazioni-e-riconciliazione.md.
Frontend statici
| File | Responsabilità |
|---|---|
app/static/index.html |
Pagina / per i giocatori |
app/static/app.js |
Logica di /: autenticazione, pannelli, polling, SSE, rivelazione del risultato |
app/static/style.css |
Stile di / (e di /report-bug) |
app/static/i18n.js |
Tabella TRANSLATIONS in 7 lingue, t(), tOrNull(), setLanguage(), applyStaticTranslations() |
app/static/report-bug.html |
Pagina /report-bug, con il proprio script inline |
app/static/admin.html |
Pannello /admin |
app/static/admin.js |
Logica del pannello: token, sezioni, polling dello stato catena, SSE |
app/static/admin.css |
Stile del pannello |
app/static/guida.html |
Pagina /guida, per ora un segnaposto |
Dipendenze tra pacchetti
Il grafo riporta gli import tra i pacchetti di app/ (a livello di pacchetto,
non di singolo modulo). db e config sono importati quasi ovunque e sono
omessi dalle frecce per leggibilità.
flowchart TD
main["main"] --> routes["api.routes"]
main --> auth
main --> tx
main --> rounds
main --> electrum
main --> deposits
main --> bets
main --> withdrawals
routes --> auth
routes --> bets
routes --> withdrawals
routes --> rounds
routes --> wallet
routes --> audit
routes --> apiutil["api (errors, client_ip, timeutil)"]
auth --> apiutil
auth --> wallet
bets --> apiutil
bets --> rounds
bets --> wallet
bets --> audit
bets -- "register_handler" --> tx
withdrawals --> apiutil
withdrawals --> rounds
withdrawals --> wallet
withdrawals --> audit
withdrawals -- "register_handler" --> tx
rounds --> electrum
rounds --> wallet
rounds --> audit
rounds -- "register_handler" --> tx
electrum --> deposits
electrum -- "draw (header math), events" --> rounds
deposits --> electrum
deposits --> wallet
deposits --> audit
deposits -- "events" --> rounds
tx --> wallet
tx --> electrum
tx --> audit
tx -- "events, config" --> rounds
- [ARC-10] Il poller delle conferme (
app/tx/confirmation.py) non importa i moduli di dominio: sonoapp/bets/confirmation.py,app/rounds/confirmation.pyeapp/withdrawals/confirmation.pya registrarsi conregister_handler(kind, handler)al momento dell'import, per i tipibet,payoutewithdrawal. Codice:app/tx/confirmation.py:register_handler·app/bets/confirmation.py·app/rounds/confirmation.py·app/withdrawals/confirmation.py· Test:tests/unit/test_confirmation.py - [ARC-11] Gli errori di dominio destinati all'utente sono sottoclassi di
ApiError(BetError,WithdrawalError) sollevate direttamente dai servizi, con il codice che il frontend traduce; le route li convertono inHTTPExceptionscegliendo lo stato HTTP. Per questobetsewithdrawalsdipendono daapp/api/errors.py. Codice:app/api/errors.py:ApiError·app/bets/service.py:BetError·app/withdrawals/service.py:WithdrawalError
Avvio del processo
app/main.py fa due cose distinte: all'import costruisce l'applicazione
FastAPI; all'avvio del server (il lifespan) prepara lo stato condiviso
e lancia le attività in background.
sequenceDiagram
participant U as uvicorn
participant M as app/main.py (import)
participant L as lifespan
U->>M: import app.main
M->>M: setup_logging()
M->>M: import dei gestori di conferma (bet, payout, withdrawal)
M->>M: FastAPI(...), include_router ×8, exception handler, route HTML, mount /
U->>L: startup
L->>L: validate_runtime_secrets()
L->>L: parse_endpoints(...)
L->>L: quorum minimo (validate_quorum_servers)
L->>L: verifica della master key (B-89, da implementare)
L->>L: START_PAUSED (B-90, da implementare)
L->>L: app.state.electrum_listener, app.state.user_locks
L->>L: create_task ×6
Note over L: il server serve richieste<br/>(controllo dei buchi di indici alla prima connessione, B-90)
U->>L: shutdown
L->>L: chiusura dei flussi SSE (B-93, da implementare)
L->>L: cancel() dei 6 task (e attesa della fine, B-93), chiusura del client Electrum
Il diagramma mostra il comportamento atteso; i passi marcati «da implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
All'import
- [ARC-12]
setup_logging()è chiamata come prima istruzione applicativa diapp/main.py, prima di importare qualunque altro moduloapp.*, così anche i log emessi durante gli import finiscono nel file. Codice:app/main.py·app/logging_config.py:setup_logging - [ARC-13]
app/main.pyimportaapp.bets.confirmation,app.rounds.confirmationeapp.withdrawals.confirmationsolo per il loro effetto collaterale (la registrazione del gestore, vedi [ARC-10]). Questi import non vanno rimossi: senza gestore, il poller segnerebbe comunque laPendingTransactioncomeconfirmed, ma senza aggiornare il partecipante, il round o il prelievo corrispondente. Codice:app/main.py·app/tx/confirmation.py:poll_once - [ARC-14] La documentazione automatica (
/docs,/redoc,/openapi.json) è disattivata a meno cheENABLE_API_DOCSsia vero. Codice:app/main.py· Test:tests/unit/test_docs_disabled.py - [ARC-15] I router vengono inclusi in quest'ordine:
auth,users,bets,withdrawals,admin,bug_reports,qr,rounds. Gli endpoint sono in api.md. Codice:app/main.py - [ARC-16] Qualunque eccezione non gestita produce una risposta 500 con
lo stesso formato degli errori voluti
(
{"detail": {"code": "internal_error", "message": "internal server error", "params": {}}}); il dettaglio dell'eccezione va solo nel log, mai nella risposta. Codice:app/main.py:log_unhandled_exception· Test:tests/unit/test_rounds_route.py - [ARC-17]
GET /healthrisponde{"status": "ok"}senza toccare database né rete: dice solo che il processo risponde (liveness). Se il servizio è davvero in grado di lavorare lo diceGET /health/ready([ARC-61], api.md (B-92)). Codice:app/main.py:health - [ARC-18] Le quattro pagine HTML hanno route esplicite che servono il
file con
Cache-Control: no-store:/→index.html,/admin→admin.html,/guida→guida.html,/report-bug→report-bug.html. Per ultimo, l'intera cartellaapp/static/è montata su/conStaticFiles(html=True): JS, CSS e ogni altro file vi sono serviti con il loro nome (/app.js,/i18n.js,/admin.css…), conCache-Control: no-cache(il browser li riconvalida a ogni caricamento). I percorsi sono relativi alla directory di lavoro, che nel container è/app. Codice:app/main.py:index_page·app/main.py:admin_panel·app/main.py:user_guide·app/main.py:report_bug_pageStato: da implementare (B-94) · Oggi:Cache-Controlc'è solo sulle quattro pagine HTML; i file serviti daStaticFiles(e/index.htmlchiesto per nome) non ne hanno, e il browser può usare uno script vecchio con una pagina nuova.
Nel lifespan
-
[ARC-19] Come primo passo,
validate_runtime_secrets()fa rifiutare l'avvio (sollevaConfigError) seJWT_SECRETè più corto di 32 caratteri o la chiave di cifratura della master key è vuota (il file indicato daXPRV_ENCRYPTION_KEY_FILEmanca o è vuoto), riportando tutti i problemi insieme.ADMIN_TOKENvuoto non blocca l'avvio: il pannello resta semplicemente chiuso. Il controllo sta nel lifespan e non in un validatore diSettings, così import e test non hanno bisogno di segreti veri. Codice:app/config.py:validate_runtime_secrets· Test:tests/unit/test_config.pyStato: da implementare (B-91) · Oggi: si controlla che la variabileXPRV_ENCRYPTION_KEYnon sia vuota. -
[ARC-20] Gli endpoint Electrum vengono letti da
ELECTRUM_HOST,ELECTRUM_PORT,ELECTRUM_USE_SSLedELECTRUM_FALLBACK_SERVERS(host:porta[:notls][:pin=<sha256>]separati da virgola, [ARC-30]); un elenco di riserva malformato blocca l'avvio. L'ordine di rotazione viene scritto nel log. Codice:app/electrum/client.py:parse_endpoints· Test:tests/unit/test_electrum_client.py -
[ARC-60] Controlli di avvio. Dopo [ARC-19] e [ARC-20] e prima di creare i task, il lifespan esegue nell'ordine, e ogni controllo fallito impedisce di servire:
- quorum minimo: almeno 2 server di riserva, salvo
ALLOW_NO_QUORUM=true(solo sviluppo, scritto nel log come avvertimento) — regola in connessione-electrum.md (B-84); - master key: la chiave viene decifrata e l'indirizzo di riserva del montepremi e un campione degli indirizzi utente salvati vengono ricalcolati e confrontati con il database — regola in sicurezza.md (B-89);
- avvio in pausa: con
START_PAUSED=truela lotteria viene messa in pausa prima che lo scheduler parta — procedura in operativita.md (B-90).
Il controllo dei buchi negli indici di derivazione dopo un ripristino (i 20 indici successivi al più alto assegnato, che non devono avere cronologia in catena) ha bisogno della rete, quindi non può stare prima dei task: gira alla prima connessione del listener, e finché non si è concluso senza trovare cronologia la registrazione è rifiutata (operativita.md, B-90). Codice:
app/main.py:lifespan·app/config.py:validate_runtime_secrets·app/config.py:validate_quorum_servers· Test:tests/unit/test_electrum_listener.pyStato: da implementare (B-89, B-90), per i controlli 2 e 3 · Oggi: dopo i segreti, gli endpoint e il quorum il lifespan crea subito i task; la chiave si decifra alla prima registrazione o al primo pagamento. - quorum minimo: almeno 2 server di riserva, salvo
-
[ARC-21] Lo stato condiviso con le route vive su
app.state:electrum_listener(l'ElectrumListener) euser_locks(unUserLocks), creati nel lifespan;auth_rate_limitersviene invece creato alla prima richiesta di registrazione o login che ne ha bisogno. Codice:app/main.py:lifespan·app/auth/routes.py:_rate_limiters -
[ARC-22] Il lifespan crea esattamente sei task asyncio, nell'ordine
ElectrumListener,RoundScheduler,ConfirmationPoller,RbfBumper,PendingTransactionReconciler,DepositReconciler. Allo spegnimento, in quest'ordine: ogni flusso SSE aperto viene chiuso (altrimenti uvicorn aspetta le connessioni aperte e Docker lo uccide), ciascun task ricevecancel()e il lifespan ne attende la fine, poi il client Electrum, se presente, viene chiuso. Il compose dà al containerstop_grace_period: 30sper completare questa sequenza prima delSIGKILL. Codice:app/main.py:lifespan·app/rounds/events.py:RoundEventBroadcaster·docker-compose.yml· Test:tests/unit/test_code_comments.pyStato: da implementare (B-93) · Oggi: i task ricevonocancel()senza attesa, i flussi SSE restano aperti, e con uno stream collegato Docker uccide il container dopo i 10s predefiniti.
Attività in background
| Task | Costruito con | Cadenza | Ruolo |
|---|---|---|---|
ElectrumListener |
_make_electrum_client, AsyncSessionLocal, endpoint |
ciclo di riconnessione; ping ogni 60s | L'unica connessione Electrum: header, sottoscrizioni, accredito depositi |
RoundScheduler |
AsyncSessionLocal, listener |
5s | Apre/chiude i round, estrae, avvia e ritenta i pagamenti |
ConfirmationPoller |
AsyncSessionLocal, lambda: listener.client |
10s | pending → confirmed e gestore per tipo |
RbfBumper |
AsyncSessionLocal, lambda: listener.client |
30s | Aumenta la commissione di ciò che è pending oltre rbf_timeout_seconds |
PendingTransactionReconciler |
AsyncSessionLocal, lambda: listener.client |
subito, poi 120s | Promuove o abbandona le righe building/pending |
DepositReconciler |
AsyncSessionLocal, listener |
300s, attende prima del primo giro | refresh_user su ogni indirizzo |
-
[ARC-23] Accesso al client Electrum. Nessun task tiene un riferimento fisso al client:
ConfirmationPoller,RbfBumperePendingTransactionReconcilerricevonolambda: listener.cliente lo rileggono a ogni giro;RoundSchedulereDepositReconcilerricevono il listener intero e leggonolistener.client(il secondo anche per chiamarerefresh_user). Così una riconnessione sostituisce il client sotto tutti. Un client non nullo implica che l'altezza della catena sia già nota (vedi connessione-electrum.md). Codice:app/main.py:lifespan -
[ARC-24] Client assente. Quando
listener.clientèNoneogni task salta il giro e riprova alla cadenza successiva;DepositReconcilerinterrompe anche una scansione in corso. Le route che devono trasmettere (POST /bets,POST /withdrawals) rispondono invece503network_unavailable. Codice:app/rounds/scheduler.py:RoundScheduler._tick·app/tx/confirmation.py:ConfirmationPoller.run·app/tx/broadcast.py:RbfBumper.run·app/tx/reconcile.py:PendingTransactionReconciler.run·app/deposits/reconcile.py:DepositReconciler._sweep_once·app/api/routes/bets.py:create_bet·app/api/routes/withdrawals.py:create_withdrawal· Test:tests/unit/test_deposit_reconcile.py -
[ARC-25] Errori. Ogni ciclo intercetta qualunque
Exceptiondi un giro, la scrive nel log con lo stack e riprende alla cadenza successiva; soloCancelledError(lo spegnimento) esce dal ciclo. All'interno di un giro, poller, bumper, riconciliatore eDepositReconcilerisolano anche il singolo elemento: una transazione o un utente che fallisce non ferma gli altri. Non esiste un supervisore: un task che terminasse non verrebbe riavviato (l'unico caso previsto è il listener senza alcun endpoint, che scrive un errore ed esce;parse_endpointsinclude però sempre il server primario). Codice:app/rounds/scheduler.py:RoundScheduler.run·app/tx/confirmation.py:poll_once·app/tx/broadcast.py:RbfBumper._tick·app/tx/reconcile.py:reconcile_once·app/deposits/reconcile.py:DepositReconciler.run·app/electrum/listener.py:ElectrumListener.run· Test:tests/unit/test_confirmation.py·tests/unit/test_reconcile.py·tests/unit/test_deposit_reconcile.py -
[ARC-26]
ElectrumListener. Dopo ogni sessione terminata passa subito al server successivo; l'attesa crescente (1s raddoppiando fino a 30s) scatta solo quando tutti i server hanno fallito nello stesso giro. Rotazione, keepalive, validazione e corroborazione sono in connessione-electrum.md. Tre cambiamenti di struttura sono attesi dal piano, con le regole in quel documento:- il listener conserva gli ultimi 10 header ricevuti; un salto di più blocchi scarica e valida gli header intermedi, e un header che si concatena a uno di quelli conservati ma non alla punta è una riorganizzazione da seguire, non la fine della sessione;
- una sessione connessa la cui punta non avanza da 360s, mentre un altro server ne riporta una più alta, viene chiusa e si passa al server successivo (B-86);
- il client legge righe fino a 16 MiB, e una risposta oltre il limite o malformata fa fallire la sola richiesta, non la connessione (B-75).
Codice:
app/electrum/listener.py:ElectrumListener.run·app/electrum/listener.py:ElectrumListener._apply_header·app/electrum/listener.py:ElectrumListener._check_not_stalled·app/electrum/client.py:ElectrumClient.connect· Test:tests/unit/test_electrum_listener.py,tests/unit/test_electrum_client.py -
[ARC-27]
RoundScheduler. A ogni giro (5s) chiamaopen_new_round_if_needede poi avanza il round attivo, in qualunque stato sia,drawingcompreso: per un round indrawingil giro rientra nell'attesa del blocco di estrazione a partire da ciò che è salvato sulla riga del round (draw_height,participants_hash, posizioni dei partecipanti), in modo idempotente. L'attesa resta dentro il giro: finché dura, lo scheduler non fa altro, il che è corretto perché nessun altro round può aprirsi finché questo non èclosed. Un'eccezione durante l'attesa termina il giro ([ARC-25]) e il giro successivo riparte dallo stato salvato, con lo stesso esito; lo stesso vale dopo un riavvio. Logica del round in ciclo-di-vita-del-round.md. Codice:app/rounds/scheduler.py:RoundScheduler._tick·app/rounds/scheduler.py:RoundScheduler._close_and_draw·app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block· Test:tests/unit/test_scheduler.py,tests/unit/test_draw_proof.py -
[ARC-28]
ConfirmationPoller(10s),RbfBumper(30s) ePendingTransactionReconciler(al primo giro subito, poi ogni 120s) lavorano sulla tabellapending_transactions(e sulla tabella di tutti i txid di ogni riga,pending_transaction_txids); il riconciliatore parte subito perché un crash durante un invio lascia proprio una rigabuildingda risolvere al riavvio. Regole in transazioni-e-riconciliazione.md. Codice:app/tx/confirmation.py:ConfirmationPoller·app/tx/broadcast.py:RbfBumper·app/tx/reconcile.py:PendingTransactionReconciler· Test:tests/unit/test_confirmation.py·tests/unit/test_broadcast.py·tests/unit/test_reconcile.py -
[ARC-29]
DepositReconcilerattende 300s, poi scorre tutti gli utenti e chiamalistener.refresh_usersu ciascuno: lo stesso codice usato dalle notifiche, così i due percorsi non possono divergere. Codice:app/deposits/reconcile.py:DepositReconciler._sweep_once· Test:tests/unit/test_deposit_reconcile.py
Configurazione
La configurazione è divisa in due posti con regole diverse.
-
[ARC-30] Segreti e collegamenti infrastrutturali stanno nelle variabili d'ambiente (file
.env, letto daSettings; variabili sconosciute ignorate).settingsè costruito una volta all'import diapp/config.py: una modifica richiede il riavvio del container. I campi sono:Campo ( Settings)Variabile Predefinito Uso database_urlDATABASE_URLsqlite+aiosqlite:///./plm_lottery.dbURL SQLAlchemy (in Docker sovrascritto dal compose) electrum_hostELECTRUM_HOSTsantantonio.sytes.netServer Electrum primario electrum_portELECTRUM_PORT50002Porta del primario electrum_use_sslELECTRUM_USE_SSLTrueTLS verso il primario electrum_fallback_serversELECTRUM_FALLBACK_SERVERS""Riserve, host:porta[:notls][:pin=<sha256>]separati da virgola; almeno 2 ([ARC-60])electrum_cert_pinELECTRUM_CERT_PIN""SHA-256 del certificato DER del primario; vuoto = non fissato (connessione-electrum.md ELC-03) allow_no_quorumALLOW_NO_QUORUMFalsePermette di partire con meno di 2 server di riserva; solo sviluppo ([ARC-60]) xprv_encryption_key_fileXPRV_ENCRYPTION_KEY_FILE/run/secrets/xprv_encryption_keyFile con la chiave Fernet della master xprv (obbligatorio, [ARC-19]); sostituisce XPRV_ENCRYPTION_KEYstart_pausedSTART_PAUSEDFalseAvvia con la lotteria in pausa, dopo un ripristino ([ARC-60]) master_key_pathMASTER_KEY_PATH./master.xprv.encFile della master xprv cifrata (in Docker sovrascritto dal compose) jwt_secretJWT_SECRET""Chiave HS256 dei token (≥ 32 caratteri, [ARC-19]) jwt_algorithmJWT_ALGORITHMHS256Algoritmo JWT jwt_expire_minutesJWT_EXPIRE_MINUTES1440(24h)Durata del token admin_tokenADMIN_TOKEN""Token del pannello /admin; vuoto = pannello chiusoenable_api_docsENABLE_API_DOCSFalseEspone Swagger/ReDoc/OpenAPI ([ARC-14]) Codice:
app/config.py:Settings· Test:tests/unit/test_config.pyStato: da implementare (B-90, B-91) · Oggi:START_PAUSEDeXPRV_ENCRYPTION_KEY_FILEnon esistono, la chiave Fernet è la variabileXPRV_ENCRYPTION_KEY. -
[ARC-31] Parametri operativi e di business (costo della scommessa, durata e pausa del round, animazione, commissione di rete, timeout RBF, indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei depositi
deposit_confirmations, conferme del blocco di estrazionedraw_confirmations, tetto al montepremimax_round_pool_sats) stanno nell'unica riga della tabellaround_config, modificabile a caldo da/adminsenza riavvio. I valori iniziali sono i default delle colonne diRoundConfig, nonapp/config.py. Se la riga manca, viene creata al primo accesso confee_addressvuoto. Campi in modello-dati.md, effetti in ciclo-di-vita-del-round.md. Codice:app/rounds/config.py:get_round_config·app/db/models.py:RoundConfig -
[ARC-32] Alcuni valori sono costanti nel codice e non configurabili: la ripartizione 70/30, le conferme richieste per le transazioni della piattaforma (1) e per le uscite coinbase (120), i limiti di input e di partecipanti, le cadenze dei task. Si cambiano solo con una modifica al codice (vedi limiti-noti.md). Le conferme dei depositi sono invece configurabili ([ARC-31]). Codice:
app/rounds/service.py:winner_share·app/wallet/psbt_builder.py·app/tx/confirmation.py
Aggiornamenti in tempo reale (SSE)
Le pagine si aggiornano da sole in due modi sovrapposti: il polling periodico e, in più, un canale Server-Sent Events che le avvisa appena qualcosa cambia.
flowchart LR
subgraph Processo
P1["round aperto / cambio di stato"] --> BR
P2["scommessa o prelievo inviato / annullato"] --> BR
P3["transazione confermata / abbandonata"] --> BR
P4["deposito accreditato / UTXO spesa o ripristinata"] --> BR
P5["nuovo header"] --> BR
BR["broadcaster.publish()"] --> Q1["coda client 1<br/>(maxsize 1)"]
BR --> Q2["coda client N"]
end
Q1 -- "event: update" --> S1["EventSource /"]
Q2 -- "event: update" --> S2["EventSource /admin"]
S1 -. "rifà le GET REST" .-> API["/rounds/current, /users/me, …"]
-
[ARC-33] Il canale è pub/sub in-process:
broadcasterè un unicoRoundEventBroadcasterper processo, con unaasyncio.Queue(maxsize=1)per client.publish()mette una notifica in ogni coda non piena; se la coda è già piena la nuova notifica viene scartata, perché quella in attesa basta già a far ricaricare tutto (notifiche ridondanti si fondono). Codice:app/rounds/events.py:RoundEventBroadcaster· Test:tests/unit/test_round_events.py -
[ARC-34] Il messaggio non ha contenuto e lo stream non ha autenticazione: è solo un segnale "è cambiato qualcosa, ricarica". Ogni dato, compresa la personalizzazione (come
user_played), arriva dagli endpoint REST autenticati. Il segnale è globale: ogni client viene avvisato di ogni evento, anche di quelli che non lo riguardano. Codice:app/api/routes/rounds.py:round_stream -
[ARC-35] Limiti di sottoscrizione: al massimo
MAX_SUBSCRIBERS(500) stream in tutto, oltre i qualiGET /rounds/streamrisponde503e il browser ricade sul polling; e al massimoMAX_SUBSCRIBERS_PER_IP(5) per IP client, oltre i quali la connessione più vecchia di quello stesso IP viene chiusa (le si invia il segnaleEVICTED) invece di rifiutare la nuova. L'IP è quello diclient_ip(vedi sicurezza.md). Codice:app/rounds/events.py:RoundEventBroadcaster.subscribe·app/api/routes/rounds.py:round_stream· Test:tests/unit/test_round_events.py -
[ARC-36] Lo stream invia
event: updatecondata: {}per ogni notifica. Da fermo controllarequest.is_disconnected()ogni 5 secondi e invia un commento: keep-aliveogni 20 secondi (4 controlli), così né un client sparito senza chiudere né il timeout di inattività di un proxy lo lasciano appeso. La risposta portaCache-Control: no-storeeX-Accel-Buffering: no. Alla chiusura il client viene sempre rimosso. Codice:app/api/routes/rounds.py:round_stream -
[ARC-37]
publish()viene chiamato in questi punti, e ogni nuovo cambiamento di stato visibile deve aggiungersi a questo elenco:- apertura di un round —
app/rounds/service.py:open_new_round_if_needed; - ogni transizione di stato del round (
closing,closedsenza partecipanti,drawing,paying_out) —app/rounds/scheduler.py:RoundScheduler._tick,app/rounds/scheduler.py:RoundScheduler._close_and_draw; - scommessa inviata e scommessa annullata —
app/bets/service.py:place_bet,app/bets/service.py:_release_failed_bet; - prelievo inviato e prelievo annullato —
app/withdrawals/service.py:request_withdrawal,app/withdrawals/service.py:_release_failed_withdrawal; - transazione confermata —
app/tx/confirmation.py:poll_once; - riga promossa o abbandonata dal riconciliatore —
app/tx/reconcile.py:reconcile_once. Oggi il riconciliatore pubblica per ogni riga che ricontrolla, anche quando non cambia nulla; la pubblicazione solo sui cambiamenti è in B-96; - deposito accreditato, UTXO segnata come spesa fuori piattaforma, UTXO
ripristinata —
app/deposits/service.py:credit_confirmed_utxos,app/deposits/service.py:mark_utxos_spent_externally,app/deposits/service.py:reinstate_reappeared_utxos; - nuovo header ricevuto —
app/electrum/listener.py:ElectrumListener._consume_headers.
Test:
tests/unit/test_bets.py(verifica la notifica di una scommessa) Stato: da implementare (B-96) · Oggi: il riconciliatore pubblica una notifica per ogni riga ricontrollata, anche senza cambiamenti. - apertura di un round —
Lock per utente
- [ARC-38]
UserLockstiene unasyncio.Lockperuser_id(creato alla prima richiesta di quell'utente e mai rimosso).POST /betsePOST /withdrawalseseguono l'intera costruzione e trasmissione dentroapp.state.user_locks.acquire(user.id): per lo stesso utente una scommessa e un prelievo non sono mai in costruzione insieme, perché spendono dallo stesso insieme di UTXO. È l'unica esclusione reciproca tra i due flussi; cosa implica è in scommesse-e-prelievi.md. Il controllo "client connesso" avviene prima di acquisire il lock. Codice:app/tx/locks.py:UserLocks.acquire·app/api/routes/bets.py:create_bet·app/api/routes/withdrawals.py:create_withdrawal
Frontend
- [ARC-39] Le interfacce sono pagine statiche in
app/static/, servite direttamente da FastAPI ([ARC-18]): HTML, JavaScript e CSS scritti a mano, senza passo di build, framework né bundler. Parlano tutte con la stessa API JSON; l'unica separazione tra utente e amministratore èrequire_adminsul lato server. Codice:app/main.py·app/static/
/ — giocatori
-
[ARC-40] Polling. Da non autenticati, la pagina interroga
GET /rounds/currentogni 15s solo per la barra di stato della catena. Da autenticati:refreshRoundogni 15s, ogni 3s mentre il round è inclosing/drawing/paying_out;checkLastRoundResult(GET /users/me/last-round-result) ogni 20s; il conto alla rovescia locale ogni secondo. Quando la scheda torna visibile i dati si ricaricano subito (i timer delle schede in background vengono rallentati dal browser). Codice:app/static/app.js:startChainOnlyPolling·app/static/app.js:scheduleNextRoundPoll·app/static/app.js:showDashboard -
[ARC-41] Richieste. Ogni chiamata API di
/ha un timeout di 15s; dopo 2 fallimenti consecutivi del polling la pagina mostra "connessione persa". Una risposta401con codicesession_expiredriporta al login. Codice:app/static/app.js:call·app/static/app.js:noteFetchOutcome -
[ARC-42] SSE. La pagina apre un solo
EventSource('/rounds/stream')e reagisce sia agli eventiupdatesia aopen(che scatta alla prima connessione e a ogni riconnessione automatica): da autenticati ricarica round, profilo e ultimo risultato, altrimenti solo lo stato della catena. Il polling resta attivo comunque: se lo stream cade o è bloccato la pagina funziona come prima, solo senza l'avviso immediato. Codice:app/static/app.js:connectRoundEvents·app/static/app.js:onRoundServerEvent -
[ARC-43] Chiavi di
localStorageusate da/:Chiave Contenuto plm_tokenJWT della sessione plm_usernameNome utente mostrato plm_addressIndirizzo di deposito plm_persisted_resultRisultato del round rivelato ( round_id,won,amount_sats), per ridisegnarlo dopo un ricaricamentoplm_langLingua scelta (condivisa con /report-bug)Il logout svuota
localStoragetranneplm_lang. La rimozione diplm_tokenin una scheda riporta al login anche le altre schede aperte (eventostorage). La logica della rivelazione del risultato è in ciclo-di-vita-del-round.md. Codice:app/static/app.js:logout·app/static/app.js:persistResult·app/static/i18n.js:setLanguage
/admin — operatore
- [ARC-44] Il pannello chiede il token operatore, lo verifica con
GET /admin/confige lo conserva insessionStorage(plm_admin_token, quindi si perde chiudendo la scheda); ogni chiamata lo invia nell'intestazioneX-Admin-Token. Non è collegato da/né lo collega. Codice:app/static/admin.js:callAdmin - [ARC-45] Il pannello interroga
GET /rounds/currentogni 15s per la barra di stato (stato del round e altezza della catena). Le sezioni Utenti, Round, Transazioni pendenti, Audit log e Bug report si caricano quando le si apre e si ricaricano a ogni evento SSE (updateoopen) mentre sono aperte; non hanno un polling periodico proprio, quindi se il canale SSE è bloccato o caduto si aggiornano solo riaprendo la sezione. La sezione Parametri non viene ricaricata dagli eventi. Il piano aggiunge al pannello lo stato della connessione Electrum (endpoint, età dell'ultimo header) e i filtri dell'audit log (B-86, B-92). Codice:app/static/admin.js:startChainStatusPolling·app/static/admin.js:connectAdminEvents·app/static/admin.js:onAdminServerEventStato: da implementare (B-86, B-92), solo per lo stato Electrum e i filtri dell'audit · Oggi: il pannello mostra solo l'altezza della catena, e l'audit log le ultime righe senza filtri.
/report-bug e /guida
- [ARC-46]
/report-bugè una pagina autonoma, raggiungibile con o senza login: se inlocalStoragec'èplm_tokenlo usa per allegare l'autore e per mostrare i propri report (GET /bug-reports/mine). Non usa SSE né polling. Il flusso di triage è descritto in api.md e in guida-admin.md. Codice:app/static/report-bug.html - [ARC-47]
/guidaè un segnaposto statico in italiano ("la guida completa sarà pubblicata qui a breve"). Codice:app/static/guida.html
Internazionalizzazione
Riguarda / e /report-bug. /admin e /guida sono volutamente solo in
italiano.
- [ARC-48] Le lingue supportate sono sette:
en,it,es,fr,de,ru,zh. Tutte le stringhe stanno in un'unica tabellaTRANSLATIONSinapp/static/i18n.js, caricato prima dello script della pagina; non ci sono file separati né richieste di rete. Codice:app/static/i18n.js - [ARC-49] La lingua iniziale è, nell'ordine: il valore di
localStorage.plm_langse supportato, le prime due lettere dinavigator.languagese supportate, altrimentien.setLanguagesalva la scelta inplm_lang, ritraduce la pagina e chiamaonLanguageChangese la pagina la definisce. Su/il selettore sta nella barra di stato della catena, non nella barra di navigazione, perché quest'ultima è nascosta fino al login. Codice:app/static/i18n.js:detectDefaultLang·app/static/i18n.js:setLanguage·app/static/index.html - [ARC-50] Il markup statico si traduce con attributi:
data-i18n(testo),data-i18n-html,data-i18n-placeholder,data-i18n-title,data-i18n-aria-label,data-i18n-alt, applicati daapplyStaticTranslationsal caricamento e a ogni cambio di lingua. Ciò che nasce da dati del server usat(chiave, parametri)e viene ridisegnato daonLanguageChange. Un elemento usa uno solo dei due meccanismi, mai entrambi (per esempio#bet-btnnon hadata-i18n, perché la sua etichetta contiene il costo configurabile). Codice:app/static/i18n.js:applyStaticTranslations·app/static/app.js:onLanguageChange·app/static/app.js:renderBetButton - [ARC-51] Ogni lingua ha esattamente lo stesso insieme di chiavi.
t()cerca la chiave nella lingua corrente, poi inen, e se manca anche lì restituisce la chiave stessa;tOrNull()restituisce invecenull, così il chiamante può usare un testo di riserva. I parametri si sostituiscono nei segnaposto{nome}. Codice:app/static/i18n.js:t·app/static/i18n.js:tOrNull - [ARC-52] L'API risponde in una sola lingua (inglese) con un
detailstrutturato{code, message, params}(vedi api.md); la traduzione spetta al client. Per undetailoggetto il frontend mostraerror.<code>nella lingua corrente, con i parametri; a ogni parametro*_satsnumerico affianca un*_plmgià formattato; se il codice non ha traduzione mostramessage. Undetailstringa viene mostrato così com'è; undetaillista (errore di validazione 422 di FastAPI) diventaerror.invalid_request. Aggiungere un errore rivolto all'utente significa dargli un codice e aggiungereerror.<codice>in tutte e sette le lingue. Codice:app/static/app.js:apiErrorMessage·app/static/app.js:errorParams·app/api/errors.py:ApiError - [ARC-53] Su
/report-bugl'avvisobugReport.englishNoticeè tradotto in tutte le lingue, ma dice sempre di scrivere la segnalazione in inglese, perché il pannello di triage non è tradotto. Codice:app/static/report-bug.html·app/static/i18n.js
Test
-
[ARC-54] La suite è tutta in
tests/unit/(351 test raccolti);tests/integration/contiene solo__init__.py. Si esegue conpython -m pytestdal venv.pyproject.tomlimpostatestpaths = ["tests"]easyncio_mode = "auto", quindi i test asincroni non richiedono@pytest.mark.asyncio. Dipendenze di sviluppo:pytest,pytest-asyncio,httpx. Codice:pyproject.toml· Test:tests/unit/test_docs_current.py -
[ARC-55] Non esiste un
conftest.py: ogni file definisce le proprie fixture. Lo schema tipico di una fixturesession_factoryè: master key generata in una directory temporanea con una chiave Fernet casuale (e cachehd._account_keyazzerata), engine SQLite in memoria conBase.metadata.create_all(le migrazioni Alembic non vengono eseguite), una rigaRoundConfigcon unfee_addressvalido dove serve aprire round. I test HTTP costruiscono un'app FastAPI minima con i soli router necessari, unElectrumListenersenza connessione suapp.state, e la interrogano conhttpx.AsyncClientsuASGITransport: illifespandiapp/main.pynon viene eseguito. Test:tests/unit/test_bets.py·tests/unit/test_rounds_route.py -
[ARC-56] La rete non viene mai contattata: al posto del client e del listener Electrum ogni file usa classi finte locali, ad esempio
FakeElectrumClient(test_bets.py,test_withdrawals.py,test_balance.py),RejectingElectrumClient(test_bets.py),FakeClienteExplodingClient(test_confirmation.py),FakeClient(test_broadcast.py),UnknownTxClient,KnownTxClient,BrokenClient(test_reconcile.py),FakeListener,FakePayoutClient,FakePayoutListener,CorroboratingListener,LateTipListener,StallingListener(test_scheduler.py),FakeListener(test_deposit_reconcile.py),FakeWriter(test_electrum_client.py). Test: i file citati -
[ARC-57] Alcuni test verificano file che non sono codice Python, perché nessun altro test li eserciterebbe:
test_docs_current.py: il numero di test dichiarato inCLAUDE.mdeREADME.mdcoincide con quello raccolto;CLAUDE.mdnon contiene affermazioni superate note; la mappa del codice diCLAUDE.mdcitarate_limit.py,client_ip.pyebug_reports; i link relativi del README e le ancore versoCLAUDE.mdesistono;test_docs_no_bare_uvicorn.py: README edocs/running-the-server.mdnon documentano unuvicorn --reloadfuori da Docker;test_code_comments.py: alcuni commenti nel codice restano veri (per esempio il numero di task avviati dal lifespan, sei, citato inapp/db/base.py);test_caddyfile_security_headers.py: ilCaddyfilemantiene le intestazioni di sicurezza e la sovrascrittura diX-Forwarded-For;test_withdrawals.py::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive: scommesse-e-prelievi.md descrive correttamente la convivenza di scommessa e prelievo.
Chi modifica questi file (compresi i documenti in
docs/) deve tenerne conto. Test:tests/unit/test_docs_current.py·tests/unit/test_docs_no_bare_uvicorn.py·tests/unit/test_code_comments.py·tests/unit/test_caddyfile_security_headers.py·tests/unit/test_withdrawals.py -
[ARC-58] Ciò che la suite non copre oggi: nessun test contro una connessione Electrum reale né contro guasti di rete realistici (ritardi, cadute a metà richiesta, risposte enormi, catene divergenti); il
lifespane l'avvio dei sei task; la route SSEround_stream(è testato solo il broadcaster);UserLockse la sua acquisizione nelle route di scommessa e prelievo; il codice JavaScript dei frontend; la parità delle chiavi tra le lingue dii18n.js; le migrazioni Alembic su dati esistenti (la catena è eseguita solo su un database vuoto, [ARC-62]; lo schema degli altri test nasce daBase.metadata.create_all). Le verifiche dal vivo sono state manuali (scripts/electrum_smoke_test.pye transazioni reali su mainnet; prelievo e RBF mai trasmessi dal vivo, vedi limiti-noti.md). La revisione del 2026-09-28 ha osservato che nessuna delle sue falle critiche sarebbe stata trovata da questa suite, che simula tutto: da qui [ARC-62], [ARC-63] e [ARC-64]. -
[ARC-62] Test delle migrazioni. Un test esegue
alembic upgrade headsu un database vuoto e verifica che esistano gli indici su espressioneix_rounds_single_activeeix_users_username_lower, che l'autogenerate non vede e chebatch_alter_tablesu SQLite elimina ricostruendo la tabella con un solo avviso. Ogni migrazione batch suroundsousersli ricrea esplicitamente. Codice:migrations/env.py·migrations/versions/· Test:tests/unit/test_migrations.py -
[ARC-63] Test di parità delle traduzioni. Un test legge
TRANSLATIONSdaapp/static/i18n.jse fallisce se una delle sette lingue ha un insieme di chiavi diverso da quello dien([ARC-51]). Codice:app/static/i18n.js· Test: — Stato: da implementare (senza B-nn: chiude LIM-21) · Oggi: la parità è verificata solo a mano. -
[ARC-64] Strato di test di guasto e di integrazione, in
tests/integration/, separato dalla suite unitaria: server Electrum simulati a livello di socket che rispondono in ritardo, chiudono la connessione dopo aver ricevuto un invio, mandano risposte oltre 16 MiB, header a difficoltà minima o catene che si biforcano; più il database SQLite su file con scrittori concorrenti. Non sostituisce la prova con denaro vero e guasti provocati di operativita.md, che resta obbligatoria prima dell'apertura al pubblico. Codice:tests/integration/__init__.py· Test: — Stato: da implementare (fase 7 del piano) · Oggi:tests/integration/contiene solo__init__.py.
Assunzioni di processo singolo
-
[ARC-59] Diversi componenti tengono stato nella memoria del processo e funzionano correttamente solo con un unico processo uvicorn ([ARC-02]):
- il canale SSE (
broadcaster): un evento pubblicato in un processo non raggiungerebbe i client collegati a un altro; - i lock per utente (
UserLocks): due processi potrebbero costruire insieme una scommessa e un prelievo dello stesso utente; - i limitatori di login e registrazione (
AuthRateLimiterssuapp.state): ogni processo conterebbe separatamente; - l'
ElectrumListenere i sei task: ogni processo aprirebbe la propria connessione e il proprio scheduler, con due scheduler che avanzano lo stesso round; - le cache locali (chiave d'account decifrata in
app/wallet/hd.py, QR memorizzati inapp/api/routes/qr.py), che sarebbero solo duplicate.
Un deployment multi-processo richiederebbe un canale condiviso (per esempio Redis pub/sub) e un lock su database o Redis. Fa eccezione l'invariante "al massimo un round attivo", garantita da un indice del database e quindi valida anche con più processi (vedi modello-dati.md). La scelta è discussa in limiti-noti.md. Codice:
app/rounds/events.py:RoundEventBroadcaster·app/tx/locks.py:UserLocks·app/auth/rate_limit.py:AuthRateLimiters·app/main.py:lifespan - il canale SSE (