# Modello dati Questo documento descrive il database di PLM Lottery: tutte le tabelle, ogni colonna, i valori ammessi per i campi di stato e le transizioni tra di essi, gli indici e i vincoli, le invarianti che legano più tabelle, la configurazione del motore SQLAlchemy e la politica delle migrazioni Alembic. La definizione di riferimento è [`app/db/models.py`](../app/db/models.py); lo schema effettivo di un'istanza è quello prodotto dalla catena di migrazioni in [`migrations/versions/`](../migrations/versions/). Le due cose coincidono (vedi [Migrazioni](#migrazioni)), con le piccole differenze segnalate lì. Cosa **non** c'è qui, e dove trovarlo: - i flussi che scrivono queste righe, passo per passo: [registrazione-e-depositi.md](registrazione-e-depositi.md), [scommesse-e-prelievi.md](scommesse-e-prelievi.md), [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md), [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md); - il catalogo dei tipi di evento di `audit_log`: [audit-log.md](audit-log.md); - gli endpoint che leggono e scrivono queste tabelle: [api.md](api.md); - chiavi, password e token: [sicurezza.md](sicurezza.md); - backup e interventi manuali sul database: [operativita.md](operativita.md); - la storia dei bug citati come `B-nn`: [storico-bug.md](storico-bug.md). ## Indice - [Diagramma delle relazioni](#diagramma-delle-relazioni) - [Convenzioni comuni](#convenzioni-comuni) - Tabelle: [`users`](#users) · [`utxo_events`](#utxo_events) · [`rounds`](#rounds) · [`round_participants`](#round_participants) · [`round_config`](#round_config) · [`pending_transactions`](#pending_transactions) · [`pending_transaction_txids`](#pending_transaction_txids) · [`withdrawals`](#withdrawals) · [`bug_reports`](#bug_reports) · [`audit_log`](#audit_log) · [`alembic_version`](#alembic_version) - [Indici e vincoli](#indici-e-vincoli) - [Invarianti tra tabelle](#invarianti-tra-tabelle) - [Motore e sessioni](#motore-e-sessioni) - [Migrazioni](#migrazioni) - [Scelte specifiche di SQLite](#scelte-specifiche-di-sqlite) ## Diagramma delle relazioni Dieci tabelle applicative nello schema atteso (nove oggi: manca `pending_transaction_txids`, B-77). `round_config` non ha relazioni: è una riga singola di parametri. Gli indirizzi della piattaforma che non appartengono a un utente sono derivati dalla master key, e i loro UTXO si leggono dalla rete, non da una tabella: | Indirizzo | Percorso | Dove è registrato | |---|---|---| | Utente | `m/84'/746'/0'/0/` | `users.address` | | Riserva del pool (il "pool" unico di oggi) | `m/84'/746'/0'/1/0` | nessuna tabella; `app/wallet/hd.py:derive_pool_key` | | Montepremi di un round | `m/84'/746'/0'/2/` | `rounds.pool_address` ([DAT-60]) | Il diagramma descrive lo schema **atteso**; le colonne e la tabella marcate "da implementare" non esistono ancora (vedi le tabelle di dettaglio). ```mermaid erDiagram users ||--o{ utxo_events : "possiede" users ||--o{ round_participants : "scommette" rounds ||--o{ round_participants : "raccoglie" users |o--o{ rounds : "vince (winner_user_id)" users ||--o{ withdrawals : "chiede" rounds |o--o{ pending_transactions : "bet / payout" withdrawals |o--o{ pending_transactions : "withdrawal" users |o--o{ pending_transactions : "bet / withdrawal" pending_transactions ||--|{ pending_transaction_txids : "ogni versione (da implementare)" users |o--o{ bug_reports : "segnala" users |o--o{ audit_log : "riguarda" rounds |o--o{ audit_log : "riguarda" users { int id PK string username UK "unico anche su lower(username)" string password_hash "Argon2" int derivation_index UK string address UK "P2WPKH plm1..." bigint cached_balance_sats "cache di utxo_events" int token_version datetime created_at } utxo_events { int id PK int user_id FK string txid "UK con vout" int vout bigint amount_sats int confirmed_height datetime confirmed_at string spent_txid "NULL, txid o external-spend" } rounds { int id PK string status "open closing drawing paying_out closed" datetime opened_at datetime closed_at int duration_seconds "snapshot" int cooldown_seconds "snapshot" bigint bet_amount_sats "snapshot (da implementare)" string fee_address "snapshot (da implementare)" int draw_confirmations "snapshot (da implementare)" string pool_address "da implementare" string server_seed_enc "cifrato fino alla rivelazione (da implementare)" string server_seed "in chiaro solo dopo la rivelazione (da implementare)" string server_seed_hash "da implementare" string participants_hash "da implementare" int draw_height "da implementare" datetime drawing_started_at int draw_block_height string draw_block_hash string seed_int int winner_user_id FK bigint pool_amount_sats bigint winner_amount_sats bigint fee_amount_sats bigint bump_reserve_sats "da implementare" string payout_txid datetime payout_blocked_at "da implementare" } round_participants { int id PK int round_id FK "UK con user_id" int user_id FK bigint bet_amount_sats string bet_txid int bet_vout "da implementare" datetime broadcast_at datetime confirmed_at int draw_position "UK con round_id (da implementare)" string status "building broadcast confirmed reorged_out" } round_config { int id PK string fee_address bigint bet_amount_sats int round_duration_seconds int round_cooldown_seconds int draw_animation_seconds int fee_rate_sat_vb int rbf_timeout_seconds int deposit_confirmations "da implementare" int draw_confirmations "da implementare" bigint max_round_pool_sats "da implementare" bool paused string paused_reason "da implementare" datetime updated_at } pending_transactions { int id PK string kind "bet payout withdrawal" int round_id FK int withdrawal_id FK int user_id FK string current_txid int fee_rate_sat_vb text raw_tx_hex datetime broadcast_at datetime last_broadcast_at string status "building pending confirmed failed" string replaced_by_txid int attempt_count string failure_reason datetime failed_at "da implementare" datetime updated_at } pending_transaction_txids { int id PK int pending_transaction_id FK string txid UK text raw_hex datetime created_at } withdrawals { int id PK int user_id FK string external_address bigint amount_requested_sats bigint amount_sent_sats string txid string status "building broadcast confirmed failed" datetime created_at datetime confirmed_at } bug_reports { int id PK text description string contact int user_id FK string status "open read resolved" datetime created_at } audit_log { int id PK string event_type text payload_json int user_id FK int round_id FK datetime created_at } ``` ## Convenzioni comuni - **[DAT-01]** Ogni importo è un numero intero di satoshi (1 PLM = 100 000 000 sat), in una colonna `BigInteger` il cui nome finisce in `_sats`. Nessun importo passa mai per un `float`: anche il valore degli input letti dalla rete per un RBF è preso come intero dalla transazione grezza (B-40). *Codice:* `app/db/models.py` · `app/tx/broadcast.py:_prevout_amount` · *Test:* `tests/unit/test_broadcast.py` - **[DAT-02]** Ogni timestamp è in UTC. Si scrive con `app/db/models.py:utcnow` (default di colonna) o con `datetime.now(timezone.utc)`; le colonne sono `DateTime` senza fuso, e SQLite restituisce valori *naive*. Chi legge un timestamp per confrontarlo con l'ora corrente lo marca UTC con `.replace(tzinfo=timezone.utc)`, e ogni risposta API lo serializza con `isoformat_utc`, che aggiunge l'offset (B-35). *Codice:* `app/db/models.py:utcnow` · `app/api/timeutil.py:isoformat_utc` · `app/rounds/service.py:round_deadline` · *Test:* `tests/unit/test_api_timeutil.py`, `tests/unit/test_scheduler.py` - **[DAT-03]** I campi di stato (`status`, `kind`) sono stringhe libere (`String(16)`), senza `CHECK` né enum nel database. I valori ammessi sono solo quelli elencati in questo documento, e a garantirlo è esclusivamente il codice che li scrive; l'unico vincolo del database che dipende da un valore di stato è l'indice `ix_rounds_single_active` ([DAT-17]). *Codice:* `app/db/models.py` - **[DAT-04]** Le chiavi esterne sono dichiarate ma, su SQLite, **non applicate**: `PRAGMA foreign_keys` non viene attivato ([DAT-52]). Nessuna chiave esterna ha `ON DELETE`. Le sole cancellazioni fisiche nel codice sono quelle di una scommessa fallita (la riga `round_participants` e la sua `pending_transactions`, con le sue versioni in `pending_transaction_txids` nello schema atteso, [DAT-24], [DAT-38], [DAT-70]); nessun'altra tabella perde righe, quindi i riferimenti restano validi per costruzione, non per vincolo. *Codice:* `app/db/base.py:_register_sqlite_pragmas` · `app/bets/service.py:_release_failed_bet` · `app/tx/reconcile.py:_abandon` - **[DAT-05]** Ogni chiave primaria è un intero autoincrementale `id`, assegnato dal database al `flush`/`commit`. Nessuna tabella usa chiavi naturali come chiave primaria; le chiavi naturali (`txid`+`vout`, `round_id`+`user_id`, `username`) sono vincoli di unicità separati. *Codice:* `app/db/models.py` ## Tabelle ### `users` Un account. Nasce alla registrazione e non viene mai cancellato. | Colonna | Tipo | Null | Default | Significato | Scritta da | |---|---|---|---|---|---| | `id` | Integer PK | no | autoincrement | Identificativo; è il `sub` del JWT | database | | `username` | String(64) | no | — | Nome scelto dall'utente, conservato **così come digitato** | `app/auth/routes.py:register` | | `password_hash` | String(256) | no | — | Hash Argon2 della password | `register`; `app/api/routes/users.py:change_password`; `app/api/routes/admin.py:reset_user_password` | | `derivation_index` | Integer, unico | no | — | Indice BIP84 dell'indirizzo: `m/84'/746'/0'/0/` | `register` | | `address` | String(128), unico | no | — | Indirizzo P2WPKH (`plm1…`) derivato da `derivation_index`; deposito, vincite e resto | `register` | | `cached_balance_sats` | BigInteger | no | `0` | Saldo confermato e spendibile, cache di `utxo_events` | solo `app/wallet/balance.py:recompute_balance` | | `token_version` | Integer | no | `0` (ORM e `server_default` nel modello) | Versione dei token di sessione; il JWT la porta nel claim `tv` | `change_password`, `reset_user_password` | | `created_at` | DateTime | no | `utcnow` | Istante della registrazione | database/ORM | - **[DAT-06]** Lo username è unico **senza distinzione tra maiuscole e minuscole** (B-57): lo garantisce l'indice unico funzionale `ix_users_username_lower` su `lower(username)`, e registrazione e login confrontano con `func.lower(...)`. Il valore è comunque memorizzato così come l'utente l'ha scritto, ed è quello mostrato in `/admin` e nel registro di audit. Poiché lo username ammesso è solo ASCII (`^[A-Za-z0-9_.-]+$`, 3–32 caratteri, validato dall'API), `lower()` è l'intera normalizzazione. Resta anche l'indice unico `ix_users_username` sulla colonna esatta, ridondante rispetto al primo ma innocuo. *Codice:* `app/db/models.py:User` · `app/auth/routes.py:register` · `app/auth/routes.py:login` · *Test:* `tests/unit/test_auth.py` - **[DAT-07]** `derivation_index` è assegnato alla registrazione come `max(derivation_index) + 1` (0 per il primo utente). Se il commit fallisce per una collisione sull'indice (due registrazioni concorrenti) si ricalcola e si riprova, fino a 5 volte; una collisione sullo username invece non si riprova e risponde `username_taken`. `derivation_index` e `address` non vengono **mai** modificati dopo l'inserimento: l'indirizzo è permanente. *Codice:* `app/auth/routes.py:register` · `app/wallet/hd.py:derive_user_address` · *Test:* `tests/unit/test_auth.py` - **[DAT-08]** `cached_balance_sats` è uguale, in ogni momento osservabile, alla somma di `amount_sats` delle righe `utxo_events` dell'utente con `spent_txid IS NULL`. La fonte di verità è `utxo_events`; la colonna è scritta solo da `recompute_balance`, che va chiamata **nella stessa transazione** di qualunque modifica alle righe `utxo_events` dell'utente (un inserimento o un cambio di `spent_txid` da o verso `NULL`). I chiamanti sono: `credit_confirmed_utxos`, `reinstate_reappeared_utxos`, `mark_utxos_spent_externally`, `place_bet`, `_release_failed_bet`, `request_withdrawal`, `_release_failed_withdrawal` e il `_abandon` del riconciliatore. Il retarget di `bump_fee` ([DAT-14]) non la ricalcola perché sostituisce un txid con un altro e non cambia la somma. Il saldo "in attesa" mostrato all'utente non è una colonna: è calcolato al volo (vedi [registrazione-e-depositi.md](registrazione-e-depositi.md)). *Codice:* `app/wallet/balance.py:recompute_balance` · `app/deposits/service.py:credit_confirmed_utxos` · `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_balance.py`, `tests/unit/test_deposits.py` - **[DAT-09]** `token_version` parte da 0 e aumenta di 1 a ogni cambio password (self-service) e a ogni reset da parte dell'amministratore; nient'altro la modifica. Un token il cui claim `tv` non coincide con il valore corrente è rifiutato (B-34). Un token senza claim `tv` vale come `tv = 0`. *Codice:* `app/api/routes/users.py:change_password` · `app/api/routes/admin.py:reset_user_password` · `app/auth/dependencies.py:get_current_user` · `app/auth/security.py:decode_access_token` · *Test:* `tests/unit/test_security.py`, `tests/unit/test_admin.py` ### `utxo_events` Un output **confermato** che paga l'indirizzo di un utente: un deposito, il resto di una sua scommessa o di un suo prelievo, una vincita. Dal punto di vista del database sono tutti uguali, perché arrivano tutti dallo stesso percorso (l'accredito dei depositi, vedi [registrazione-e-depositi.md](registrazione-e-depositi.md)). | Colonna | Tipo | Null | Default | Significato | Scritta da | |---|---|---|---|---|---| | `id` | Integer PK | no | autoincrement | — | database | | `user_id` | FK `users.id`, indicizzata | no | — | Proprietario dell'indirizzo pagato | `app/deposits/service.py:credit_confirmed_utxos` | | `txid` | String(64) | no | — | Transazione che ha creato l'output | `credit_confirmed_utxos` | | `vout` | Integer | no | — | Posizione dell'output | `credit_confirmed_utxos` | | `amount_sats` | BigInteger | no | — | Valore dell'output (`value` di `listunspent`) | `credit_confirmed_utxos` | | `confirmed_height` | Integer | no | — | Altezza del blocco riportata dal server (sempre > 0) | `credit_confirmed_utxos` | | `confirmed_at` | DateTime | no | `utcnow` | Istante dell'**accredito** nel database, non l'orario del blocco | ORM | | `spent_txid` | String(64) | sì | `NULL` | Chi ha speso l'output, vedi [DAT-12] | vedi tabella sotto | - **[DAT-10]** Una riga si inserisce solo per un output che non è già presente, che ha raggiunto le conferme richieste per l'accredito e che gli altri server Electrum hanno corroborato con lo stesso importo (B-59; la corroborazione è in [connessione-electrum.md](connessione-electrum.md)). Le conferme richieste e la soglia sotto cui un output non si accredita (`DUST_LIMIT_SATS`) sono regole del deposito, definite in [registrazione-e-depositi.md](registrazione-e-depositi.md) (B-82, B-75): lo schema non le ripete. Il numero di conferme configurabile è la colonna `round_config.deposit_confirmations` ([DAT-62]). L'inserimento è idempotente su (`txid`, `vout`), che è unico su tutta la tabella, non solo per utente. Le righe non si cancellano mai e, dopo l'inserimento, solo `spent_txid` cambia. *Codice:* `app/deposits/service.py:find_new_credit_candidates` · `app/deposits/service.py:credit_confirmed_utxos` · `app/electrum/listener.py:ElectrumListener.refresh_user` · *Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py` *Stato:* da implementare (B-82, B-75) · *Oggi:* si accredita ogni output con `height > 0` (1 conferma), coinbase comprese, di qualunque importo. - **[DAT-11]** Gli UTXO dei montepremi e della riserva **non** sono in `utxo_events`: non appartengono a un utente. Il pagamento di un round spende solo gli outpoint delle scommesse di quel round (`bet_txid`, `bet_vout` dei partecipanti `confirmed`, [DAT-63]) all'indirizzo `rounds.pool_address`, dopo averli verificati sulla rete; qualunque altro output presente allo stesso indirizzo non viene speso. Resta l'esclusione degli outpoint già impegnati da un pagamento non ancora risolto (letti dal `raw_tx_hex` delle righe `pending_transactions` di tipo `payout` in `building`/`pending`). Per lo stesso motivo l'abbandono di un pagamento non ha input da liberare. *Codice:* `app/rounds/scheduler.py:_reserved_payout_outpoints` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py` *Stato:* da implementare (B-80, B-82) · *Oggi:* un solo indirizzo del pool (`m/84'/746'/0'/1/0`) per tutti i round; il pagamento sceglie fra tutti i suoi UTXO confermati, di qualunque round o provenienza. - **[DAT-12]** `spent_txid` ha tre significati: - `NULL`: l'output è spendibile e conta nel saldo; - un txid: l'output è impegnato da una transazione della piattaforma (scommessa o prelievo), dal momento della sua costruzione, prima ancora della trasmissione; resta impostato per sempre una volta confermata; - la stringa `external-spend`: l'output è sparito dalla `listunspent` e gli altri server hanno confermato che è stato speso fuori dalla piattaforma (B-29). Le transizioni ammesse sono queste, e nessun'altra funzione scrive la colonna: | Da | A | Funzione | Quando | |---|---|---|---| | `NULL` | txid | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal` | fase 1, prima del broadcast | | txid | nuovo txid | `app/tx/broadcast.py:_retarget_txid_references` | bump RBF, e conferma di una versione precedente della stessa transazione ([DAT-14]) | | txid | `NULL` | `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal` | la rete ha rifiutato **esplicitamente** il broadcast | | txid | `NULL` | `app/tx/reconcile.py:_release_inputs` | la transazione è stata abbandonata con una prova ([DAT-13]) | | `NULL` | `external-spend` | `app/deposits/service.py:mark_utxos_spent_externally` | spesa esterna corroborata | | `external-spend` | `NULL` | `app/deposits/service.py:reinstate_reappeared_utxos` | l'outpoint ricompare come non speso, corroborato dal quorum | Chi sceglie gli input di una nuova transazione considera solo le righe con `spent_txid IS NULL`. *Codice:* le funzioni in tabella · *Test:* `tests/unit/test_deposits.py`, `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py` *Stato:* da implementare (B-76, B-78, B-83) · *Oggi:* il rilascio dopo il broadcast avviene su qualunque eccezione (anche un timeout), l'abbandono sul silenzio del solo server attivo, il reintegro sulla parola del solo server attivo. - **[DAT-13]** Il riconciliatore libera un input solo se il suo `spent_txid` è uno dei txid della riga che sta abbandonando (`current_txid` o una sua versione in `pending_transaction_txids`, [DAT-70]): se nel frattempo lo stesso output risulta impegnato da un'altra transazione, quella rivendicazione è quella valida e non va toccata. *Codice:* `app/tx/reconcile.py:_release_inputs` · *Test:* `tests/unit/test_reconcile.py` *Stato:* da implementare (B-77), per le versioni precedenti · *Oggi:* il confronto è solo con `current_txid`. - **[DAT-14]** Per ogni output impegnato, `spent_txid` è sempre il txid **corrente** della transazione che lo spende, cioè il `current_txid` della sua riga `pending_transactions`. Un bump RBF cambia il txid e quindi riscrive, nella stessa transazione del database, ogni `spent_txid` uguale al vecchio txid. Quando conferma una versione **diversa** da quella corrente (una versione precedente registrata in `pending_transaction_txids`, [DAT-70]), `current_txid`, `raw_tx_hex`, lo `spent_txid` degli input e il txid della riga di dominio vengono riportati a quella versione, nella stessa transazione del database che segna la conferma. Senza questo allineamento [DAT-13] non riconoscerebbe più gli input di una transazione rimpiazzata. *Codice:* `app/tx/broadcast.py:bump_fee` · `app/tx/broadcast.py:_retarget_txid_references` · `app/tx/confirmation.py:poll_once` · *Test:* `tests/unit/test_broadcast.py` *Stato:* da implementare (B-77) · *Oggi:* solo il bump riallinea; la conferma di una versione precedente non viene riconosciuta. ### `rounds` Un round della lotteria, dall'apertura al pagamento confermato. I round chiusi restano nella tabella come storico. | Colonna | Tipo | Null | Default | Significato | Scritta da | |---|---|---|---|---|---| | `id` | Integer PK | no | autoincrement | — | database | | `status` | String(16) | no | `"open"` | Fase del round: `open`, `closing`, `drawing`, `paying_out`, `closed` ([RND-02](ciclo-di-vita-del-round.md)) | vedi [RND-02](ciclo-di-vita-del-round.md) | | `opened_at` | DateTime | no | `utcnow` | Apertura; base della scadenza | `app/rounds/service.py:open_new_round_if_needed` (default) | | `closed_at` | DateTime | sì | `NULL` | Chiusura delle scommesse, poi conferma del pagamento ([RND-28](ciclo-di-vita-del-round.md)) | `app/rounds/scheduler.py:RoundScheduler._tick`; `app/rounds/confirmation.py:_on_payout_confirmed` | | `duration_seconds` | Integer | no | `600` (anche `server_default`) | Durata di **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` | | `cooldown_seconds` | Integer | no | `30` (anche `server_default`) | Pausa dopo **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` | | `bet_amount_sats` | BigInteger | no | backfill da `round_config` nella migrazione | Costo della scommessa di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` | | `fee_address` | String(128) | no | backfill da `round_config` nella migrazione | Destinatario della commissione di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` | | `draw_confirmations` | Integer | no | `3` (`server_default`) | Conferme richieste sul blocco di estrazione di **questo** round, copiate all'apertura ([DAT-61]) — *da implementare (B-73, B-95)* | `open_new_round_if_needed` | | `pool_address` | String(128) | no | backfill con l'indirizzo di riserva per i round esistenti | Indirizzo del montepremi di **questo** round ([DAT-60]) — *da implementare (B-80)* | `open_new_round_if_needed` | | `server_seed_enc` | Text | sì | `NULL` | Il segreto del round cifrato con Fernet, con la stessa chiave della master xprv; scritto all'apertura ([DAT-64]) — *da implementare (B-73)* | `open_new_round_if_needed` | | `server_seed` | String(64) | sì | `NULL` | Il segreto in chiaro (32 byte in esadecimale), scritto **solo** alla rivelazione ([DAT-64]) — *da implementare (B-73)* | `RoundScheduler._close_and_draw` | | `server_seed_hash` | String(64) | sì | `NULL` | `sha256(server_seed)` in esadecimale, pubblicato dall'apertura ([DAT-64]) — *da implementare (B-73)* | `open_new_round_if_needed` | | `participants_hash` | String(64) | sì | `NULL` | Impronta dell'elenco ordinato dei partecipanti ([DAT-65]) — *da implementare (B-73, B-81)* | `RoundScheduler._close_and_draw` | | `draw_height` | Integer | sì | `NULL` | Altezza del blocco di estrazione, fissata all'ingresso in `drawing` ([DAT-65]) — *da implementare (B-73, B-81)* | `_close_and_draw` | | `drawing_started_at` | DateTime | sì | `NULL` | Istante del passaggio a `drawing` | `RoundScheduler._close_and_draw` | | `draw_block_height` | Integer | sì | `NULL` | Altezza del blocco usato per l'estrazione; nello schema atteso è sempre uguale a `draw_height` | `_close_and_draw` | | `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_close_and_draw` | | `seed_int` | String(128) | sì | `NULL` | Il seme in decimale (fino a 78 cifre, per questo è una stringa). Atteso: il valore intero di `sha256(server_seed ‖ block_hash ‖ participants_hash)`, con la codifica definita in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73); oggi `int(draw_block_hash, 16)` | `_close_and_draw` | | `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_close_and_draw` | | `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`; `RoundScheduler._trigger_payout` (atteso, B-82) | | `winner_amount_sats` | BigInteger | sì | `NULL` | Quanto riceve il vincitore (70% meno la commissione di rete) | `RoundScheduler._trigger_payout` | | `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` nello schema atteso | `_trigger_payout` | | `bump_reserve_sats` | BigInteger | sì | `NULL` | Output di riserva per l'RBF del pagamento, verso l'indirizzo di riserva ([DAT-73]) — *da implementare (B-79)* | `_trigger_payout`; `app/tx/broadcast.py:bump_fee` | | `payout_txid` | String(64) | sì | `NULL` | Txid corrente della transazione di pagamento | `_trigger_payout`; `_retarget_txid_references`; `app/tx/reconcile.py:_abandon` | | `payout_blocked_at` | DateTime | sì | `NULL` | Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) — *da implementare (B-82)* | `_trigger_payout`; `app/api/routes/admin.py:resume_lottery` | - ~~**[DAT-15]**~~ Assorbito da [RND-02](ciclo-di-vita-del-round.md): i valori di `status` e le transizioni sono comportamento del round, e il loro unico proprietario è il documento del ciclo di vita. Qui restano i fatti di schema: `status` è una `String(16)` senza `CHECK` ([DAT-03]); gli stati `open`, `closing`, `drawing`, `paying_out` sono quelli **attivi** coperti dall'indice `ix_rounds_single_active` ([DAT-17]); la sola scrittura di `status` fuori dal codice applicativo è la migrazione `8a1c4e7b2d90` ([DAT-59]). Il trattenimento di un pagamento (B-82) **non** è un nuovo stato ma la colonna `payout_blocked_at` ([DAT-68]), così l'elenco degli stati attivi, e con esso l'indice su espressione, non cambia. - **[DAT-16]** `place_bet` protegge l'inserimento del partecipante con un compare-and-set sulla riga del round, nella stessa transazione: `UPDATE rounds SET status = 'open' WHERE id = :id AND status = 'open'`. Se l'aggiornamento non tocca esattamente una riga la transazione viene annullata e la scommessa rifiutata prima di qualunque broadcast (B-53). L'aggiornamento non cambia alcun valore: garantisce che, al commit, il round fosse ancora `open` nella stessa transazione che inserisce il partecipante, così il passaggio a `closing` e l'inserimento non possono intrecciarsi. La transazione è aperta con `BEGIN IMMEDIATE` ([DAT-74]), quindi le letture che precedono il compare-and-set (saldo, partecipanti già presenti) vedono lo stesso stato su cui si scrive. Un `OperationalError` in questo punto è il `busy_timeout` scaduto (`database is locked`), non un conflitto di snapshot: la scommessa è rifiutata allo stesso modo, perché nulla è ancora stato trasmesso. *Codice:* `app/bets/service.py:place_bet` · *Test:* `tests/unit/test_bets.py` · `tests/unit/test_sqlite_transactions.py` - **[DAT-17]** Al massimo **un round attivo** alla volta, come vincolo del database: `ix_rounds_single_active` è un indice unico sull'espressione costante `(1)`, parziale sugli stati attivi (`WHERE status IN ('open', 'closing', 'drawing', 'paying_out')`). La tabella può quindi contenere quanti round `closed` si vuole, ma un secondo inserimento attivo fallisce con `IntegrityError` (B-09). `open_new_round_if_needed` gestisce il conflitto annullando il proprio inserimento e adottando il round del concorrente, con al massimo 3 tentativi. L'indice è dichiarato sia con `sqlite_where` sia con `postgresql_where`, così resta parziale anche su PostgreSQL. `get_active_round` ordina comunque per `id` decrescente e registra un errore se ne trova più di uno. *Codice:* `app/db/models.py:Round` · `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:get_active_round` · *Test:* `tests/unit/test_rounds_service.py` - ~~**[DAT-18]**~~ Assorbito da [RND-05](ciclo-di-vita-del-round.md) e [RND-04](ciclo-di-vita-del-round.md): la copia della durata e della pausa all'apertura, la scadenza e il conteggio della pausa dal `closed_at` sono regole del round. Qui resta il fatto di schema: `duration_seconds` e `cooldown_seconds` sono colonne `NOT NULL` con `server_default` (tabella sopra), scritte solo all'inserimento del round. - ~~**[DAT-19]**~~ Assorbito da [RND-28](ciclo-di-vita-del-round.md): le due scritture di `closed_at` (chiusura delle scommesse, poi conferma del pagamento) sono comportamento del round. Qui resta il fatto di schema: `closed_at` è l'unica colonna di `rounds` scritta due volte. - **[DAT-20]** `drawing_started_at` si scrive una sola volta, al passaggio a `drawing`. Da lì si misura quanto l'estrazione sta aspettando un blocco (`draw_waiting_since` in `GET /rounds/current`, le voci `draw_stalled` del registro, B-36). *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_rounds_route.py` - **[DAT-21]** `draw_block_height`, `draw_block_hash`, `seed_int`, `winner_user_id` e `pool_amount_sats` si scrivono insieme, nella stessa transazione che porta il round a `paying_out`. Poi `draw_block_height`, `draw_block_hash`, `seed_int` e `winner_user_id` non cambiano più. `pool_amount_sats` è la somma di `bet_amount_sats` dei soli partecipanti `confirmed`, gli stessi fra cui si estrae il vincitore; può essere solo **ridotto**, una volta, dalla verifica che precede il pagamento ([DAT-67]). *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* `tests/unit/test_scheduler.py` - **[DAT-22]** `winner_amount_sats`, `fee_amount_sats`, `bump_reserve_sats` e `payout_txid` si scrivono nella fase 3 di `_trigger_payout`, insieme alla riga `pending_transactions` del pagamento e **prima** del broadcast (B-25). `payout_txid` segue poi il txid corrente a ogni bump RBF. Quando il riconciliatore abbandona il pagamento tornano **tutti** a `NULL`, nella stessa transazione del database, così un round in `paying_out` senza pagamento in volo non mostra importi di un tentativo che non esiste più. Un bump RBF non cambia `winner_amount_sats` né `fee_amount_sats`: riduce solo l'output di riserva ([DAT-73]). *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/broadcast.py:_retarget_txid_references` · `app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`, `tests/unit/test_reconcile.py` *Stato:* da implementare (B-96, B-79) · *Oggi:* l'abbandono azzera solo `payout_txid` e lascia `winner_amount_sats`/`fee_amount_sats` del tentativo abbandonato; non esiste un output di riserva e il bump riduce un resto del pool che normalmente non c'è. - **[DAT-60]** Ogni round ha il proprio indirizzo del montepremi, `pool_address`, derivato all'apertura da `m/84'/746'/0'/2/` e scritto nella stessa transazione che inserisce il round (l'`id` è noto dopo il `flush`, prima del commit); non cambia più. Le scommesse del round pagano questo indirizzo. I round aperti prima della migrazione ricevono come `pool_address` l'indirizzo unico di oggi (`m/84'/746'/0'/1/0`), che da allora è l'indirizzo di **riserva**: il loro pagamento spende, come per ogni round, gli outpoint delle loro scommesse a quell'indirizzo ([DAT-11]). Per questo la colonna non ha un vincolo `UNIQUE`: i round nuovi hanno indirizzi distinti per costruzione (percorso diverso per ogni `id`), i round vecchi condividono l'indirizzo di riserva. *Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/wallet/hd.py:derive_pool_key` · *Test:* — *Stato:* da implementare (B-80) · *Oggi:* la colonna non esiste; tutte le scommesse di tutti i round pagano `m/84'/746'/0'/1/0`. - **[DAT-61]** `bet_amount_sats`, `fee_address` e `draw_confirmations` di `rounds` sono una **copia** dei valori di `round_config` presa quando il round si apre, nella stessa transazione dell'inserimento, e non cambiano più. Le scommesse del round costano `rounds.bet_amount_sats`, il suo pagamento paga la commissione a `rounds.fee_address`, la sua estrazione attende `rounds.draw_confirmations` conferme. La regola di dominio è in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-95). La migrazione riempie le righe esistenti con i valori attuali di `round_config` ([DAT-55]). *Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* — *Stato:* da implementare (B-95) · *Oggi:* `place_bet` e `_trigger_payout` leggono `bet_amount_sats` e `fee_address` da `round_config` al momento dell'uso, quindi una modifica a metà round cambia il round in corso. - **[DAT-64]** Il segreto del round sono 32 byte casuali generati con `secrets.token_bytes(32)`. All'inserimento del round si scrivono insieme `server_seed_hash` (`sha256` di quei 32 byte, esadecimale minuscolo) e `server_seed_enc`: il segreto **cifrato con Fernet** usando la stessa chiave che cifra la master xprv (`XPRV_ENCRYPTION_KEY`, dopo B-91 il file di [sicurezza.md](sicurezza.md#sec-53) SEC-53). Il segreto in chiaro non viene mai scritto nel database prima della rivelazione: `server_seed` resta `NULL` e si valorizza (esadecimale minuscolo, 64 caratteri) nella stessa transazione che scrive `winner_user_id`, oppure alla chiusura di un round senza partecipanti. Così una copia del database o un suo backup, senza la chiave, non rivela il segreto di un round ancora aperto. Nessun endpoint e nessuna voce del registro di audit contengono il segreto prima della rivelazione ([api.md](api.md), `GET /rounds/{id}/proof`). Nessuna colonna cambia più dopo la rivelazione; `server_seed_enc` resta com'è. Se all'avvio la chiave non decifra il `server_seed_enc` di un round non ancora rivelato, il server rifiuta di partire ([sicurezza.md](sicurezza.md#sec-49) SEC-49). I round aperti prima della migrazione hanno le tre colonne a `NULL`. *Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/api/routes/rounds.py:current_round` · *Test:* — *Stato:* da implementare (B-73) · *Oggi:* le colonne non esistono; il seme è l'hash del blocco da solo. - **[DAT-65]** All'ingresso in `drawing`, nella **stessa transazione** che cambia lo stato, si scrivono `draw_height`, `participants_hash` e il `draw_position` di ogni partecipante `confirmed` ([DAT-66]). `draw_height` è l'altezza della punta nota in quel momento più 1; `participants_hash` è l'impronta dell'elenco ordinato dei partecipanti con la codifica di [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73). Nessuno dei tre cambia più. Sono ciò che permette di riprendere l'estrazione dopo un errore o un riavvio ottenendo lo stesso vincitore (B-81): la ripresa legge questi valori dal database e non ricalcola nulla. *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* — *Stato:* da implementare (B-73, B-81) · *Oggi:* le colonne non esistono; l'elenco dei partecipanti e la punta alla chiusura restano solo in memoria. - **[DAT-67]** Prima di costruire il pagamento, gli outpoint delle scommesse vengono verificati sulla rete; i partecipanti la cui scommessa non c'è più passano a `reorged_out` ([DAT-24]) e `pool_amount_sats` viene riscritto come somma dei partecipanti rimasti `confirmed`, **nella stessa transazione del database**. Il valore scritto è sempre minore o uguale al precedente. La regola di dominio (quorum, ripartizione su ciò che c'è) è in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-82). *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* — *Stato:* da implementare (B-82) · *Oggi:* `pool_amount_sats` non cambia dopo l'estrazione, e un pagamento che non trova tutte le scommesse fallisce con "insufficient pool UTXOs" a ogni tentativo. - **[DAT-68]** `payout_blocked_at` si scrive quando la verifica di [DAT-67] trova assente la scommessa del **vincitore**: il round resta `paying_out`, non si costruisce alcun pagamento finché la colonna non è `NULL`, e nella stessa transazione si attiva la pausa con `paused_reason = 'payout_blocked'` ([DAT-69]). Torna `NULL` solo con `POST /admin/resume` ([api.md](api.md)), dopo di che la verifica si ripete al tentativo successivo. *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/api/routes/admin.py:resume_lottery` · *Test:* — *Stato:* da implementare (B-82) · *Oggi:* la colonna non esiste. - **[DAT-73]** Ogni pagamento ha un output di **riserva** verso l'indirizzo di riserva (`m/84'/746'/0'/1/0`), di valore `max(DUST_LIMIT_SATS, vsize stimata × 10 sat/vB)`, preso dalla quota delle commissioni; `bump_reserve_sats` ne registra il valore iniziale. Un bump RBF del pagamento riduce questo output (non `winner_amount_sats` né `fee_amount_sats`) e non riscrive `bump_reserve_sats`. La regola di dominio è in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-79). *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/wallet/psbt_builder.py:build_payout_transaction` · `app/tx/broadcast.py:bump_fee` · *Test:* — *Stato:* da implementare (B-79) · *Oggi:* la colonna non esiste; il pagamento non ha di norma alcun resto e quindi non si può accelerare. ### `round_participants` Una scommessa: un utente in un round. | Colonna | Tipo | Null | Default | Significato | Scritta da | |---|---|---|---|---|---| | `id` | Integer PK | no | autoincrement | — | database | | `round_id` | FK `rounds.id`, indicizzata | no | — | Il round | `app/bets/service.py:place_bet` | | `user_id` | FK `users.id`, indicizzata | no | — | Chi scommette | `place_bet` | | `bet_amount_sats` | BigInteger | no | — | Quanto arriva davvero al pool: costo della scommessa **meno** la commissione di rete | `place_bet` | | `bet_txid` | String(64) | no | — | Txid corrente della scommessa; dopo la conferma, il txid della versione confermata ([DAT-14]) | `place_bet`; `app/tx/broadcast.py:_retarget_txid_references` | | `bet_vout` | Integer | no | `0` (`server_default`, backfill `0`) | Posizione dell'output verso il montepremi nella transazione della scommessa ([DAT-63]) — *da implementare (B-73)* | `place_bet` | | `broadcast_at` | DateTime | no | `utcnow` | Istante della fase 1 di `place_bet`, vedi [DAT-25] | `place_bet` | | `confirmed_at` | DateTime | sì | `NULL` | Istante in cui la scommessa è risultata confermata | `app/bets/confirmation.py:_on_bet_confirmed` | | `draw_position` | Integer | sì | `NULL` | Posizione (da 0) nell'elenco ordinato dell'estrazione; valorizzata solo per i partecipanti `confirmed` all'ingresso in `drawing` ([DAT-66]) — *da implementare (B-81)* | `app/rounds/scheduler.py:RoundScheduler._close_and_draw` | | `status` | String(16) | no | `"broadcast"` (mai usato: si scrive sempre esplicitamente) | Vedi [DAT-24] | vedi diagramma | - **[DAT-23]** Al massimo una scommessa per utente per round: vincolo unico su (`round_id`, `user_id`). `place_bet` lo verifica già prima di costruire la transazione (`already_betting`). Il vincolo è anche la chiave con cui i gestori di conferma, bump e riconciliazione ritrovano la riga, invece del txid che cambia. *Codice:* `app/db/models.py:RoundParticipant` · `app/bets/service.py:place_bet` · `app/bets/confirmation.py:_on_bet_confirmed` · *Test:* `tests/unit/test_bets.py` - **[DAT-24]** `status` assume solo i valori `building`, `broadcast`, `confirmed`, `reorged_out`. Una scommessa che non va a buon fine prima della conferma viene **cancellata**, e l'utente non risulta nel round. `reorged_out` è terminale e riguarda solo una scommessa già `confirmed` il cui outpoint non è più sulla catena alla verifica che precede il pagamento ([DAT-67]): la riga **non** si cancella, perché ha già un `draw_position` e fa parte dell'elenco pubblicato con `participants_hash`, ma non conta nel montepremi. Diagramma del comportamento atteso (oggi manca `reorged_out`): ```mermaid stateDiagram-v2 [*] --> building: place_bet fase 1 building --> broadcast: place_bet fase 2 / _promote building --> confirmed: _on_bet_confirmed broadcast --> confirmed: _on_bet_confirmed building --> [*]: cancellata (_release_failed_bet / _abandon) broadcast --> [*]: cancellata (_abandon) confirmed --> reorged_out: verifica prima del pagamento (da implementare) confirmed --> [*] reorged_out --> [*] ``` | Da | A | Funzione | |---|---|---| | — | `building` | `app/bets/service.py:place_bet` (fase 1, prima del broadcast) | | `building` | `broadcast` | `place_bet` (fase 2, broadcast riuscito); `app/tx/reconcile.py:_promote` | | `building`, `broadcast` | `confirmed` | `app/bets/confirmation.py:_on_bet_confirmed` | | `building` | cancellata | `app/bets/service.py:_release_failed_bet` (broadcast rifiutato esplicitamente) | | `building`, `broadcast` | cancellata | `app/tx/reconcile.py:_abandon` (abbandono con prova) | | `confirmed` | `reorged_out` | `app/rounds/scheduler.py:RoundScheduler._trigger_payout` (da implementare) | Se il compare-and-set di [DAT-16] fallisce, la riga `building` non arriva nemmeno al commit. Un broadcast dall'esito ambiguo (timeout, connessione caduta) lascia la riga in `building` per il riconciliatore ([scommesse-e-prelievi.md](scommesse-e-prelievi.md) B-76). *Codice:* `app/bets/service.py:place_bet` · `app/bets/service.py:_release_failed_bet` · `app/tx/reconcile.py:_promote` · `app/tx/reconcile.py:_abandon` · `app/bets/confirmation.py:_on_bet_confirmed` · *Test:* `tests/unit/test_bets.py`, `tests/unit/test_confirmation.py`, `tests/unit/test_reconcile.py` *Stato:* da implementare (B-82, B-76) · *Oggi:* `reorged_out` non esiste (un partecipante confermato la cui scommessa sparisce resta `confirmed`), e qualunque eccezione del broadcast cancella la riga come un rifiuto. - **[DAT-25]** `broadcast_at` è l'istante della fase 1 di `place_bet`, appena prima del broadcast, e non viene mai riscritto (nemmeno da un bump RBF). Non determina più l'ordine di estrazione, che è quello di [DAT-66]: resta un'informazione per `/admin` e per il registro. *Codice:* `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* `tests/unit/test_scheduler.py` *Stato:* da implementare (B-73) · *Oggi:* l'estrazione ordina i partecipanti confermati per `broadcast_at` crescente, senza criterio di parità. - **[DAT-63]** `bet_vout` è l'indice dell'output della scommessa che paga `rounds.pool_address`; con il costruttore attuale è sempre `0` (`recipient_sats` è il primo output). Insieme a `bet_txid` identifica l'outpoint che l'elenco dell'estrazione pubblica e che il pagamento spende ([DAT-11]). *Codice:* `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py:build_signed_transaction` · *Test:* — *Stato:* da implementare (B-73) · *Oggi:* la colonna non esiste; l'outpoint della scommessa non è registrato. - **[DAT-66]** `draw_position` si scrive una sola volta, all'ingresso in `drawing` ([DAT-65]), per ogni partecipante `confirmed` del round: i partecipanti sono ordinati per `bet_txid` crescente (confronto delle stringhe esadecimali minuscole) e numerati da 0. Il vincolo unico (`round_id`, `draw_position`) impedisce due partecipanti nella stessa posizione; i partecipanti senza posizione hanno `NULL`, ammesso più volte dal vincolo. `draw_position` non cambia nemmeno quando la riga passa a `reorged_out`. *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/db/models.py:RoundParticipant` · *Test:* — *Stato:* da implementare (B-73, B-81) · *Oggi:* la colonna non esiste; l'ordine esiste solo in memoria durante l'attesa del blocco. - **[DAT-26]** `bet_amount_sats` è l'importo del primo output della scommessa (`recipient_sats`), cioè il costo della scommessa del round (`rounds.bet_amount_sats`, [DAT-61]) meno la commissione di rete. Non cambia più: un bump RBF riduce il resto dell'utente, non l'output verso il pool. Per questo il montepremi si calcola sommando questa colonna, non moltiplicando il numero di partecipanti per il costo (B-11). *Codice:* `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py:build_signed_transaction` · `app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_bets.py` *Stato:* da implementare (B-95) · *Oggi:* il costo è `round_config.bet_amount_sats` letto al momento della scommessa. - **[DAT-27]** Il numero di righe di un round, contando tutti gli stati, non supera `MAX_PARTICIPANTS_PER_ROUND` (400): la 401ª scommessa è rifiutata con `round_full` prima che si muova denaro (B-52). Con `round_config.max_round_pool_sats` diverso da 0 ([DAT-62]), una scommessa è rifiutata con `round_full` anche quando la somma dei `bet_amount_sats` del round, contando tutti gli stati, ha già raggiunto quel tetto; la regola di dominio è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95). *Codice:* `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py` · *Test:* `tests/unit/test_bets.py` *Stato:* da implementare (B-95), solo per il tetto sul montepremi · *Oggi:* esiste solo il limite dei 400 partecipanti. ### `round_config` I parametri operativi, in **una sola riga**, modificabili a caldo da `/admin` senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). | Colonna | Tipo | Default | Limiti accettati da `PUT /admin/config` | Significato | Quando ha effetto | |---|---|---|---|---|---| | `id` | Integer PK | autoincrement | — | — | — | | `fee_address` | String(128), non null | nessun default nel modello; la riga nasce con `""` | indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Atteso: dal round **successivo**, copiato all'apertura ([DAT-61]). Oggi: subito, si legge a ogni costruzione del pagamento, quindi vale anche per il round in corso se il suo pagamento non è ancora stato costruito | | `bet_amount_sats` | BigInteger | `1_000_000_000` (10 PLM) | `> 0`, `≤ 100 000 PLM` | Costo della scommessa; anche il prelievo minimo | Atteso: per le scommesse dal round **successivo** ([DAT-61]), per il prelievo minimo subito. Oggi: subito, per le scommesse e i prelievi successivi; le scommesse già piazzate tengono il proprio importo ([DAT-26]) | | `round_duration_seconds` | Integer | `600` | 30 s – 7 giorni | Durata del round | Dal round **successivo** ([RND-05](ciclo-di-vita-del-round.md)) | | `round_cooldown_seconds` | Integer | `30` | 0 s – 24 h | Pausa dopo la chiusura | Dal round **successivo**: la pausa dopo il round in corso è quella copiata alla sua apertura ([RND-05](ciclo-di-vita-del-round.md)) | | `draw_animation_seconds` | Integer | `20` | 0 – 600 s | Durata minima dell'animazione dell'estrazione nel frontend; non condiziona l'estrazione vera | Subito (letto da `GET /rounds/current`) | | `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (10 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` | | `rbf_timeout_seconds` | Integer | `900` | 60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) | | `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) — *da implementare (B-82)* | Subito, per gli accrediti successivi; gli output già accreditati restano | | `draw_confirmations` | Integer | `3` | 1 – 20 | Conferme richieste sul blocco di estrazione ([DAT-62]) — *da implementare (B-73)* | Dal round **successivo**, copiato all'apertura ([DAT-61]) | | `max_round_pool_sats` | BigInteger | `0` (tetto disattivato) | `0`, oppure `≥ bet_amount_sats` e `≤ 4 000 000 000 000 000` (400 × 100 000 PLM) | Tetto alla somma delle scommesse di un round ([DAT-27], [DAT-62]) — *da implementare (B-95)* | Subito, anche per il round in corso: vale per le scommesse successive | | `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa della lotteria | Subito per l'apertura del round successivo; il round in corso non è toccato | | `paused_reason` | String(16) | `NULL` | non modificabile da `PUT /admin/config` | Perché la lotteria è in pausa ([DAT-69]) — *da implementare (B-78, B-82, B-85)* | Insieme a `paused` | | `updated_at` | DateTime | `utcnow`, aggiornato a ogni modifica (`onupdate`) | — | Ultima modifica della riga | ORM | - **[DAT-28]** La tabella contiene al più una riga, creata alla prima lettura con i default di colonna di `RoundConfig` e `fee_address = ""`. Tutti leggono la configurazione attraverso `get_round_config`, che prende la prima riga trovata. Il database non impone l'unicità della riga: la garanzia è che solo `get_round_config` ne inserisce una. I default di un'istanza nuova sono quelli del modello, non variabili d'ambiente. *Codice:* `app/rounds/config.py:get_round_config` · `app/db/models.py:RoundConfig` · *Test:* `tests/unit/test_admin.py` - **[DAT-29]** Le colonne modificabili da `PUT /admin/config` sono esattamente `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`, entro i limiti della tabella sopra. Ogni modifica effettiva scrive una voce `config_updated` con il valore prima e dopo, e un valore uguale a quello attuale non conta come modifica. *Codice:* `app/api/routes/admin.py:update_config` · `app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py` *Stato:* da implementare (B-73, B-82, B-95), per le ultime tre colonne · *Oggi:* le colonne modificabili sono le prime sette. - **[DAT-30]** `paused` e `paused_reason` si modificano solo con `POST /admin/pause` e `POST /admin/resume`, che scrivono `lottery_paused` e `lottery_resumed` nel registro (B-10), e con le pause automatiche di [DAT-69], che scrivono la propria voce di audit. *Codice:* `app/api/routes/admin.py:pause_lottery` · `app/api/routes/admin.py:resume_lottery` · *Test:* `tests/unit/test_admin.py` *Stato:* da implementare (B-78, B-82, B-85) · *Oggi:* `paused_reason` non esiste e nessun codice mette in pausa la lotteria da solo. - **[DAT-62]** `deposit_confirmations` (predefinito 6) è il numero di conferme dopo cui un output che paga un utente si accredita ([DAT-10]); `draw_confirmations` (predefinito 3) è il numero di blocchi che la catena deve avere a partire da `draw_height` prima che si legga l'hash del blocco di estrazione, copiato su ogni round all'apertura ([DAT-61]); `max_round_pool_sats` (predefinito 0) è il tetto di [DAT-27], disattivato quando vale 0. Il significato di dominio è in [registrazione-e-depositi.md](registrazione-e-depositi.md) (B-82), [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73) e [scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95). La migrazione le aggiunge con un `server_default` temporaneo pari al predefinito ([DAT-55]). *Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` · *Test:* — *Stato:* da implementare (B-73, B-82, B-95) · *Oggi:* le colonne non esistono; depositi ed estrazione usano 1 conferma e non c'è tetto. - **[DAT-69]** `paused_reason` è `NULL` esattamente quando `paused` è falso; con `paused` vero vale uno di questi valori, e nessun altro: | Valore | Chi lo scrive | Quando | |---|---|---| | `operator` | `app/api/routes/admin.py:pause_lottery`; l'avvio con `START_PAUSED=true` ([operativita.md](operativita.md) B-90) | pausa decisa dall'operatore | | `reorg` | il listener Electrum | riorganizzazione di profondità ≥ 2 ([connessione-electrum.md](connessione-electrum.md) B-85) | | `payout_blocked` | `app/rounds/scheduler.py:RoundScheduler._trigger_payout` | la scommessa del vincitore non è più sulla catena ([DAT-68], B-82) | | `resurrected_tx` | `app/tx/reconcile.py:reconcile_once` | una riga `failed` risulta confermata ([DAT-72], B-78) | Una pausa automatica (`reorg`, `payout_blocked`, `resurrected_tx`) scrive sempre il proprio motivo, anche sopra una pausa già attiva; una pausa `operator` non sovrascrive un motivo automatico già presente, così la ragione che richiede un controllo non si perde. `POST /admin/resume` è l'unico modo di tornare a `paused = false`, `paused_reason = NULL`, per qualunque motivo. La pausa, come oggi, impedisce solo l'apertura del round successivo. *Codice:* `app/api/routes/admin.py:pause_lottery` · `app/api/routes/admin.py:resume_lottery` · `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/reconcile.py:reconcile_once` · *Test:* — *Stato:* da implementare (B-78, B-82, B-85, B-90) · *Oggi:* esiste solo `paused`, scritto solo dai due endpoint admin. - **[DAT-31]** Con `fee_address` vuoto (o di soli spazi) non si apre nessun round (B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la validazione rifiuta la stringa vuota come indirizzo non valido. *Codice:* `app/rounds/service.py:rounds_can_open` · `app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_admin.py` ### `pending_transactions` Ogni transazione in uscita dalla piattaforma: scommesse, prelievi e pagamenti. È la riga su cui lavorano il poller delle conferme, il bumper RBF e il riconciliatore (vedi [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md)). | Colonna | Tipo | Null | Default | Significato | Scritta da | |---|---|---|---|---|---| | `id` | Integer PK | no | autoincrement | — | database | | `kind` | String(16) | no | — | `bet`, `withdrawal` o `payout` | chi crea la riga | | `round_id` | FK `rounds.id` | sì | `NULL` | Il round (`bet`, `payout`) | `place_bet`; `_trigger_payout` | | `withdrawal_id` | FK `withdrawals.id` | sì | `NULL` | Il prelievo (`withdrawal`) | `request_withdrawal` | | `user_id` | FK `users.id` | sì | `NULL` | Il mittente (`bet`, `withdrawal`); `NULL` per `payout` | `place_bet`; `request_withdrawal` | | `current_txid` | String(64) | no | — | Txid della versione più recente della transazione | creazione; `bump_fee` | | `fee_rate_sat_vb` | Integer | no | — | Commissione della versione corrente, in sat/vB | creazione; `bump_fee` | | `raw_tx_hex` | Text | no | — | Transazione firmata della versione corrente | creazione; `bump_fee` | | `broadcast_at` | DateTime | no | `utcnow` | Prima trasmissione, vedi [DAT-35] | ORM, alla creazione | | `last_broadcast_at` | DateTime | no | `utcnow` | Ultima trasmissione, vedi [DAT-35] | ORM; `bump_fee` | | `status` | String(16) | no | `"pending"` (mai usato: si crea sempre `building`) | Vedi [DAT-33] | vedi diagramma | | `replaced_by_txid` | String(64) | sì | `NULL` | Il txid **sostituito** dall'ultimo bump (nonostante il nome, punta all'indietro). Nello schema atteso la cronologia completa è in `pending_transaction_txids` ([DAT-70]) e questa colonna resta solo informativa | `bump_fee` | | `attempt_count` | Integer | no | `1` | 1 + numero di bump riusciti | `bump_fee` | | `failure_reason` | String(128) | sì | `NULL` | Perché la transazione è fallita; solo con `status = failed` | `_abandon`; `_release_failed_withdrawal` | | `failed_at` | DateTime | sì | `NULL` | Istante del passaggio a `failed`; da lì si contano i 14 giorni di sorveglianza ([DAT-72]) — *da implementare (B-78)* | `_abandon`; `_release_failed_withdrawal` | | `updated_at` | DateTime | no | `utcnow`, `onupdate` | Ultima modifica | ORM | Le funzioni citate sono `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/tx/broadcast.py:bump_fee`, `app/tx/reconcile.py:_abandon` e `app/withdrawals/service.py:_release_failed_withdrawal`. - **[DAT-32]** Le colonne di collegamento dipendono da `kind`: | `kind` | `round_id` | `user_id` | `withdrawal_id` | Firmata con | Riga di dominio collegata | |---|---|---|---|---|---| | `bet` | il round | lo scommettitore | `NULL` | chiave dell'utente | `round_participants` (`round_id`, `user_id`) | | `withdrawal` | `NULL` | chi preleva | il prelievo | chiave dell'utente | `withdrawals` (`withdrawal_id`) | | `payout` | il round | `NULL` | `NULL` | chiave del montepremi del round (`m/84'/746'/0'/2/`; la chiave di riserva per i round anteriori a B-80) | `rounds` (`round_id`) | I gestori delle conferme e del riconciliatore ritrovano la riga di dominio da questi identificativi, che non cambiano, e non dal txid, che cambia a ogni bump (B-02). L'indirizzo di cui controllare la cronologia si ricava da `kind` e `user_id` e, per un `payout`, da `rounds.pool_address` del `round_id` (atteso, B-80; oggi è l'unico indirizzo del pool per tutti i pagamenti). *Codice:* `app/tx/pending_address.py:own_address_for` · `app/tx/broadcast.py:_signing_context` · `app/bets/confirmation.py:_on_bet_confirmed` · `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` · `app/rounds/confirmation.py:_on_payout_confirmed` · *Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_pending_address.py` - **[DAT-33]** `status` assume solo i valori `building`, `pending`, `confirmed`, `failed`. `confirmed` è terminale; `failed` è terminale per il bumper e per il poller ordinario, ma per 14 giorni da `failed_at` la riga resta sorvegliata e può ancora passare a `confirmed` se una delle sue versioni conferma ([DAT-72]). Diagramma del comportamento atteso (oggi `failed` è definitivo e il broadcast ambiguo non esiste come caso a sé): ```mermaid stateDiagram-v2 [*] --> building: creazione, prima del broadcast building --> building: broadcast ambiguo (timeout, connessione caduta) building --> pending: broadcast accettato / _promote building --> failed: rifiuto esplicito / _abandon con prova building --> [*]: cancellata (_release_failed_bet, rifiuto esplicito) pending --> confirmed: poll_once pending --> failed: _abandon con prova failed --> confirmed: conferma tardiva entro 14 giorni (pending_tx_resurrected) confirmed --> [*] failed --> [*]: dopo 14 giorni da failed_at ``` | Da | A | Funzione | |---|---|---| | — | `building` | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` | | `building` | `pending` | le stesse tre, dopo che il server attivo ha accettato il broadcast (anche con un rifiuto "già noto / già nel mempool"); `app/tx/reconcile.py:_promote` | | `pending` | `confirmed` | `app/tx/confirmation.py:poll_once` (poi il gestore del `kind`) | | `building`, `pending` | `failed` | `app/tx/reconcile.py:_abandon`, solo con una prova ([transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) B-78) | | `building` | `failed` | `app/withdrawals/service.py:_release_failed_withdrawal`, solo per un rifiuto esplicito | | `building` | cancellata | `app/bets/service.py:_release_failed_bet`, solo per un rifiuto esplicito | | `failed` | `confirmed` | `app/tx/reconcile.py:reconcile_once` ([DAT-72]) | Un broadcast dall'esito ambiguo lascia la riga in `building` per il riconciliatore, per tutti e tre i `kind` (B-76). Il poller delle conferme guarda le righe `pending`, più le `failed` entro 14 giorni. *Codice:* le funzioni in tabella · *Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_reconcile.py`, `tests/unit/test_scheduler.py`, `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py` *Stato:* da implementare (B-76, B-78) · *Oggi:* qualunque eccezione del broadcast di una scommessa o di un prelievo è trattata come un rifiuto; l'abbandono avviene quando la cronologia del solo server attivo non contiene `current_txid`; il poller guarda solo le righe `pending` e una riga `failed` non viene più guardata. - **[DAT-34]** Dopo la creazione, `current_txid`, `raw_tx_hex`, `fee_rate_sat_vb`, `replaced_by_txid`, `attempt_count` e `last_broadcast_at` cambiano solo in `bump_fee`, e `current_txid`/`raw_tx_hex` anche nel riallineamento a una versione precedente confermata ([DAT-14]). `bump_fee` procede in quest'ordine: 1. scrive la nuova versione in `pending_transaction_txids` come **candidata**, con un commit, prima di trasmetterla ([DAT-71]); 2. la trasmette; 3. aggiorna la riga con `UPDATE ... WHERE id = :id AND status = 'pending' AND current_txid = :vecchio`, insieme al retarget di [DAT-14], in una sola transazione. Se l'aggiornamento tocca zero righe (la riga è stata confermata, abbandonata o già riaccelerata nel frattempo) non scrive nulla e registra il conflitto, che il riconciliatore risolve. `fee_rate_sat_vb` registra la commissione effettivamente pagata dalla nuova versione, non quella obiettivo. *Codice:* `app/tx/broadcast.py:bump_fee` · *Test:* `tests/unit/test_broadcast.py` *Stato:* da implementare (B-77) · *Oggi:* la nuova versione è trasmessa nella fase 2 prima di esistere nel database, e la fase 3 riscrive la riga **senza ricontrollarne** stato e `current_txid`: un abbandono o una conferma avvenuti nel frattempo vengono sovrascritti. `replaced_by_txid` conserva solo il txid immediatamente precedente. - **[DAT-35]** `broadcast_at` è la **prima** trasmissione e non viene mai riscritta: è il riferimento da cui il riconciliatore misura i propri tempi di attesa, definiti in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md). `last_broadcast_at` è l'ultima trasmissione: la aggiorna `bump_fee` e da lì `should_bump` misura se serve un altro bump. Tenerle separate impedisce che bump ripetuti rinviino all'infinito il controllo del riconciliatore (B-27). *Codice:* `app/tx/reconcile.py:_is_due` · `app/tx/broadcast.py:should_bump` · `app/tx/broadcast.py:bump_fee` · *Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_reconcile.py` - **[DAT-36]** `failure_reason` e `failed_at` si scrivono solo quando la riga passa a `failed`, `failure_reason` troncato a 128 caratteri. Il motivo nomina la prova su cui si è abbandonato (un input speso da un'altra transazione confermata, con il suo txid, oppure il rifiuto esplicito della ritrasmissione, con il messaggio della rete, B-78), o il messaggio d'errore della rete per un broadcast rifiutato. Una riga che risorge ([DAT-72]) conserva `failure_reason` e `failed_at`. *Codice:* `app/tx/reconcile.py:_abandon` · `app/withdrawals/service.py:_release_failed_withdrawal` · *Test:* `tests/unit/test_reconcile.py` *Stato:* da implementare (B-78) · *Oggi:* il riconciliatore scrive sempre `"not found on chain"` e `failed_at` non esiste. - **[DAT-72]** Una riga `failed` con `failed_at` negli ultimi 14 giorni resta tra quelle che il riconciliatore controlla a ogni giro (120 s, [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) TX-23), su tutti i txid di `pending_transaction_txids`. Se una versione risulta confermata, la riga passa a `confirmed`, si scrive `pending_tx_resurrected` e la lotteria va in pausa con `paused_reason = 'resurrected_tx'` ([DAT-69]), tutto nella stessa transazione. Le righe di dominio già riportate indietro dall'abbandono (partecipante cancellato, prelievo `failed`, input liberati) **non** vengono ricostruite automaticamente: le riallinea l'operatore ([operativita.md](operativita.md) B-78). *Codice:* `app/tx/reconcile.py:reconcile_once` · `app/tx/reconcile.py:_abandon` · *Test:* — *Stato:* da implementare (B-78) · *Oggi:* una riga `failed` non viene mai più letta. - **[DAT-37]** Per le righe `bet` e `withdrawal` in `building`/`pending`, `raw_tx_hex` è la fonte del saldo "in attesa" (gli output che tornano all'indirizzo dell'utente). Per le righe `payout` in `building`/`pending` definisce gli outpoint del pool già impegnati ([DAT-11]). Per ogni riga abbandonata definisce gli input da liberare ([DAT-13]). *Codice:* `app/wallet/balance.py:compute_pending_balance` · `app/rounds/scheduler.py:_reserved_payout_outpoints` · `app/tx/reconcile.py:_release_inputs` · *Test:* `tests/unit/test_balance.py`, `tests/unit/test_scheduler.py`, `tests/unit/test_reconcile.py` - **[DAT-38]** Le righe `pending_transactions` non si cancellano, con una sola eccezione: la riga `bet` il cui broadcast è stato rifiutato, cancellata insieme al partecipante da `_release_failed_bet`. Un prelievo rifiutato invece lascia la riga, in `failed`. *Codice:* `app/bets/service.py:_release_failed_bet` · `app/withdrawals/service.py:_release_failed_withdrawal` · *Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py` ### `pending_transaction_txids` *Tabella da implementare (B-77).* Ogni versione che una riga `pending_transactions` abbia mai avuto: la prima, ogni sostituzione RBF trasmessa e ogni candidata scritta prima di un bump. Serve perché una qualunque di queste versioni può essere quella che la rete conferma. | Colonna | Tipo | Null | Default | Significato | Scritta da | |---|---|---|---|---|---| | `id` | Integer PK | no | autoincrement | — | database | | `pending_transaction_id` | FK `pending_transactions.id`, indicizzata | no | — | La transazione di cui questa è una versione | `place_bet`; `request_withdrawal`; `_trigger_payout`; `bump_fee` | | `txid` | String(64), unico | no | — | Txid della versione | come sopra | | `raw_hex` | Text | no | — | Transazione firmata di questa versione; stringa vuota solo per un `replaced_by_txid` ricostruito dal backfill della migrazione, di cui la transazione grezza non è più disponibile | come sopra | | `created_at` | DateTime | no | `utcnow` | Istante in cui la versione è stata scritta, prima della sua trasmissione | ORM | - **[DAT-70]** Ogni txid che una riga `pending_transactions` abbia mai avuto come versione ha una riga qui, scritta nello **stesso commit** che precede la sua trasmissione: la prima versione insieme alla riga `pending_transactions` in `building` ([DAT-44]), ogni sostituzione nella fase 1 di `bump_fee` ([DAT-34]). Le righe non si modificano e non si cancellano; `txid` è unico su tutta la tabella, quindi un txid appartiene a una sola transazione della piattaforma. Il poller delle conferme e il riconciliatore cercano **tutti** i txid della riga, non solo `current_txid`: la conferma di uno qualunque conferma la riga ([DAT-14]). La riga `bet` cancellata da `_release_failed_bet` ([DAT-38]) porta con sé le proprie versioni, nella stessa transazione. *Codice:* `app/db/models.py` · `app/tx/broadcast.py:bump_fee` · `app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` · *Test:* — *Stato:* da implementare (B-77) · *Oggi:* la tabella non esiste; poller e riconciliatore confrontano solo `current_txid`, e `replaced_by_txid` ricorda un solo passo indietro. - **[DAT-71]** Una versione **candidata** è una riga di questa tabella il cui txid non è (ancora) `current_txid`: `bump_fee` l'ha scritta ma la fase 3 non l'ha ancora adottata, perché il processo si è fermato, la trasmissione è fallita o l'aggiornamento condizionale ha trovato un conflitto. Una candidata mai trasmessa è innocua (nessuno la conferma); una candidata trasmessa e poi confermata viene riconosciuta come qualunque altra versione. Il riconciliatore, quando ritrasmette, usa il `raw_tx_hex` corrente, non quello di una candidata. *Codice:* `app/tx/broadcast.py:bump_fee` · `app/tx/reconcile.py:reconcile_once` · *Test:* — *Stato:* da implementare (B-77) · *Oggi:* non esistono candidate: la sostituzione esiste nel database solo dopo il suo broadcast riuscito. ### `withdrawals` Una richiesta di prelievo verso un indirizzo esterno. A differenza di una scommessa, resta nella tabella anche quando fallisce: è un'istruzione data dall'utente. | Colonna | Tipo | Null | Default | Significato | Scritta da | |---|---|---|---|---|---| | `id` | Integer PK | no | autoincrement | — | database | | `user_id` | FK `users.id`, indicizzata | no | — | Chi preleva | `app/withdrawals/service.py:request_withdrawal` | | `external_address` | String(128) | no | — | Destinatario: un indirizzo PLM valido, diverso da quello dell'utente e (atteso, B-80) da ogni indirizzo di montepremi, di riserva e delle commissioni | `request_withdrawal` | | `amount_requested_sats` | BigInteger | no | — | Importo chiesto dall'utente | `request_withdrawal` | | `amount_sent_sats` | BigInteger | sì (sempre valorizzata dal codice) | `NULL` | Quanto riceve il destinatario, vedi [DAT-40] | `request_withdrawal` | | `txid` | String(64) | sì | `NULL` | Txid corrente; `NULL` dopo un fallimento | `request_withdrawal`; `_retarget_txid_references`; `_release_failed_withdrawal`; `_abandon` | | `status` | String(16) | no | `"pending"` (mai usato: si crea sempre `building`) | Vedi [DAT-39] | vedi diagramma | | `created_at` | DateTime | no | `utcnow` | Istante della richiesta | ORM | | `confirmed_at` | DateTime | sì | `NULL` | Istante in cui il prelievo è risultato confermato | `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` | - **[DAT-39]** `status` assume solo i valori `building`, `broadcast`, `confirmed`, `failed`; gli ultimi due sono terminali. Il passaggio a `failed` azzera `txid`. Diagramma del comportamento atteso (oggi un broadcast ambiguo porta a `failed` come un rifiuto): ```mermaid stateDiagram-v2 [*] --> building: request_withdrawal building --> building: broadcast ambiguo (risposta 202) building --> broadcast: broadcast accettato / _promote building --> confirmed: _on_withdrawal_confirmed broadcast --> confirmed: _on_withdrawal_confirmed building --> failed: rifiuto esplicito / _abandon con prova broadcast --> failed: _abandon con prova confirmed --> [*] failed --> [*] ``` | Da | A | Funzione | |---|---|---| | — | `building` | `app/withdrawals/service.py:request_withdrawal` | | `building` | `broadcast` | `request_withdrawal` (broadcast accettato); `app/tx/reconcile.py:_promote` | | `building`, `broadcast` | `confirmed` | `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` | | `building` | `failed` | `app/withdrawals/service.py:_release_failed_withdrawal` (rifiuto esplicito) | | `building`, `broadcast` | `failed` | `app/tx/reconcile.py:_abandon` (abbandono con prova) | Se la riga `pending_transactions` di un prelievo `failed` risorge ([DAT-72]), il prelievo resta `failed` finché l'operatore non lo riallinea. *Codice:* le funzioni in tabella · *Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py` *Stato:* da implementare (B-76, B-78) · *Oggi:* qualunque eccezione del broadcast porta a `failed`, e l'abbandono non richiede una prova. - **[DAT-40]** `amount_requested_sats` è ciò che l'utente ha chiesto; `amount_sent_sats` è l'output effettivo verso il destinatario: l'importo chiesto meno la commissione di rete e, per un prelievo dell'intero saldo, meno quanto serve a lasciare un resto di `DUST_LIMIT_SATS` (B-62). Le due colonne si scrivono insieme alla creazione e non cambiano più: un bump RBF riduce il resto, non l'output del destinatario. *Codice:* `app/withdrawals/service.py:request_withdrawal` · `app/wallet/psbt_builder.py:build_signed_transaction` · *Test:* `tests/unit/test_withdrawals.py` ### `bug_reports` Una segnalazione inviata da `/report-bug`, anche senza login. | Colonna | Tipo | Null | Default | Significato | Scritta da | |---|---|---|---|---|---| | `id` | Integer PK | no | autoincrement | — | database | | `description` | Text | no | — | Testo della segnalazione, già ripulito dagli spazi, 1–2000 caratteri (limite dell'API) | `app/api/routes/bug_reports.py:create_bug_report` | | `contact` | String(256) | sì | `NULL` | Recapito facoltativo; una stringa vuota diventa `NULL` | `create_bug_report` | | `user_id` | FK `users.id` | sì | `NULL` | Chi ha segnalato, se aveva fatto il login | `create_bug_report` | | `status` | String(16) | no | `"open"` | `open`, `read` o `resolved` | `create_bug_report` (default); `app/api/routes/admin.py:update_bug_report_status` | | `created_at` | DateTime | no | `utcnow` | Istante della segnalazione | ORM | - **[DAT-41]** `status` assume solo i valori `open`, `read`, `resolved`. Nasce `open`. Poi lo cambia solo l'amministratore, verso **uno qualunque** dei tre valori (il percorso previsto è `open → read → resolved`, ma anche i ritorni indietro sono ammessi). Ogni cambio effettivo scrive `bug_report_status_changed` con il valore prima e dopo, e riassegnare lo stesso valore non scrive nulla (B-60). `read` esiste per permettere a chi ha segnalato di distinguere "visto" da "risolto". *Codice:* `app/api/routes/admin.py:update_bug_report_status` · `app/api/routes/bug_reports.py:create_bug_report` · *Test:* `tests/unit/test_bug_reports.py` ### `audit_log` Il registro di audit: chi ha fatto cosa, e quando. Il catalogo dei tipi di evento e dei loro payload è in [audit-log.md](audit-log.md). | Colonna | Tipo | Null | Default | Significato | |---|---|---|---|---| | `id` | Integer PK | no | autoincrement | Ordine di scrittura | | `event_type` | String(32) | no | — | Tipo di evento (es. `bet_placed`, `payout_failed`) | | `payload_json` | Text | no | — | Dettagli dell'evento, oggetto JSON serializzato | | `user_id` | FK `users.id` | sì | `NULL` | Utente interessato, se c'è | | `round_id` | FK `rounds.id` | sì | `NULL` | Round interessato, se c'è | | `created_at` | DateTime | no | `utcnow` | Istante di scrittura | - **[DAT-42]** Il registro è in sola aggiunta: le righe si inseriscono solo con `write_audit_log`, che aggiunge la riga alla sessione del chiamante senza fare commit, così la voce entra nella stessa transazione del cambiamento che descrive. Nessun codice aggiorna o cancella righe di `audit_log`. `payload_json` è `Text` (B-47). *Codice:* `app/audit/log.py:write_audit_log` · *Test:* `tests/unit/test_models_text_columns.py`, `tests/unit/test_admin.py` - **[DAT-43]** Il registro non è solo informativo. Il `created_at` dell'ultima voce `payout_failed` di un round fa da orologio per il riprovare del pagamento: un nuovo tentativo parte solo 60 s dopo (B-26). Per questo ogni uscita anticipata di `_trigger_payout` scrive una voce `payout_failed`. *Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` · `app/rounds/scheduler.py:RoundScheduler._log_payout_failure` · *Test:* `tests/unit/test_scheduler.py` ### `alembic_version` Tabella di servizio di Alembic: una riga con la revisione applicata (`version_num`). Non è nel modello e il codice applicativo non la legge. ## Indici e vincoli | Nome | Tabella | Definizione | Perché | |---|---|---|---| | PK `id` | tutte | intero autoincrementale | [DAT-05] | | `ix_users_username` | `users` | `UNIQUE (username)` | unicità esatta, dallo schema iniziale; ridondante rispetto alla riga sotto | | `ix_users_username_lower` | `users` | `UNIQUE (lower(username))` | username senza distinzione di maiuscole, [DAT-06] (B-57) | | unico `derivation_index` | `users` | `UNIQUE` | un indice HD, quindi un indirizzo, per utente, [DAT-07] | | unico `address` | `users` | `UNIQUE` | un indirizzo non appartiene a due utenti | | unico (`txid`, `vout`) | `utxo_events` | `UNIQUE` | un outpoint si accredita una volta sola, [DAT-10] | | `ix_utxo_events_user_id` | `utxo_events` | indice su `user_id` | calcolo del saldo e selezione degli UTXO per utente | | `ix_rounds_single_active` | `rounds` | `UNIQUE ((1)) WHERE status IN ('open','closing','drawing','paying_out')` | al massimo un round attivo, [DAT-17] (B-09) | | unico (`round_id`, `user_id`) | `round_participants` | `UNIQUE` | una scommessa per utente per round, [DAT-23] | | unico (`round_id`, `draw_position`) | `round_participants` | `UNIQUE` (i `NULL` non collidono) | una posizione di estrazione per partecipante, [DAT-66] — *da implementare (B-81)* | | unico `txid` | `pending_transaction_txids` | `UNIQUE` | un txid appartiene a una sola transazione, [DAT-70] — *da implementare (B-77)* | | `ix_pending_transaction_txids_pending_transaction_id` | `pending_transaction_txids` | indice | versioni di una transazione — *da implementare (B-77)* | | `ix_round_participants_round_id` | `round_participants` | indice | partecipanti di un round | | `ix_round_participants_user_id` | `round_participants` | indice | round di un utente (`last-round-result`) | | `ix_withdrawals_user_id` | `withdrawals` | indice | prelievi di un utente | Non ci sono indici su `pending_transactions`, `audit_log` o `bug_reports` oltre alla chiave primaria. In particolare `pending_transactions.status` e `audit_log.event_type`/`round_id`, usati dai filtri dei task in background, sono letti con una scansione completa. ## Invarianti tra tabelle - **[DAT-44]** **Prima si scrive, poi si trasmette.** Ogni **versione** di una transazione in uscita esiste nel database prima di essere trasmessa. Per la prima versione: la riga `pending_transactions` in `building`, la sua riga in `pending_transaction_txids`, la riga di dominio (partecipante in `building`, prelievo in `building`, oppure `payout_txid` sul round) e, per scommesse e prelievi, gli input già marcati con `spent_txid`, tutto in un commit che precede il broadcast (B-08, B-25). Per una sostituzione RBF: la sua riga candidata in `pending_transaction_txids`, in un commit che precede il broadcast ([DAT-34], [DAT-71]). Nessuna sessione del database resta aperta durante una chiamata di rete. *Codice:* `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/broadcast.py:bump_fee` · *Test:* `tests/unit/test_bets.py`, `tests/unit/test_scheduler.py` *Stato:* da implementare (B-77), per le sostituzioni RBF · *Oggi:* l'invariante vale per la prima versione; una sostituzione RBF viene trasmessa nella fase 2 di `bump_fee` e scritta nel database solo nella fase 3, quindi un arresto o un `database is locked` fra le due lascia sulla rete una transazione che il database non conosce. - **[DAT-45]** **Ogni versione è riconoscibile, una sola è corrente.** Per ogni transazione non terminale, `pending_transactions.current_txid` coincide con il txid registrato sulla riga di dominio (`round_participants.bet_txid`, `withdrawals.txid`, `rounds.payout_txid`) e con lo `spent_txid` di ogni input impegnato ([DAT-14]); ogni altro txid che la transazione abbia avuto è in `pending_transaction_txids` ([DAT-70]), e la conferma di uno qualunque di essi conferma la transazione. `current_txid` cambia solo con un aggiornamento condizionato al valore precedente ([DAT-34]), che aggiorna tutti i riferimenti nella stessa transazione del database. Per un pagamento, `payout_txid` si aggiorna solo se era ancora uguale al vecchio txid. *Codice:* `app/tx/broadcast.py:_retarget_txid_references` · `app/tx/broadcast.py:bump_fee` · `app/tx/confirmation.py:poll_once` · *Test:* `tests/unit/test_broadcast.py` *Stato:* da implementare (B-77) · *Oggi:* l'aggiornamento dei riferimenti avviene in una transazione del database separata dal broadcast e non condizionata, e i txid precedenti sono dimenticati (salvo l'ultimo, in `replaced_by_txid`, che nessuno legge): se conferma una versione precedente, poller e riconciliatore non la riconoscono. - **[DAT-46]** **Il montepremi di ogni round si conserva, separato dagli altri.** Per un round pagato, con i soli partecipanti rimasti `confirmed` dopo la verifica di [DAT-67]: - gli input del pagamento sono esattamente gli outpoint (`bet_txid`, `bet_vout`) di quei partecipanti, all'indirizzo `rounds.pool_address` del round ([DAT-11]); nessun input viene da un altro round o da un altro indirizzo; - `pool_amount_sats` = somma dei loro `bet_amount_sats` = somma dei valori di quegli input; - 70% (arrotondato per difetto) di `pool_amount_sats` = `winner_amount_sats` + commissione di rete del pagamento; - il resto (30% più l'arrotondamento) = `fee_amount_sats` + `bump_reserve_sats` ([DAT-73]). Quindi `pool_amount_sats` = `winner_amount_sats` + `fee_amount_sats` + `bump_reserve_sats` + commissione di rete, al satoshi. La commissione di rete esce dalla quota del vincitore, la riserva da quella della piattaforma. La ripartizione è nel codice, non in `round_config`. *Codice:* `app/rounds/service.py:winner_share` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/wallet/psbt_builder.py:build_payout_transaction` · *Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py` *Stato:* da implementare (B-79, B-80, B-82) · *Oggi:* tutti i round condividono un indirizzo, il pagamento sceglie fra tutti i suoi UTXO confermati e può avere un resto verso quello stesso indirizzo; non c'è riserva, e vale `pool_amount_sats` = `winner_amount_sats` + `fee_amount_sats` + commissione di rete. - ~~**[DAT-47]**~~ Assorbito da [RND-12](ciclo-di-vita-del-round.md): l'attesa delle scommesse in volo prima dell'estrazione è comportamento del round. Il testo di qui diceva anche che l'attesa finisce perché ogni riga in volo viene "prima o poi" confermata o cancellata: non è una garanzia (una riga non viene abbandonata senza una prova, B-78) e non va ripetuta. - ~~**[DAT-48]**~~ Assorbito da [RND-26](ciclo-di-vita-del-round.md): le due protezioni contro il doppio pagamento sono comportamento del round. Lo schema non ha un vincolo che le imponga; ciò che il database fornisce è il `raw_tx_hex` da cui si leggono gli outpoint impegnati ([DAT-37]). - **[DAT-49]** **Le transazioni ritrovano la loro riga senza il txid.** Le righe collegate a una `pending_transactions` si ritrovano tramite `round_id`, `user_id` e `withdrawal_id` ([DAT-32]); il txid serve solo come ripiego per righe senza collegamento. *Codice:* `app/bets/confirmation.py:_on_bet_confirmed` · `app/rounds/confirmation.py:_on_payout_confirmed` · *Test:* `tests/unit/test_confirmation.py` ## Motore e sessioni - **[DAT-50]** C'è un solo motore asincrono per processo, creato all'importazione di `app/db/base.py` da `settings.database_url` (`DATABASE_URL`; predefinito `sqlite+aiosqlite:///./plm_lottery.db`, e nel container `sqlite+aiosqlite:////app/db_data/plm_lottery.db`, montato da `./data/db`). Le sessioni si creano con `AsyncSessionLocal` (`async_sessionmaker` con `expire_on_commit=False`): gli oggetti letti restano utilizzabili dopo il commit. *Codice:* `app/db/base.py` · `app/config.py` - **[DAT-51]** Gli endpoint HTTP ricevono una sessione per richiesta dalla dipendenza `get_session`. I task in background ricevono `AsyncSessionLocal` come factory e aprono sessioni brevi, chiuse prima di ogni chiamata di rete. Il commit è sempre esplicito: `get_session` non ne fa alla fine della richiesta. *Codice:* `app/db/session.py:get_session` · `app/main.py` · *Test:* `tests/unit/test_broadcast.py` - **[DAT-52]** Su SQLite ogni nuova connessione esegue `PRAGMA journal_mode=WAL`, `PRAGMA synchronous=FULL` e `PRAGMA busy_timeout=5000` (B-39): lettori e scrittori non si bloccano a vicenda, un secondo scrittore aspetta fino a 5 s invece di fallire subito, e ogni commit è sul disco prima che il commit ritorni, così un'interruzione di corrente non perde un commit già confermato al chiamante (una registrazione, e con essa il suo `derivation_index`, B-88). Con un altro dialetto non viene eseguito nulla. `PRAGMA foreign_keys` non è tra questi, da cui [DAT-04]. *Codice:* `app/db/base.py:_register_sqlite_pragmas` · *Test:* `tests/unit/test_db_base.py` · `tests/unit/test_sqlite_transactions.py` - **[DAT-74]** Ogni transazione del database che sposta denaro o ne fissa lo stato inizia con `BEGIN IMMEDIATE`, che prende il lock di scrittura di SQLite **prima** della prima lettura: le letture su cui la transazione decide (saldo, UTXO liberi, partecipanti, stato del round o della riga `pending_transactions`) e le scritture che ne seguono vedono lo stesso stato, senza che un altro scrittore si inserisca fra le due. Riguarda almeno le fasi di scrittura di `place_bet`, `request_withdrawal`, `_trigger_payout`, `bump_fee`, l'accredito dei depositi (accredito, marcatura come speso all'esterno, reintegro: quest'ultimo solo quando c'è qualcosa da reintegrare, perché gira a ogni aggiornamento), le transizioni di stato dello scheduler, i gestori di conferma, il riconciliatore e l'allocazione dell'indice di derivazione alla registrazione (senza il lock due registrazioni simultanee leggevano lo stesso massimo). Si ottiene con `begin_immediate(session)`, che chiude l'eventuale transazione di sola lettura già aperta (di solito la ricerca dell'utente autenticato) e apre quella nuova con `BEGIN IMMEDIATE`; le altre transazioni, in sola lettura, restano `BEGIN` semplice e in WAL non aspettano nessuno. Un `OperationalError` `database is locked` è il `busy_timeout` di [DAT-52] scaduto: prima di un broadcast la transazione viene annullata e l'operazione rifiutata o rimandata al giro successivo; **dopo** un broadcast non si annulla nulla sulla rete, la riga resta nello stato scritto prima del broadcast (`building`) e il riconciliatore la risolve ([transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) B-88). *Codice:* `app/db/base.py:begin_immediate` · `app/db/base.py:_register_sqlite_pragmas` · `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/broadcast.py:bump_fee` · `app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` · `app/deposits/service.py:credit_confirmed_utxos` · `app/auth/routes.py:register` · *Test:* `tests/unit/test_sqlite_transactions.py` ## Migrazioni ### Politica - **[DAT-53]** Lo schema si cambia solo con una revisione Alembic in `migrations/versions/`. Si modifica `app/db/models.py`, poi si genera la revisione con `alembic revision --autogenerate -m "..."` e la si **rilegge e corregge a mano** prima del commit. La catena è lineare, senza rami. `migrations/env.py` prende l'URL da `settings.database_url` e i metadati da `Base.metadata`, e non abilita `render_as_batch`: le modifiche a colonne esistenti su SQLite vanno scritte a mano dentro `op.batch_alter_table`. *Codice:* `migrations/env.py` · `app/db/models.py` - **[DAT-54]** Le migrazioni si applicano **solo** all'avvio del container: il comando del `Dockerfile` controlla che esista la master key, esegue `alembic upgrade head` e solo se riesce avvia `uvicorn`. Una migrazione che fallisce impedisce quindi al server di partire invece di lasciarlo girare su uno schema a metà. `alembic upgrade head` non va mai lanciato a mano. *Codice:* `Dockerfile` - **[DAT-55]** Una colonna `NOT NULL` aggiunta a una tabella che può avere già righe deve dare un valore a quelle righe nella migrazione stessa, in uno di due modi: un `server_default` temporaneo, rimosso subito dopo in modo che le righe nuove prendano il default dell'ORM (`round_config`, `users.token_version`); oppure un backfill esplicito calcolato dai dati (`last_broadcast_at` copiato da `broadcast_at`; `rounds.duration_seconds`/`cooldown_seconds` copiati da `round_config`, così il round in corso non cambia durata con la migrazione). *Codice:* `migrations/versions/53cc70d16e63_add_operational_params_to_round_config.py` · `migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py` · `migrations/versions/283844a44b4a_snapshot_round_timing_onto_the_round_.py` - **[DAT-56]** Gli indici su espressioni (`ix_rounds_single_active`, `ix_users_username_lower`) vanno scritti a mano nella migrazione: l'autogenerate su SQLite non li sa confrontare e li ignora con un avviso. Inoltre `op.batch_alter_table` su SQLite ricrea la tabella e **perde senza errori** gli indici su espressioni, perché SQLAlchemy non li legge dallo schema. Una migrazione che usi `batch_alter_table` su `rounds` o `users` deve quindi, nella stessa `upgrade()`, eliminare e ricreare esplicitamente l'indice relativo con SQL grezzo, dopo il blocco `batch_alter_table`. Le migrazioni di B-73/B-80/B-81/B-95 aggiungono colonne a `rounds`: se aggiungono solo colonne con `op.add_column` (che su SQLite non ricrea la tabella) l'indice resta; se usano `batch_alter_table`, devono ricrearlo. Il test di [DAT-75] è ciò che rende questa regola verificabile. *Codice:* `migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py` · `migrations/versions/c1d4a97b5e10_case_insensitive_usernames.py` - **[DAT-75]** Un test esegue `alembic upgrade head` su un database SQLite vuoto e verifica che, alla fine della catena, esistano `ix_rounds_single_active` e `ix_users_username_lower` con la loro definizione (lette da `sqlite_master`), e che un secondo round attivo e un secondo username uguale a meno delle maiuscole vengano rifiutati con `IntegrityError`. Il test gira con la suite unitaria, quindi una migrazione che perde uno dei due indici non entra nel repository. *Codice:* `migrations/env.py` · *Test:* — *Stato:* da implementare (B-97) · *Oggi:* nessun test esegue le migrazioni (vedi sotto). - **[DAT-57]** Una migrazione che incontra dati incompatibili con un nuovo vincolo si comporta in uno di due modi. Se esiste una correzione **senza ambiguità**, la applica e procede. Se correggere significherebbe decidere sulla proprietà dei fondi, **si rifiuta**, e il server non parte ([DAT-54]). Le due migrazioni di questo tipo sono [DAT-58] e [DAT-59]. - **[DAT-58]** `c1d4a97b5e10` (username senza distinzione di maiuscole, B-57) **si rifiuta di procedere** se due account esistenti hanno username che differiscono solo per maiuscole/minuscole: solleva un errore che elenca i gruppi in collisione e non crea l'indice. Non sceglie quale account rinominare, perché entrambi possono contenere fondi: l'operatore rinomina a mano un account (e avvisa l'utente), poi riavvia (vedi [operativita.md](operativita.md)). *Codice:* `migrations/versions/c1d4a97b5e10_case_insensitive_usernames.py` - **[DAT-59]** `8a1c4e7b2d90` (indice del round attivo unico, B-09), se trova più round attivi, porta a `closed` tutti tranne il più recente prima di creare l'indice. È la stessa scelta che `get_active_round` faceva già in silenzio. *Codice:* `migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py` Oggi nessun test esegue le migrazioni: i test unitari creano lo schema direttamente dal modello (`Base.metadata.create_all`), quindi verificano il modello, non la catena di revisioni. [DAT-75] (B-97) aggiunge il primo test sulla catena. ### Storia dello schema In ordine di applicazione (ogni riga rivede la precedente): | Revisione | Contenuto | |---|---| | `274efdcbfbcc` | Schema iniziale: `round_config` (solo `fee_address`, `bet_amount_sats`, `updated_at`), `users`, `rounds`, `utxo_events`, `withdrawals`, `audit_log`, `pending_transactions`, `round_participants` | | `53cc70d16e63` | `round_config`: `round_duration_seconds`, `round_cooldown_seconds`, `min_amount_sats`, `fee_rate_sat_vb`, `rbf_timeout_seconds` (valori prima presi da `app/config.py`) | | `1db52f3a7c67` | `round_config.draw_animation_seconds` | | `5f2079b95b33` | `round_config.paused` | | `6cb50b29f64c` | Rimosso `round_config.min_amount_sats`: il prelievo minimo è il costo della scommessa | | `8a1c4e7b2d90` | `pending_transactions.failure_reason`; indice `ix_rounds_single_active` (B-04, B-09) | | `861e76aaf34c` | `pending_transactions.last_broadcast_at`, con backfill da `broadcast_at` (B-27) | | `943dbd74d983` | `users.token_version` (B-34) | | `9ef6a51509f7` | `rounds.drawing_started_at` (B-36) | | `87a0c640355c` | `audit_log.payload_json` e `pending_transactions.raw_tx_hex` da `VARCHAR` a `Text` (B-47) | | `ee8508d98d34` | Tabella `bug_reports`, con un booleano `resolved` | | `be71fdac734e` | `bug_reports.resolved` sostituito da `status` a tre valori (`resolved` → `'resolved'`, il resto → `'open'`) | | `c1d4a97b5e10` | Indice `ix_users_username_lower` (B-57), [DAT-58] | | `283844a44b4a` | `rounds.duration_seconds`/`cooldown_seconds` (B-61), backfill dalla configurazione attuale — **head** | Revisioni attese, non ancora scritte (ciascuna nel commit della sua voce, con i backfill di [DAT-55]): `rounds` (`pool_address`, `bet_amount_sats`, `fee_address`, `draw_confirmations`, `server_seed`, `server_seed_hash`, `participants_hash`, `draw_height`, `bump_reserve_sats`, `payout_blocked_at`), `round_participants` (`bet_vout`, `draw_position` e il vincolo unico), `round_config` (`deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats`, `paused_reason`, con `paused_reason = 'operator'` per un'istanza già in pausa), `pending_transactions.failed_at` (backfill da `updated_at` per le righe `failed`), e la tabella `pending_transaction_txids` (backfill: una riga per `current_txid` e una per `replaced_by_txid` non nullo di ogni riga esistente; per la seconda il `raw_hex` non è più disponibile e si scrive la stringa vuota). ### Differenze note tra modello e schema migrato - `users.token_version`: il modello dichiara `server_default="0"`, ma la migrazione `943dbd74d983` rimuove il default del server dopo il backfill. Un database migrato non ha quindi un default a livello SQL. Non ha effetti pratici, perché ogni inserimento passa dall'ORM che fornisce `0`, e l'autogenerate non confronta i default del server, quindi non lo segnala. - `rounds.duration_seconds`/`cooldown_seconds`: `server_default` presente sia nel modello sia nel database migrato. ## Scelte specifiche di SQLite - **Un solo file, un solo processo.** SQLite con WAL regge i sei task in background e le richieste HTTP di un singolo processo uvicorn ([DAT-52]). I lock per utente e il canale SSE sono comunque in memoria, quindi più processi non sono supportati in nessun caso (vedi [limiti-noti.md](limiti-noti.md)). - **Serializzazione degli scrittori.** SQLite ha un solo scrittore alla volta per file; un secondo scrittore aspetta il lock fino al `busy_timeout` ([DAT-52]) e poi riceve `OperationalError: database is locked`. Non esistono conflitti fra snapshot: un `OperationalError` è sempre un'attesa scaduta. Le transazioni che spostano denaro prendono il lock con `BEGIN IMMEDIATE` prima di leggere ([DAT-74]), quindi il compare-and-set di [DAT-16] e le letture che lo precedono stanno nella stessa transazione. Il driver (pysqlite) non apre più le transazioni da solo (`isolation_level = None`): è l'evento `begin` del motore a emettere `BEGIN` o `BEGIN IMMEDIATE`, così ogni lettura sta dentro una transazione vera (B-88). - **Indice parziale su espressione.** `ix_rounds_single_active` usa sia un'espressione (`(1)`) sia una clausola `WHERE`; SQLite e PostgreSQL supportano entrambe, e il modello dichiara la clausola per tutti e due (`sqlite_where`, `postgresql_where`). La migrazione la crea invece con SQL grezzo (`CREATE UNIQUE INDEX ... ON rounds ((1)) WHERE ...`). - **Timestamp senza fuso.** SQLite non conserva il fuso orario ([DAT-02]). - **Chiavi esterne non applicate** ([DAT-04]). - **`Text` invece di `VARCHAR` senza lunghezza** per i contenuti di lunghezza arbitraria (`raw_tx_hex`, `payload_json`, `description`), così lo schema resta portabile ad altri database (B-47). - **Portabilità.** I pragma sono limitati al dialetto `sqlite` e l'indice del round attivo è dichiarato anche per PostgreSQL. Un `DATABASE_URL` PostgreSQL non è però mai stato provato, e il database supportato resta SQLite.