# 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) · [`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 Nove tabelle applicative. `round_config` non ha relazioni: è una riga singola di parametri. L'indirizzo del montepremi (il "pool") non è in nessuna tabella: è derivato dalla master key (`m/84'/746'/0'/1/0`, `app/wallet/hd.py:derive_pool_key`) e i suoi UTXO si leggono dalla rete. ```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" 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" 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 string payout_txid } round_participants { int id PK int round_id FK "UK con user_id" int user_id FK bigint bet_amount_sats string bet_txid datetime broadcast_at datetime confirmed_at string status "building broadcast confirmed" } 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 bool paused 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 updated_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`, [DAT-24], [DAT-38]); 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 con `height > 0` (almeno 1 conferma) che non è già presente, e solo dopo che gli altri server Electrum hanno corroborato lo stesso outpoint con lo stesso importo (B-59; la corroborazione è in [connessione-electrum.md](connessione-electrum.md)). 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` - **[DAT-11]** Gli UTXO del pool **non** sono in `utxo_events`: il pool non è un utente. Il pagamento sceglie i suoi input da una `listunspent` dell'indirizzo del pool, escludendo gli 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` - **[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 | | txid | `NULL` | `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal` | la rete ha rifiutato il broadcast | | txid | `NULL` | `app/tx/reconcile.py:_release_inputs` | la transazione è stata abbandonata ([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 | 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` - **[DAT-13]** Il riconciliatore libera un input solo se il suo `spent_txid` è uguale al `current_txid` della riga che sta abbandonando: 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` - **[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. 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` · *Test:* `tests/unit/test_broadcast.py` ### `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, vedi [DAT-15] | vedi diagramma | | `opened_at` | DateTime | no | `utcnow` | Apertura; base della scadenza | `app/rounds/service.py:open_new_round_if_needed` (default) | | `closed_at` | DateTime | sì | `NULL` | Vedi [DAT-19] | `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` | | `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 | `_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: `int(draw_block_hash, 16)` scritto in decimale (fino a 78 cifre, per questo è una stringa) | `_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 | `_close_and_draw` | | `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) | `_trigger_payout` | | `payout_txid` | String(64) | sì | `NULL` | Txid corrente della transazione di pagamento | `_trigger_payout`; `_retarget_txid_references`; `app/tx/reconcile.py:_abandon` | - **[DAT-15]** `status` assume solo i valori `open`, `closing`, `drawing`, `paying_out`, `closed`. I primi quattro sono gli stati **attivi**. Le transizioni sono esclusivamente queste: ```mermaid stateDiagram-v2 [*] --> open: open_new_round_if_needed open --> closing: _tick, scadenza superata closing --> closed: _close_and_draw, nessun partecipante confermato closing --> drawing: _close_and_draw, nessuna scommessa in volo drawing --> paying_out: _close_and_draw, blocco corroborato paying_out --> closed: _on_payout_confirmed closed --> [*] ``` | Da | A | Funzione | Condizione | |---|---|---|---| | — | `open` | `app/rounds/service.py:open_new_round_if_needed` | nessun round attivo, non in pausa, `fee_address` impostato, cooldown del round precedente trascorso | | `open` | `closing` | `app/rounds/scheduler.py:RoundScheduler._tick` | `now >= opened_at + duration_seconds` | | `closing` | `closed` | `RoundScheduler._close_and_draw` | nessuna scommessa `building`/`broadcast` e nessuna `confirmed` | | `closing` | `drawing` | `RoundScheduler._close_and_draw` | nessuna scommessa `building`/`broadcast`, almeno una `confirmed` | | `drawing` | `paying_out` | `RoundScheduler._close_and_draw` | `_wait_for_next_block` ha restituito un blocco corroborato | | `paying_out` | `closed` | `app/rounds/confirmation.py:_on_payout_confirmed` | la transazione di pagamento ha 1 conferma | Un pagamento abbandonato **non** cambia lo stato: il round resta `paying_out` con `payout_txid = NULL` e il pagamento viene ricostruito. L'unica altra scrittura di `status` fuori da questa tabella è la migrazione `8a1c4e7b2d90`, che una tantum portò a `closed` i round attivi duplicati ([DAT-59]). Il significato di ogni fase è in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md). *Codice:* le funzioni in tabella · *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_rounds_service.py`, `tests/unit/test_confirmation.py` - **[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 (o SQLite segnala un conflitto di scrittura) la transazione viene annullata e la scommessa rifiutata prima di qualunque broadcast (B-53). L'aggiornamento non cambia alcun valore: serve a prendere il lock di scrittura sulla riga, così il passaggio a `closing` e l'inserimento non possono intrecciarsi. *Codice:* `app/bets/service.py:place_bet` · *Test:* `tests/unit/test_bets.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]** `duration_seconds` e `cooldown_seconds` sono una **copia** dei valori di `round_config` presa quando il round si apre, e non cambiano più (B-61). La scadenza del round è `opened_at + duration_seconds`; la pausa prima del round successivo è il `cooldown_seconds` del round chiuso più recente, contata dal suo `closed_at`. Modificare `round_config` influisce quindi solo sui round aperti dopo. *Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:round_deadline` · *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py` - **[DAT-19]** `closed_at` si scrive due volte. La prima al passaggio `open → closing`, con l'ora della chiusura delle scommesse. La seconda alla conferma del pagamento (`paying_out → closed`), dove viene sovrascritto con l'ora della conferma, così la pausa tra i round parte da quando il vincitore è stato davvero pagato. Un round chiuso senza partecipanti tiene il primo valore. *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/rounds/confirmation.py:_on_payout_confirmed` · *Test:* `tests/unit/test_rounds_service.py` (conteggio della pausa da `closed_at`; la seconda scrittura non ha un test dedicato) - **[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`, e poi non cambiano più. `pool_amount_sats` è la somma di `bet_amount_sats` dei soli partecipanti `confirmed`, gli stessi fra cui si estrae il vincitore. *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* `tests/unit/test_scheduler.py` - **[DAT-22]** `winner_amount_sats`, `fee_amount_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, e torna `NULL` quando il riconciliatore abbandona il pagamento. `winner_amount_sats` e `fee_amount_sats` invece non vengono azzerati da un abbandono: li riscrive il tentativo successivo. Un bump RBF non li cambia, perché riduce solo il resto del pool. *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` ### `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 | `place_bet`; `app/tx/broadcast.py:_retarget_txid_references` | | `broadcast_at` | DateTime | no | `utcnow` | Ordine di estrazione, vedi [DAT-25] | `place_bet` | | `confirmed_at` | DateTime | sì | `NULL` | Istante in cui la scommessa è risultata confermata | `app/bets/confirmation.py:_on_bet_confirmed` | | `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`. Non esiste uno stato di fallimento: una scommessa che non va a buon fine viene **cancellata**, e l'utente non risulta nel round. ```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 --> [*] ``` | 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) | | `building`, `broadcast` | cancellata | `app/tx/reconcile.py:_abandon` | Se il compare-and-set di [DAT-16] fallisce, la riga `building` non arriva nemmeno al commit. *Codice:* le funzioni in tabella · *Test:* `tests/unit/test_bets.py`, `tests/unit/test_confirmation.py`, `tests/unit/test_reconcile.py` - **[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). L'estrazione ordina i partecipanti confermati per `broadcast_at` crescente. *Codice:* `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* `tests/unit/test_scheduler.py` - **[DAT-26]** `bet_amount_sats` è l'importo del primo output della scommessa (`recipient_sats`), cioè `round_config.bet_amount_sats` in vigore al momento della scommessa 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 attuale (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` - **[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). *Codice:* `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py` · *Test:* `tests/unit/test_bets.py` ### `round_config` I parametri operativi, in **una sola riga**, modificabili a caldo da `/admin` senza riavviare (vedi [decisioni-e-limiti.md](decisioni-e-limiti.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 | 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 | 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** ([DAT-18]) | | `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 ([DAT-18]) | | `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) | | `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa di manutenzione | Subito per l'apertura del round successivo; il round in corso non è toccato | | `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`, 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` - **[DAT-30]** `paused` si modifica solo con `POST /admin/pause` e `POST /admin/resume`, che scrivono `lottery_paused` e `lottery_resumed` nel registro (B-10). *Codice:* `app/api/routes/admin.py:pause_lottery` · `app/api/routes/admin.py:resume_lottery` · *Test:* `tests/unit/test_admin.py` - **[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) | `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` | | `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 pool | `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`. *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`; gli ultimi due sono terminali. ```mermaid stateDiagram-v2 [*] --> building: creazione, prima del broadcast building --> pending: broadcast riuscito / _promote building --> failed: _release_failed_withdrawal / _abandon building --> [*]: cancellata (_release_failed_bet) pending --> confirmed: poll_once pending --> failed: _abandon confirmed --> [*] failed --> [*] ``` | 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 il broadcast riuscito; `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` | | `building` | `failed` | `app/withdrawals/service.py:_release_failed_withdrawal` | | `building` | cancellata | `app/bets/service.py:_release_failed_bet` | Un pagamento il cui broadcast fallisce resta `building` finché il riconciliatore non lo risolve. Il poller delle conferme guarda solo le righe `pending`. *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` - **[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`, tutti nella stessa transazione del database, solo dopo che il nuovo broadcast è riuscito e solo per una riga ancora `pending`. `fee_rate_sat_vb` registra la commissione effettivamente pagata dalla nuova versione, non quella obiettivo. `replaced_by_txid` conserva solo il txid immediatamente precedente: la catena completa dei rimpiazzi non è memorizzata. *Codice:* `app/tx/broadcast.py:bump_fee` · *Test:* `tests/unit/test_broadcast.py` - **[DAT-35]** `broadcast_at` è la **prima** trasmissione e non viene mai riscritta: da lì il riconciliatore misura dopo quanto abbandonare (120 s per `building`, 6 h per `pending`). `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 l'abbandono (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` si scrive solo quando la riga passa a `failed`, troncato a 128 caratteri: `"not found on chain"` dal riconciliatore, il messaggio d'errore della rete per un prelievo rifiutato. *Codice:* `app/tx/reconcile.py:_abandon` · `app/withdrawals/service.py:_release_failed_withdrawal` · *Test:* `tests/unit/test_reconcile.py` - **[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` ### `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 | `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`. ```mermaid stateDiagram-v2 [*] --> building: request_withdrawal building --> broadcast: broadcast riuscito / _promote building --> confirmed: _on_withdrawal_confirmed broadcast --> confirmed: _on_withdrawal_confirmed building --> failed: _release_failed_withdrawal / _abandon broadcast --> failed: _abandon confirmed --> [*] failed --> [*] ``` | Da | A | Funzione | |---|---|---| | — | `building` | `app/withdrawals/service.py:request_withdrawal` | | `building` | `broadcast` | `request_withdrawal` (broadcast riuscito); `app/tx/reconcile.py:_promote` | | `building`, `broadcast` | `confirmed` | `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` | | `building` | `failed` | `app/withdrawals/service.py:_release_failed_withdrawal` | | `building`, `broadcast` | `failed` | `app/tx/reconcile.py:_abandon` | *Codice:* le funzioni in tabella · *Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py` - **[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] | | `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 transazione in uscita esiste nel database prima di essere trasmessa: la riga `pending_transactions` in `building`, 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). 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` · *Test:* `tests/unit/test_bets.py`, `tests/unit/test_scheduler.py` - **[DAT-45]** **Un solo txid valido.** 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]). `bump_fee` li aggiorna tutti 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` · *Test:* `tests/unit/test_broadcast.py` - **[DAT-46]** **Il montepremi si conserva.** Per un round pagato: `pool_amount_sats` = somma dei `bet_amount_sats` dei partecipanti `confirmed`; `fee_amount_sats` = `pool_amount_sats` − 70% (arrotondato per difetto) di `pool_amount_sats`; `winner_amount_sats` = quel 70% meno la commissione di rete del pagamento. La commissione di rete esce quindi dalla quota del vincitore, mai 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` - **[DAT-47]** **Un round non si chiude con scommesse in volo.** Finché un round ha partecipanti in `building` o `broadcast` resta in `closing`. Il conteggio si ripete nella stessa sessione che legge i partecipanti da estrarre (B-53), e l'attesa finisce perché ogni riga in volo viene prima o poi confermata o cancellata dal riconciliatore. *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* `tests/unit/test_scheduler.py` - **[DAT-48]** **Un solo pagamento per round.** Non si costruisce un pagamento per un round che ha già una riga `payout` in `building` o `pending`, e gli outpoint del pool impegnati da qualunque pagamento non risolto sono esclusi dalla selezione ([DAT-11]). *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/rounds/scheduler.py:_reserved_payout_outpoints` · *Test:* `tests/unit/test_scheduler.py` - **[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=NORMAL` e `PRAGMA busy_timeout=5000` (B-39): lettori e scrittori non si bloccano a vicenda, e un secondo scrittore aspetta fino a 5 s invece di fallire subito. 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` ## 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 futura che usi `batch_alter_table` su `rounds` o `users` deve quindi ricreare esplicitamente l'indice relativo. Oggi nessuna migrazione lo fa dopo la creazione di quegli indici. *Codice:* `migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py` · `migrations/versions/c1d4a97b5e10_case_insensitive_usernames.py` - **[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` 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. ### 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** | ### 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 [decisioni-e-limiti.md](decisioni-e-limiti.md)). - **Serializzazione degli scrittori.** Il compare-and-set di [DAT-16] si basa sul lock di scrittura di SQLite: un conflitto tra snapshot arriva come `OperationalError` e viene trattato come "round non più aperto". - **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.