Files
plm-lottery/docs/modello-dati.md
T

1558 lines
96 KiB
Markdown
Raw Normal View History

# 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/<derivation_index>` | `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/<round_id>` | `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/<index>` | `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): 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) · *Oggi:* si accredita ogni output
con `height > 0` (1 conferma), coinbase comprese.
- **[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/<id>` 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/<round_id>`; 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.