Files
plm-lottery/docs/modello-dati.md
T
davideandClaude Opus 5.5 61efbb0e37 Replace flowchart/ with a traceable specification in docs/
The two Mermaid diagrams under flowchart/ described the intended flow but
had drifted from the code and carried no link back to it. docs/ now holds
the specification instead: 15 Italian documents (flows, architecture, data
model, API, security, audit log, operations, decisions and limits, bug
history) with 472 numbered requirements, each pointing at the function
that implements it and the test that covers it. docs/processo-di-modifica.md
defines the ID convention and the spec -> code -> test change workflow, and
CLAUDE.md now names docs/ as authoritative.

Writing the spec from the code also corrected stale claims in CLAUDE.md
(B-41's get_history check, BUGS.md still tracking B-71/B-72, Cache-Control
scope, the error contract's bare-string exceptions) and in the operator
guides (payouts do retry, only `drawing` fails to resume, rounds do not
open without fee_address). The B-70 doc test now pins the prose in
docs/scommesse-e-prelievi.md instead of the deleted diagram.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:16:01 +02:00

1089 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Modello dati
Questo documento descrive il database di PLM Lottery: tutte le tabelle, ogni
colonna, i valori ammessi per i campi di stato e le transizioni tra di essi, gli
indici e i vincoli, le invarianti che legano più tabelle, la configurazione del
motore SQLAlchemy e la politica delle migrazioni Alembic.
La definizione di riferimento è [`app/db/models.py`](../app/db/models.py); lo
schema effettivo di un'istanza è quello prodotto dalla catena di migrazioni in
[`migrations/versions/`](../migrations/versions/). Le due cose coincidono (vedi
[Migrazioni](#migrazioni)), con le piccole differenze segnalate lì.
Cosa **non** c'è qui, e dove trovarlo:
- i flussi che scrivono queste righe, passo per passo:
[registrazione-e-depositi.md](registrazione-e-depositi.md),
[scommesse-e-prelievi.md](scommesse-e-prelievi.md),
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md),
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md);
- il catalogo dei tipi di evento di `audit_log`: [audit-log.md](audit-log.md);
- gli endpoint che leggono e scrivono queste tabelle: [api.md](api.md);
- chiavi, password e token: [sicurezza.md](sicurezza.md);
- backup e interventi manuali sul database: [operativita.md](operativita.md);
- la storia dei bug citati come `B-nn`: [storico-bug.md](storico-bug.md).
## Indice
- [Diagramma delle relazioni](#diagramma-delle-relazioni)
- [Convenzioni comuni](#convenzioni-comuni)
- Tabelle: [`users`](#users) · [`utxo_events`](#utxo_events) ·
[`rounds`](#rounds) · [`round_participants`](#round_participants) ·
[`round_config`](#round_config) ·
[`pending_transactions`](#pending_transactions) ·
[`withdrawals`](#withdrawals) · [`bug_reports`](#bug_reports) ·
[`audit_log`](#audit_log) · [`alembic_version`](#alembic_version)
- [Indici e vincoli](#indici-e-vincoli)
- [Invarianti tra tabelle](#invarianti-tra-tabelle)
- [Motore e sessioni](#motore-e-sessioni)
- [Migrazioni](#migrazioni)
- [Scelte specifiche di SQLite](#scelte-specifiche-di-sqlite)
## Diagramma delle relazioni
Nove tabelle applicative. `round_config` non ha relazioni: è una riga singola
di parametri. L'indirizzo del montepremi (il "pool") non è in nessuna tabella:
è derivato dalla master key (`m/84'/746'/0'/1/0`,
`app/wallet/hd.py:derive_pool_key`) e i suoi UTXO si leggono dalla rete.
```mermaid
erDiagram
users ||--o{ utxo_events : "possiede"
users ||--o{ round_participants : "scommette"
rounds ||--o{ round_participants : "raccoglie"
users |o--o{ rounds : "vince (winner_user_id)"
users ||--o{ withdrawals : "chiede"
rounds |o--o{ pending_transactions : "bet / payout"
withdrawals |o--o{ pending_transactions : "withdrawal"
users |o--o{ pending_transactions : "bet / withdrawal"
users |o--o{ bug_reports : "segnala"
users |o--o{ audit_log : "riguarda"
rounds |o--o{ audit_log : "riguarda"
users {
int id PK
string username UK "unico anche su lower(username)"
string password_hash "Argon2"
int derivation_index UK
string address UK "P2WPKH plm1..."
bigint cached_balance_sats "cache di utxo_events"
int token_version
datetime created_at
}
utxo_events {
int id PK
int user_id FK
string txid "UK con vout"
int vout
bigint amount_sats
int confirmed_height
datetime confirmed_at
string spent_txid "NULL, txid o external-spend"
}
rounds {
int id PK
string status "open closing drawing paying_out closed"
datetime opened_at
datetime closed_at
int duration_seconds "snapshot"
int cooldown_seconds "snapshot"
datetime drawing_started_at
int draw_block_height
string draw_block_hash
string seed_int
int winner_user_id FK
bigint pool_amount_sats
bigint winner_amount_sats
bigint fee_amount_sats
string payout_txid
}
round_participants {
int id PK
int round_id FK "UK con user_id"
int user_id FK
bigint bet_amount_sats
string bet_txid
datetime broadcast_at
datetime confirmed_at
string status "building broadcast confirmed"
}
round_config {
int id PK
string fee_address
bigint bet_amount_sats
int round_duration_seconds
int round_cooldown_seconds
int draw_animation_seconds
int fee_rate_sat_vb
int rbf_timeout_seconds
bool paused
datetime updated_at
}
pending_transactions {
int id PK
string kind "bet payout withdrawal"
int round_id FK
int withdrawal_id FK
int user_id FK
string current_txid
int fee_rate_sat_vb
text raw_tx_hex
datetime broadcast_at
datetime last_broadcast_at
string status "building pending confirmed failed"
string replaced_by_txid
int attempt_count
string failure_reason
datetime updated_at
}
withdrawals {
int id PK
int user_id FK
string external_address
bigint amount_requested_sats
bigint amount_sent_sats
string txid
string status "building broadcast confirmed failed"
datetime created_at
datetime confirmed_at
}
bug_reports {
int id PK
text description
string contact
int user_id FK
string status "open read resolved"
datetime created_at
}
audit_log {
int id PK
string event_type
text payload_json
int user_id FK
int round_id FK
datetime created_at
}
```
## Convenzioni comuni
- **[DAT-01]** Ogni importo è un numero intero di satoshi (1 PLM =
100 000 000 sat), in una colonna `BigInteger` il cui nome finisce in `_sats`.
Nessun importo passa mai per un `float`: anche il valore degli input letti
dalla rete per un RBF è preso come intero dalla transazione grezza (B-40).
*Codice:* `app/db/models.py` · `app/tx/broadcast.py:_prevout_amount` ·
*Test:* `tests/unit/test_broadcast.py`
- **[DAT-02]** Ogni timestamp è in UTC. Si scrive con `app/db/models.py:utcnow`
(default di colonna) o con `datetime.now(timezone.utc)`; le colonne sono
`DateTime` senza fuso, e SQLite restituisce valori *naive*. Chi legge un
timestamp per confrontarlo con l'ora corrente lo marca UTC con
`.replace(tzinfo=timezone.utc)`, e ogni risposta API lo serializza con
`isoformat_utc`, che aggiunge l'offset (B-35).
*Codice:* `app/db/models.py:utcnow` · `app/api/timeutil.py:isoformat_utc` ·
`app/rounds/service.py:round_deadline` ·
*Test:* `tests/unit/test_api_timeutil.py`, `tests/unit/test_scheduler.py`
- **[DAT-03]** I campi di stato (`status`, `kind`) sono stringhe libere
(`String(16)`), senza `CHECK` né enum nel database. I valori ammessi sono solo
quelli elencati in questo documento, e a garantirlo è esclusivamente il codice
che li scrive; l'unico vincolo del database che dipende da un valore di stato
è l'indice `ix_rounds_single_active` ([DAT-17]).
*Codice:* `app/db/models.py`
- **[DAT-04]** Le chiavi esterne sono dichiarate ma, su SQLite, **non
applicate**: `PRAGMA foreign_keys` non viene attivato
([DAT-52]). Nessuna chiave esterna ha `ON DELETE`. Le sole cancellazioni
fisiche nel codice sono quelle di una scommessa fallita (la riga
`round_participants` e la sua `pending_transactions`,
[DAT-24], [DAT-38]); nessun'altra tabella perde righe, quindi i riferimenti
restano validi per costruzione, non per vincolo.
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
`app/bets/service.py:_release_failed_bet` · `app/tx/reconcile.py:_abandon`
- **[DAT-05]** Ogni chiave primaria è un intero autoincrementale `id`, assegnato
dal database al `flush`/`commit`. Nessuna tabella usa chiavi naturali come
chiave primaria; le chiavi naturali (`txid`+`vout`, `round_id`+`user_id`,
`username`) sono vincoli di unicità separati.
*Codice:* `app/db/models.py`
## Tabelle
### `users`
Un account. Nasce alla registrazione e non viene mai cancellato.
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
| `id` | Integer PK | no | autoincrement | Identificativo; è il `sub` del JWT | database |
| `username` | String(64) | no | — | Nome scelto dall'utente, conservato **così come digitato** | `app/auth/routes.py:register` |
| `password_hash` | String(256) | no | — | Hash Argon2 della password | `register`; `app/api/routes/users.py:change_password`; `app/api/routes/admin.py:reset_user_password` |
| `derivation_index` | Integer, unico | no | — | Indice BIP84 dell'indirizzo: `m/84'/746'/0'/0/<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 con `height > 0` (almeno
1 conferma) che non è già presente, e solo dopo che gli altri server Electrum
hanno corroborato lo stesso outpoint con lo stesso importo (B-59; la
corroborazione è in [connessione-electrum.md](connessione-electrum.md)).
L'inserimento è idempotente su (`txid`, `vout`), che è unico su tutta la
tabella, non solo per utente. Le righe non si cancellano mai e, dopo
l'inserimento, solo `spent_txid` cambia.
*Codice:* `app/deposits/service.py:find_new_credit_candidates` ·
`app/deposits/service.py:credit_confirmed_utxos` ·
`app/electrum/listener.py:ElectrumListener.refresh_user` ·
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`
- **[DAT-11]** Gli UTXO del pool **non** sono in `utxo_events`: il pool non è un
utente. Il pagamento sceglie i suoi input da una `listunspent` dell'indirizzo
del pool, escludendo gli outpoint già impegnati da un pagamento non ancora
risolto (letti dal `raw_tx_hex` delle righe `pending_transactions` di tipo
`payout` in `building`/`pending`). Per lo stesso motivo l'abbandono di un
pagamento non ha input da liberare.
*Codice:* `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`
- **[DAT-12]** `spent_txid` ha tre significati:
- `NULL`: l'output è spendibile e conta nel saldo;
- un txid: l'output è impegnato da una transazione della piattaforma
(scommessa o prelievo), dal momento della sua costruzione, prima ancora
della trasmissione; resta impostato per sempre una volta confermata;
- la stringa `external-spend`: l'output è sparito dalla `listunspent` e gli
altri server hanno confermato che è stato speso fuori dalla piattaforma
(B-29).
Le transizioni ammesse sono queste, e nessun'altra funzione scrive la colonna:
| Da | A | Funzione | Quando |
|---|---|---|---|
| `NULL` | txid | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal` | fase 1, prima del broadcast |
| txid | nuovo txid | `app/tx/broadcast.py:_retarget_txid_references` | bump RBF |
| txid | `NULL` | `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal` | la rete ha rifiutato il broadcast |
| txid | `NULL` | `app/tx/reconcile.py:_release_inputs` | la transazione è stata abbandonata ([DAT-13]) |
| `NULL` | `external-spend` | `app/deposits/service.py:mark_utxos_spent_externally` | spesa esterna corroborata |
| `external-spend` | `NULL` | `app/deposits/service.py:reinstate_reappeared_utxos` | l'outpoint ricompare come non speso |
Chi sceglie gli input di una nuova transazione considera solo le righe con
`spent_txid IS NULL`.
*Codice:* le funzioni in tabella ·
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_bets.py`,
`tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py`
- **[DAT-13]** Il riconciliatore libera un input solo se il suo `spent_txid` è
uguale al `current_txid` della riga che sta abbandonando: se nel frattempo lo
stesso output risulta impegnato da un'altra transazione, quella rivendicazione
è quella valida e non va toccata.
*Codice:* `app/tx/reconcile.py:_release_inputs` ·
*Test:* `tests/unit/test_reconcile.py`
- **[DAT-14]** Per ogni output impegnato, `spent_txid` è sempre il txid
**corrente** della transazione che lo spende, cioè il `current_txid` della sua
riga `pending_transactions`. Un bump RBF cambia il txid e quindi riscrive,
nella stessa transazione del database, ogni `spent_txid` uguale al vecchio
txid. Senza questo allineamento [DAT-13] non riconoscerebbe più gli input di
una transazione rimpiazzata.
*Codice:* `app/tx/broadcast.py:bump_fee` ·
`app/tx/broadcast.py:_retarget_txid_references` ·
*Test:* `tests/unit/test_broadcast.py`
### `rounds`
Un round della lotteria, dall'apertura al pagamento confermato. I round chiusi
restano nella tabella come storico.
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
| `id` | Integer PK | no | autoincrement | — | database |
| `status` | String(16) | no | `"open"` | Fase del round, vedi [DAT-15] | vedi diagramma |
| `opened_at` | DateTime | no | `utcnow` | Apertura; base della scadenza | `app/rounds/service.py:open_new_round_if_needed` (default) |
| `closed_at` | DateTime | sì | `NULL` | Vedi [DAT-19] | `app/rounds/scheduler.py:RoundScheduler._tick`; `app/rounds/confirmation.py:_on_payout_confirmed` |
| `duration_seconds` | Integer | no | `600` (anche `server_default`) | Durata di **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` |
| `cooldown_seconds` | Integer | no | `30` (anche `server_default`) | Pausa dopo **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` |
| `drawing_started_at` | DateTime | sì | `NULL` | Istante del passaggio a `drawing` | `RoundScheduler._close_and_draw` |
| `draw_block_height` | Integer | sì | `NULL` | Altezza del blocco usato per l'estrazione | `_close_and_draw` |
| `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_close_and_draw` |
| `seed_int` | String(128) | sì | `NULL` | Il seme: `int(draw_block_hash, 16)` scritto in decimale (fino a 78 cifre, per questo è una stringa) | `_close_and_draw` |
| `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_close_and_draw` |
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate | `_close_and_draw` |
| `winner_amount_sats` | BigInteger | sì | `NULL` | Quanto riceve il vincitore (70% meno la commissione di rete) | `RoundScheduler._trigger_payout` |
| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address` (30% più il resto dell'arrotondamento) | `_trigger_payout` |
| `payout_txid` | String(64) | sì | `NULL` | Txid corrente della transazione di pagamento | `_trigger_payout`; `_retarget_txid_references`; `app/tx/reconcile.py:_abandon` |
- **[DAT-15]** `status` assume solo i valori `open`, `closing`, `drawing`,
`paying_out`, `closed`. I primi quattro sono gli stati **attivi**. Le
transizioni sono esclusivamente queste:
```mermaid
stateDiagram-v2
[*] --> open: open_new_round_if_needed
open --> closing: _tick, scadenza superata
closing --> closed: _close_and_draw, nessun partecipante confermato
closing --> drawing: _close_and_draw, nessuna scommessa in volo
drawing --> paying_out: _close_and_draw, blocco corroborato
paying_out --> closed: _on_payout_confirmed
closed --> [*]
```
| Da | A | Funzione | Condizione |
|---|---|---|---|
| — | `open` | `app/rounds/service.py:open_new_round_if_needed` | nessun round attivo, non in pausa, `fee_address` impostato, cooldown del round precedente trascorso |
| `open` | `closing` | `app/rounds/scheduler.py:RoundScheduler._tick` | `now >= opened_at + duration_seconds` |
| `closing` | `closed` | `RoundScheduler._close_and_draw` | nessuna scommessa `building`/`broadcast` e nessuna `confirmed` |
| `closing` | `drawing` | `RoundScheduler._close_and_draw` | nessuna scommessa `building`/`broadcast`, almeno una `confirmed` |
| `drawing` | `paying_out` | `RoundScheduler._close_and_draw` | `_wait_for_next_block` ha restituito un blocco corroborato |
| `paying_out` | `closed` | `app/rounds/confirmation.py:_on_payout_confirmed` | la transazione di pagamento ha 1 conferma |
Un pagamento abbandonato **non** cambia lo stato: il round resta `paying_out`
con `payout_txid = NULL` e il pagamento viene ricostruito. L'unica altra
scrittura di `status` fuori da questa tabella è la migrazione `8a1c4e7b2d90`,
che una tantum portò a `closed` i round attivi duplicati ([DAT-59]). Il
significato di ogni fase è in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
*Codice:* le funzioni in tabella ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_rounds_service.py`,
`tests/unit/test_confirmation.py`
- **[DAT-16]** `place_bet` protegge l'inserimento del partecipante con un
compare-and-set sulla riga del round, nella stessa transazione:
`UPDATE rounds SET status = 'open' WHERE id = :id AND status = 'open'`. Se
l'aggiornamento non tocca esattamente una riga (o SQLite segnala un conflitto
di scrittura) la transazione viene annullata e la scommessa rifiutata prima di
qualunque broadcast (B-53). L'aggiornamento non cambia alcun valore: serve a
prendere il lock di scrittura sulla riga, così il passaggio a `closing` e
l'inserimento non possono intrecciarsi.
*Codice:* `app/bets/service.py:place_bet` · *Test:* `tests/unit/test_bets.py`
- **[DAT-17]** Al massimo **un round attivo** alla volta, come vincolo del
database: `ix_rounds_single_active` è un indice unico sull'espressione
costante `(1)`, parziale sugli stati attivi
(`WHERE status IN ('open', 'closing', 'drawing', 'paying_out')`). La tabella
può quindi contenere quanti round `closed` si vuole, ma un secondo inserimento
attivo fallisce con `IntegrityError` (B-09). `open_new_round_if_needed`
gestisce il conflitto annullando il proprio inserimento e adottando il round
del concorrente, con al massimo 3 tentativi. L'indice è dichiarato sia con
`sqlite_where` sia con `postgresql_where`, così resta parziale anche su
PostgreSQL. `get_active_round` ordina comunque per `id` decrescente e registra
un errore se ne trova più di uno.
*Codice:* `app/db/models.py:Round` ·
`app/rounds/service.py:open_new_round_if_needed` ·
`app/rounds/service.py:get_active_round` ·
*Test:* `tests/unit/test_rounds_service.py`
- **[DAT-18]** `duration_seconds` e `cooldown_seconds` sono una **copia** dei
valori di `round_config` presa quando il round si apre, e non cambiano più
(B-61). La scadenza del round è `opened_at + duration_seconds`; la pausa prima
del round successivo è il `cooldown_seconds` del round chiuso più recente,
contata dal suo `closed_at`. Modificare `round_config` influisce quindi solo
sui round aperti dopo.
*Codice:* `app/rounds/service.py:open_new_round_if_needed` ·
`app/rounds/service.py:round_deadline` ·
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py`
- **[DAT-19]** `closed_at` si scrive due volte. La prima al passaggio
`open → closing`, con l'ora della chiusura delle scommesse. La seconda alla
conferma del pagamento (`paying_out → closed`), dove viene sovrascritto con
l'ora della conferma, così la pausa tra i round parte da quando il vincitore
è stato davvero pagato. Un round chiuso senza partecipanti tiene il primo
valore.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
`app/rounds/confirmation.py:_on_payout_confirmed` ·
*Test:* `tests/unit/test_rounds_service.py` (conteggio della pausa da
`closed_at`; la seconda scrittura non ha un test dedicato)
- **[DAT-20]** `drawing_started_at` si scrive una sola volta, al passaggio a
`drawing`. Da lì si misura quanto l'estrazione sta aspettando un blocco
(`draw_waiting_since` in `GET /rounds/current`, le voci `draw_stalled` del
registro, B-36).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
`app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_rounds_route.py`
- **[DAT-21]** `draw_block_height`, `draw_block_hash`, `seed_int`,
`winner_user_id` e `pool_amount_sats` si scrivono insieme, nella stessa
transazione che porta il round a `paying_out`, e poi non cambiano più.
`pool_amount_sats` è la somma di `bet_amount_sats` dei soli partecipanti
`confirmed`, gli stessi fra cui si estrae il vincitore.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
- **[DAT-22]** `winner_amount_sats`, `fee_amount_sats` e `payout_txid` si
scrivono nella fase 3 di `_trigger_payout`, insieme alla riga
`pending_transactions` del pagamento e **prima** del broadcast (B-25).
`payout_txid` segue poi il txid corrente a ogni bump RBF, e torna `NULL`
quando il riconciliatore abbandona il pagamento. `winner_amount_sats` e
`fee_amount_sats` invece non vengono azzerati da un abbandono: li riscrive il
tentativo successivo. Un bump RBF non li cambia, perché riduce solo il resto
del pool.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/tx/broadcast.py:_retarget_txid_references` · `app/tx/reconcile.py:_abandon` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`,
`tests/unit/test_reconcile.py`
### `round_participants`
Una scommessa: un utente in un round.
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
| `id` | Integer PK | no | autoincrement | — | database |
| `round_id` | FK `rounds.id`, indicizzata | no | — | Il round | `app/bets/service.py:place_bet` |
| `user_id` | FK `users.id`, indicizzata | no | — | Chi scommette | `place_bet` |
| `bet_amount_sats` | BigInteger | no | — | Quanto arriva davvero al pool: costo della scommessa **meno** la commissione di rete | `place_bet` |
| `bet_txid` | String(64) | no | — | Txid corrente della scommessa | `place_bet`; `app/tx/broadcast.py:_retarget_txid_references` |
| `broadcast_at` | DateTime | no | `utcnow` | Ordine di estrazione, vedi [DAT-25] | `place_bet` |
| `confirmed_at` | DateTime | sì | `NULL` | Istante in cui la scommessa è risultata confermata | `app/bets/confirmation.py:_on_bet_confirmed` |
| `status` | String(16) | no | `"broadcast"` (mai usato: si scrive sempre esplicitamente) | Vedi [DAT-24] | vedi diagramma |
- **[DAT-23]** Al massimo una scommessa per utente per round: vincolo unico su
(`round_id`, `user_id`). `place_bet` lo verifica già prima di costruire la
transazione (`already_betting`). Il vincolo è anche la chiave con cui i
gestori di conferma, bump e riconciliazione ritrovano la riga, invece del txid
che cambia.
*Codice:* `app/db/models.py:RoundParticipant` · `app/bets/service.py:place_bet` ·
`app/bets/confirmation.py:_on_bet_confirmed` · *Test:* `tests/unit/test_bets.py`
- **[DAT-24]** `status` assume solo i valori `building`, `broadcast`,
`confirmed`. Non esiste uno stato di fallimento: una scommessa che non va a
buon fine viene **cancellata**, e l'utente non risulta nel round.
```mermaid
stateDiagram-v2
[*] --> building: place_bet fase 1
building --> broadcast: place_bet fase 2 / _promote
building --> confirmed: _on_bet_confirmed
broadcast --> confirmed: _on_bet_confirmed
building --> [*]: cancellata (_release_failed_bet / _abandon)
broadcast --> [*]: cancellata (_abandon)
confirmed --> [*]
```
| Da | A | Funzione |
|---|---|---|
| — | `building` | `app/bets/service.py:place_bet` (fase 1, prima del broadcast) |
| `building` | `broadcast` | `place_bet` (fase 2, broadcast riuscito); `app/tx/reconcile.py:_promote` |
| `building`, `broadcast` | `confirmed` | `app/bets/confirmation.py:_on_bet_confirmed` |
| `building` | cancellata | `app/bets/service.py:_release_failed_bet` (broadcast rifiutato) |
| `building`, `broadcast` | cancellata | `app/tx/reconcile.py:_abandon` |
Se il compare-and-set di [DAT-16] fallisce, la riga `building` non arriva
nemmeno al commit.
*Codice:* le funzioni in tabella ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_confirmation.py`,
`tests/unit/test_reconcile.py`
- **[DAT-25]** `broadcast_at` è l'istante della fase 1 di `place_bet`, appena
prima del broadcast, e non viene mai riscritto (nemmeno da un bump RBF).
L'estrazione ordina i partecipanti confermati per `broadcast_at` crescente.
*Codice:* `app/bets/service.py:place_bet` ·
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
- **[DAT-26]** `bet_amount_sats` è l'importo del primo output della scommessa
(`recipient_sats`), cioè `round_config.bet_amount_sats` in vigore al momento
della scommessa meno la commissione di rete. Non cambia più: un bump RBF
riduce il resto dell'utente, non l'output verso il pool. Per questo il
montepremi si calcola sommando questa colonna, non moltiplicando il numero di
partecipanti per il costo attuale (B-11).
*Codice:* `app/bets/service.py:place_bet` ·
`app/wallet/psbt_builder.py:build_signed_transaction` ·
`app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_bets.py`
- **[DAT-27]** Il numero di righe di un round, contando tutti gli stati, non
supera `MAX_PARTICIPANTS_PER_ROUND` (400): la 401ª scommessa è rifiutata con
`round_full` prima che si muova denaro (B-52).
*Codice:* `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py` ·
*Test:* `tests/unit/test_bets.py`
### `round_config`
I parametri operativi, in **una sola riga**, modificabili a caldo da `/admin`
senza riavviare (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
| Colonna | Tipo | Default | Limiti accettati da `PUT /admin/config` | Significato | Quando ha effetto |
|---|---|---|---|---|---|
| `id` | Integer PK | autoincrement | — | — | — |
| `fee_address` | String(128), non null | nessun default nel modello; la riga nasce con `""` | indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Subito: si legge a ogni costruzione del pagamento, quindi vale anche per il round in corso se il suo pagamento non è ancora stato costruito |
| `bet_amount_sats` | BigInteger | `1_000_000_000` (10 PLM) | `> 0`, `≤ 100 000 PLM` | Costo della scommessa; anche il prelievo minimo | Subito, per le scommesse e i prelievi successivi; le scommesse già piazzate tengono il proprio importo ([DAT-26]) |
| `round_duration_seconds` | Integer | `600` | 30 s – 7 giorni | Durata del round | Dal round **successivo** ([DAT-18]) |
| `round_cooldown_seconds` | Integer | `30` | 0 s – 24 h | Pausa dopo la chiusura | Dal round **successivo**: la pausa dopo il round in corso è quella copiata alla sua apertura ([DAT-18]) |
| `draw_animation_seconds` | Integer | `20` | 0 – 600 s | Durata minima dell'animazione dell'estrazione nel frontend; non condiziona l'estrazione vera | Subito (letto da `GET /rounds/current`) |
| `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (10 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` |
| `rbf_timeout_seconds` | Integer | `900` | 60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) |
| `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa di manutenzione | Subito per l'apertura del round successivo; il round in corso non è toccato |
| `updated_at` | DateTime | `utcnow`, aggiornato a ogni modifica (`onupdate`) | — | Ultima modifica della riga | ORM |
- **[DAT-28]** La tabella contiene al più una riga, creata alla prima lettura
con i default di colonna di `RoundConfig` e `fee_address = ""`. Tutti leggono
la configurazione attraverso `get_round_config`, che prende la prima riga
trovata. Il database non impone l'unicità della riga: la garanzia è che solo
`get_round_config` ne inserisce una. I default di un'istanza nuova sono quelli
del modello, non variabili d'ambiente.
*Codice:* `app/rounds/config.py:get_round_config` · `app/db/models.py:RoundConfig` ·
*Test:* `tests/unit/test_admin.py`
- **[DAT-29]** Le colonne modificabili da `PUT /admin/config` sono esattamente
`fee_address`, `bet_amount_sats`, `round_duration_seconds`,
`round_cooldown_seconds`, `fee_rate_sat_vb`, `rbf_timeout_seconds`,
`draw_animation_seconds`, entro i limiti della tabella sopra. Ogni modifica
effettiva scrive una voce `config_updated` con il valore prima e dopo, e un
valore uguale a quello attuale non conta come modifica.
*Codice:* `app/api/routes/admin.py:update_config` ·
`app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py`
- **[DAT-30]** `paused` si modifica solo con `POST /admin/pause` e
`POST /admin/resume`, che scrivono `lottery_paused` e `lottery_resumed` nel
registro (B-10).
*Codice:* `app/api/routes/admin.py:pause_lottery` ·
`app/api/routes/admin.py:resume_lottery` · *Test:* `tests/unit/test_admin.py`
- **[DAT-31]** Con `fee_address` vuoto (o di soli spazi) non si apre nessun round
(B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la
validazione rifiuta la stringa vuota come indirizzo non valido.
*Codice:* `app/rounds/service.py:rounds_can_open` ·
`app/api/routes/admin.py:RoundConfigUpdate` ·
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_admin.py`
### `pending_transactions`
Ogni transazione in uscita dalla piattaforma: scommesse, prelievi e pagamenti.
È la riga su cui lavorano il poller delle conferme, il bumper RBF e il
riconciliatore (vedi
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md)).
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
| `id` | Integer PK | no | autoincrement | — | database |
| `kind` | String(16) | no | — | `bet`, `withdrawal` o `payout` | chi crea la riga |
| `round_id` | FK `rounds.id` | sì | `NULL` | Il round (`bet`, `payout`) | `place_bet`; `_trigger_payout` |
| `withdrawal_id` | FK `withdrawals.id` | sì | `NULL` | Il prelievo (`withdrawal`) | `request_withdrawal` |
| `user_id` | FK `users.id` | sì | `NULL` | Il mittente (`bet`, `withdrawal`); `NULL` per `payout` | `place_bet`; `request_withdrawal` |
| `current_txid` | String(64) | no | — | Txid della versione più recente della transazione | creazione; `bump_fee` |
| `fee_rate_sat_vb` | Integer | no | — | Commissione della versione corrente, in sat/vB | creazione; `bump_fee` |
| `raw_tx_hex` | Text | no | — | Transazione firmata della versione corrente | creazione; `bump_fee` |
| `broadcast_at` | DateTime | no | `utcnow` | Prima trasmissione, vedi [DAT-35] | ORM, alla creazione |
| `last_broadcast_at` | DateTime | no | `utcnow` | Ultima trasmissione, vedi [DAT-35] | ORM; `bump_fee` |
| `status` | String(16) | no | `"pending"` (mai usato: si crea sempre `building`) | Vedi [DAT-33] | vedi diagramma |
| `replaced_by_txid` | String(64) | sì | `NULL` | Il txid **sostituito** dall'ultimo bump (nonostante il nome, punta all'indietro) | `bump_fee` |
| `attempt_count` | Integer | no | `1` | 1 + numero di bump riusciti | `bump_fee` |
| `failure_reason` | String(128) | sì | `NULL` | Perché la transazione è fallita; solo con `status = failed` | `_abandon`; `_release_failed_withdrawal` |
| `updated_at` | DateTime | no | `utcnow`, `onupdate` | Ultima modifica | ORM |
Le funzioni citate sono `app/bets/service.py:place_bet`,
`app/withdrawals/service.py:request_withdrawal`,
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
`app/tx/broadcast.py:bump_fee`, `app/tx/reconcile.py:_abandon` e
`app/withdrawals/service.py:_release_failed_withdrawal`.
- **[DAT-32]** Le colonne di collegamento dipendono da `kind`:
| `kind` | `round_id` | `user_id` | `withdrawal_id` | Firmata con | Riga di dominio collegata |
|---|---|---|---|---|---|
| `bet` | il round | lo scommettitore | `NULL` | chiave dell'utente | `round_participants` (`round_id`, `user_id`) |
| `withdrawal` | `NULL` | chi preleva | il prelievo | chiave dell'utente | `withdrawals` (`withdrawal_id`) |
| `payout` | il round | `NULL` | `NULL` | chiave del pool | `rounds` (`round_id`) |
I gestori delle conferme e del riconciliatore ritrovano la riga di dominio da
questi identificativi, che non cambiano, e non dal txid, che cambia a ogni
bump (B-02). L'indirizzo di cui controllare la cronologia si ricava da
`kind` e `user_id`.
*Codice:* `app/tx/pending_address.py:own_address_for` ·
`app/tx/broadcast.py:_signing_context` ·
`app/bets/confirmation.py:_on_bet_confirmed` ·
`app/withdrawals/confirmation.py:_on_withdrawal_confirmed` ·
`app/rounds/confirmation.py:_on_payout_confirmed` ·
*Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_pending_address.py`
- **[DAT-33]** `status` assume solo i valori `building`, `pending`, `confirmed`,
`failed`; gli ultimi due sono terminali.
```mermaid
stateDiagram-v2
[*] --> building: creazione, prima del broadcast
building --> pending: broadcast riuscito / _promote
building --> failed: _release_failed_withdrawal / _abandon
building --> [*]: cancellata (_release_failed_bet)
pending --> confirmed: poll_once
pending --> failed: _abandon
confirmed --> [*]
failed --> [*]
```
| Da | A | Funzione |
|---|---|---|
| — | `building` | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` |
| `building` | `pending` | le stesse tre, dopo il broadcast riuscito; `app/tx/reconcile.py:_promote` |
| `pending` | `confirmed` | `app/tx/confirmation.py:poll_once` (poi il gestore del `kind`) |
| `building`, `pending` | `failed` | `app/tx/reconcile.py:_abandon` |
| `building` | `failed` | `app/withdrawals/service.py:_release_failed_withdrawal` |
| `building` | cancellata | `app/bets/service.py:_release_failed_bet` |
Un pagamento il cui broadcast fallisce resta `building` finché il
riconciliatore non lo risolve. Il poller delle conferme guarda solo le righe
`pending`.
*Codice:* le funzioni in tabella ·
*Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_reconcile.py`,
`tests/unit/test_scheduler.py`, `tests/unit/test_bets.py`,
`tests/unit/test_withdrawals.py`
- **[DAT-34]** Dopo la creazione, `current_txid`, `raw_tx_hex`,
`fee_rate_sat_vb`, `replaced_by_txid`, `attempt_count` e `last_broadcast_at`
cambiano solo in `bump_fee`, tutti nella stessa transazione del database, solo
dopo che il nuovo broadcast è riuscito e solo per una riga ancora `pending`.
`fee_rate_sat_vb` registra la commissione effettivamente pagata dalla nuova
versione, non quella obiettivo. `replaced_by_txid` conserva solo il txid
immediatamente precedente: la catena completa dei rimpiazzi non è memorizzata.
*Codice:* `app/tx/broadcast.py:bump_fee` · *Test:* `tests/unit/test_broadcast.py`
- **[DAT-35]** `broadcast_at` è la **prima** trasmissione e non viene mai
riscritta: da lì il riconciliatore misura dopo quanto abbandonare (120 s per
`building`, 6 h per `pending`). `last_broadcast_at` è l'ultima trasmissione:
la aggiorna `bump_fee` e da lì `should_bump` misura se serve un altro bump.
Tenerle separate impedisce che bump ripetuti rinviino all'infinito
l'abbandono (B-27).
*Codice:* `app/tx/reconcile.py:_is_due` · `app/tx/broadcast.py:should_bump` ·
`app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_reconcile.py`
- **[DAT-36]** `failure_reason` si scrive solo quando la riga passa a `failed`,
troncato a 128 caratteri: `"not found on chain"` dal riconciliatore, il
messaggio d'errore della rete per un prelievo rifiutato.
*Codice:* `app/tx/reconcile.py:_abandon` ·
`app/withdrawals/service.py:_release_failed_withdrawal` ·
*Test:* `tests/unit/test_reconcile.py`
- **[DAT-37]** Per le righe `bet` e `withdrawal` in `building`/`pending`,
`raw_tx_hex` è la fonte del saldo "in attesa" (gli output che tornano
all'indirizzo dell'utente). Per le righe `payout` in `building`/`pending`
definisce gli outpoint del pool già impegnati ([DAT-11]). Per ogni riga
abbandonata definisce gli input da liberare ([DAT-13]).
*Codice:* `app/wallet/balance.py:compute_pending_balance` ·
`app/rounds/scheduler.py:_reserved_payout_outpoints` ·
`app/tx/reconcile.py:_release_inputs` ·
*Test:* `tests/unit/test_balance.py`, `tests/unit/test_scheduler.py`,
`tests/unit/test_reconcile.py`
- **[DAT-38]** Le righe `pending_transactions` non si cancellano, con una sola
eccezione: la riga `bet` il cui broadcast è stato rifiutato, cancellata insieme
al partecipante da `_release_failed_bet`. Un prelievo rifiutato invece lascia
la riga, in `failed`.
*Codice:* `app/bets/service.py:_release_failed_bet` ·
`app/withdrawals/service.py:_release_failed_withdrawal` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`
### `withdrawals`
Una richiesta di prelievo verso un indirizzo esterno. A differenza di una
scommessa, resta nella tabella anche quando fallisce: è un'istruzione data
dall'utente.
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
| `id` | Integer PK | no | autoincrement | — | database |
| `user_id` | FK `users.id`, indicizzata | no | — | Chi preleva | `app/withdrawals/service.py:request_withdrawal` |
| `external_address` | String(128) | no | — | Destinatario: un indirizzo PLM valido, diverso da quello dell'utente | `request_withdrawal` |
| `amount_requested_sats` | BigInteger | no | — | Importo chiesto dall'utente | `request_withdrawal` |
| `amount_sent_sats` | BigInteger | sì (sempre valorizzata dal codice) | `NULL` | Quanto riceve il destinatario, vedi [DAT-40] | `request_withdrawal` |
| `txid` | String(64) | sì | `NULL` | Txid corrente; `NULL` dopo un fallimento | `request_withdrawal`; `_retarget_txid_references`; `_release_failed_withdrawal`; `_abandon` |
| `status` | String(16) | no | `"pending"` (mai usato: si crea sempre `building`) | Vedi [DAT-39] | vedi diagramma |
| `created_at` | DateTime | no | `utcnow` | Istante della richiesta | ORM |
| `confirmed_at` | DateTime | sì | `NULL` | Istante in cui il prelievo è risultato confermato | `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` |
- **[DAT-39]** `status` assume solo i valori `building`, `broadcast`,
`confirmed`, `failed`; gli ultimi due sono terminali. Il passaggio a `failed`
azzera `txid`.
```mermaid
stateDiagram-v2
[*] --> building: request_withdrawal
building --> broadcast: broadcast riuscito / _promote
building --> confirmed: _on_withdrawal_confirmed
broadcast --> confirmed: _on_withdrawal_confirmed
building --> failed: _release_failed_withdrawal / _abandon
broadcast --> failed: _abandon
confirmed --> [*]
failed --> [*]
```
| Da | A | Funzione |
|---|---|---|
| — | `building` | `app/withdrawals/service.py:request_withdrawal` |
| `building` | `broadcast` | `request_withdrawal` (broadcast riuscito); `app/tx/reconcile.py:_promote` |
| `building`, `broadcast` | `confirmed` | `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` |
| `building` | `failed` | `app/withdrawals/service.py:_release_failed_withdrawal` |
| `building`, `broadcast` | `failed` | `app/tx/reconcile.py:_abandon` |
*Codice:* le funzioni in tabella ·
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py`
- **[DAT-40]** `amount_requested_sats` è ciò che l'utente ha chiesto;
`amount_sent_sats` è l'output effettivo verso il destinatario: l'importo
chiesto meno la commissione di rete e, per un prelievo dell'intero saldo, meno
quanto serve a lasciare un resto di `DUST_LIMIT_SATS` (B-62). Le due colonne si
scrivono insieme alla creazione e non cambiano più: un bump RBF riduce il
resto, non l'output del destinatario.
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
`app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_withdrawals.py`
### `bug_reports`
Una segnalazione inviata da `/report-bug`, anche senza login.
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
| `id` | Integer PK | no | autoincrement | — | database |
| `description` | Text | no | — | Testo della segnalazione, già ripulito dagli spazi, 1–2000 caratteri (limite dell'API) | `app/api/routes/bug_reports.py:create_bug_report` |
| `contact` | String(256) | sì | `NULL` | Recapito facoltativo; una stringa vuota diventa `NULL` | `create_bug_report` |
| `user_id` | FK `users.id` | sì | `NULL` | Chi ha segnalato, se aveva fatto il login | `create_bug_report` |
| `status` | String(16) | no | `"open"` | `open`, `read` o `resolved` | `create_bug_report` (default); `app/api/routes/admin.py:update_bug_report_status` |
| `created_at` | DateTime | no | `utcnow` | Istante della segnalazione | ORM |
- **[DAT-41]** `status` assume solo i valori `open`, `read`, `resolved`. Nasce
`open`. Poi lo cambia solo l'amministratore, verso **uno qualunque** dei tre
valori (il percorso previsto è `open → read → resolved`, ma anche i ritorni
indietro sono ammessi). Ogni cambio effettivo scrive
`bug_report_status_changed` con il valore prima e dopo, e riassegnare lo
stesso valore non scrive nulla (B-60). `read` esiste per permettere a chi ha
segnalato di distinguere "visto" da "risolto".
*Codice:* `app/api/routes/admin.py:update_bug_report_status` ·
`app/api/routes/bug_reports.py:create_bug_report` ·
*Test:* `tests/unit/test_bug_reports.py`
### `audit_log`
Il registro di audit: chi ha fatto cosa, e quando. Il catalogo dei tipi di
evento e dei loro payload è in [audit-log.md](audit-log.md).
| Colonna | Tipo | Null | Default | Significato |
|---|---|---|---|---|
| `id` | Integer PK | no | autoincrement | Ordine di scrittura |
| `event_type` | String(32) | no | — | Tipo di evento (es. `bet_placed`, `payout_failed`) |
| `payload_json` | Text | no | — | Dettagli dell'evento, oggetto JSON serializzato |
| `user_id` | FK `users.id` | sì | `NULL` | Utente interessato, se c'è |
| `round_id` | FK `rounds.id` | sì | `NULL` | Round interessato, se c'è |
| `created_at` | DateTime | no | `utcnow` | Istante di scrittura |
- **[DAT-42]** Il registro è in sola aggiunta: le righe si inseriscono solo con
`write_audit_log`, che aggiunge la riga alla sessione del chiamante senza
fare commit, così la voce entra nella stessa transazione del cambiamento che
descrive. Nessun codice aggiorna o cancella righe di `audit_log`.
`payload_json` è `Text` (B-47).
*Codice:* `app/audit/log.py:write_audit_log` ·
*Test:* `tests/unit/test_models_text_columns.py`, `tests/unit/test_admin.py`
- **[DAT-43]** Il registro non è solo informativo. Il `created_at` dell'ultima
voce `payout_failed` di un round fa da orologio per il riprovare del
pagamento: un nuovo tentativo parte solo 60 s dopo (B-26). Per questo ogni
uscita anticipata di `_trigger_payout` scrive una voce `payout_failed`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` ·
`app/rounds/scheduler.py:RoundScheduler._log_payout_failure` ·
*Test:* `tests/unit/test_scheduler.py`
### `alembic_version`
Tabella di servizio di Alembic: una riga con la revisione applicata
(`version_num`). Non è nel modello e il codice applicativo non la legge.
## Indici e vincoli
| Nome | Tabella | Definizione | Perché |
|---|---|---|---|
| PK `id` | tutte | intero autoincrementale | [DAT-05] |
| `ix_users_username` | `users` | `UNIQUE (username)` | unicità esatta, dallo schema iniziale; ridondante rispetto alla riga sotto |
| `ix_users_username_lower` | `users` | `UNIQUE (lower(username))` | username senza distinzione di maiuscole, [DAT-06] (B-57) |
| unico `derivation_index` | `users` | `UNIQUE` | un indice HD, quindi un indirizzo, per utente, [DAT-07] |
| unico `address` | `users` | `UNIQUE` | un indirizzo non appartiene a due utenti |
| unico (`txid`, `vout`) | `utxo_events` | `UNIQUE` | un outpoint si accredita una volta sola, [DAT-10] |
| `ix_utxo_events_user_id` | `utxo_events` | indice su `user_id` | calcolo del saldo e selezione degli UTXO per utente |
| `ix_rounds_single_active` | `rounds` | `UNIQUE ((1)) WHERE status IN ('open','closing','drawing','paying_out')` | al massimo un round attivo, [DAT-17] (B-09) |
| unico (`round_id`, `user_id`) | `round_participants` | `UNIQUE` | una scommessa per utente per round, [DAT-23] |
| `ix_round_participants_round_id` | `round_participants` | indice | partecipanti di un round |
| `ix_round_participants_user_id` | `round_participants` | indice | round di un utente (`last-round-result`) |
| `ix_withdrawals_user_id` | `withdrawals` | indice | prelievi di un utente |
Non ci sono indici su `pending_transactions`, `audit_log` o `bug_reports` oltre
alla chiave primaria. In particolare `pending_transactions.status` e
`audit_log.event_type`/`round_id`, usati dai filtri dei task in background, sono
letti con una scansione completa.
## Invarianti tra tabelle
- **[DAT-44]** **Prima si scrive, poi si trasmette.** Ogni transazione in
uscita esiste nel database prima di essere trasmessa: la riga
`pending_transactions` in `building`, la riga di dominio (partecipante in
`building`, prelievo in `building`, oppure `payout_txid` sul round) e, per
scommesse e prelievi, gli input già marcati con `spent_txid`, tutto in un
commit che precede il broadcast (B-08, B-25). Nessuna sessione del database
resta aperta durante una chiamata di rete.
*Codice:* `app/bets/service.py:place_bet` ·
`app/withdrawals/service.py:request_withdrawal` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_scheduler.py`
- **[DAT-45]** **Un solo txid valido.** Per ogni transazione non terminale,
`pending_transactions.current_txid` coincide con il txid registrato sulla
riga di dominio (`round_participants.bet_txid`, `withdrawals.txid`,
`rounds.payout_txid`) e con lo `spent_txid` di ogni input impegnato
([DAT-14]). `bump_fee` li aggiorna tutti nella stessa transazione del
database. Per un pagamento, `payout_txid` si aggiorna solo se era ancora
uguale al vecchio txid.
*Codice:* `app/tx/broadcast.py:_retarget_txid_references` ·
*Test:* `tests/unit/test_broadcast.py`
- **[DAT-46]** **Il montepremi si conserva.** Per un round pagato:
`pool_amount_sats` = somma dei `bet_amount_sats` dei partecipanti
`confirmed`; `fee_amount_sats` = `pool_amount_sats` − 70% (arrotondato per
difetto) di `pool_amount_sats`; `winner_amount_sats` = quel 70% meno la
commissione di rete del pagamento. La commissione di rete esce quindi dalla
quota del vincitore, mai da quella della piattaforma. La ripartizione è nel
codice, non in `round_config`.
*Codice:* `app/rounds/service.py:winner_share` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py`
- **[DAT-47]** **Un round non si chiude con scommesse in volo.** Finché un round
ha partecipanti in `building` o `broadcast` resta in `closing`. Il conteggio
si ripete nella stessa sessione che legge i partecipanti da estrarre (B-53), e
l'attesa finisce perché ogni riga in volo viene prima o poi confermata o
cancellata dal riconciliatore.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
- **[DAT-48]** **Un solo pagamento per round.** Non si costruisce un pagamento
per un round che ha già una riga `payout` in `building` o `pending`, e gli
outpoint del pool impegnati da qualunque pagamento non risolto sono esclusi
dalla selezione ([DAT-11]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/rounds/scheduler.py:_reserved_payout_outpoints` ·
*Test:* `tests/unit/test_scheduler.py`
- **[DAT-49]** **Le transazioni ritrovano la loro riga senza il txid.** Le righe
collegate a una `pending_transactions` si ritrovano tramite `round_id`,
`user_id` e `withdrawal_id` ([DAT-32]); il txid serve solo come ripiego per
righe senza collegamento.
*Codice:* `app/bets/confirmation.py:_on_bet_confirmed` ·
`app/rounds/confirmation.py:_on_payout_confirmed` ·
*Test:* `tests/unit/test_confirmation.py`
## Motore e sessioni
- **[DAT-50]** C'è un solo motore asincrono per processo, creato
all'importazione di `app/db/base.py` da `settings.database_url`
(`DATABASE_URL`; predefinito `sqlite+aiosqlite:///./plm_lottery.db`, e nel
container `sqlite+aiosqlite:////app/db_data/plm_lottery.db`, montato da
`./data/db`). Le sessioni si creano con `AsyncSessionLocal`
(`async_sessionmaker` con `expire_on_commit=False`): gli oggetti letti restano
utilizzabili dopo il commit.
*Codice:* `app/db/base.py` · `app/config.py`
- **[DAT-51]** Gli endpoint HTTP ricevono una sessione per richiesta dalla
dipendenza `get_session`. I task in background ricevono `AsyncSessionLocal`
come factory e aprono sessioni brevi, chiuse prima di ogni chiamata di rete.
Il commit è sempre esplicito: `get_session` non ne fa alla fine della
richiesta.
*Codice:* `app/db/session.py:get_session` · `app/main.py` ·
*Test:* `tests/unit/test_broadcast.py`
- **[DAT-52]** Su SQLite ogni nuova connessione esegue
`PRAGMA journal_mode=WAL`, `PRAGMA synchronous=NORMAL` e
`PRAGMA busy_timeout=5000` (B-39): lettori e scrittori non si bloccano a
vicenda, e un secondo scrittore aspetta fino a 5 s invece di fallire subito.
Con un altro dialetto non viene eseguito nulla. `PRAGMA foreign_keys` non è
tra questi, da cui [DAT-04].
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
*Test:* `tests/unit/test_db_base.py`
## Migrazioni
### Politica
- **[DAT-53]** Lo schema si cambia solo con una revisione Alembic in
`migrations/versions/`. Si modifica `app/db/models.py`, poi si genera la
revisione con `alembic revision --autogenerate -m "..."` e la si **rilegge e
corregge a mano** prima del commit. La catena è lineare, senza rami.
`migrations/env.py` prende l'URL da `settings.database_url` e i metadati da
`Base.metadata`, e non abilita `render_as_batch`: le modifiche a colonne
esistenti su SQLite vanno scritte a mano dentro `op.batch_alter_table`.
*Codice:* `migrations/env.py` · `app/db/models.py`
- **[DAT-54]** Le migrazioni si applicano **solo** all'avvio del container: il
comando del `Dockerfile` controlla che esista la master key, esegue
`alembic upgrade head` e solo se riesce avvia `uvicorn`. Una migrazione che
fallisce impedisce quindi al server di partire invece di lasciarlo girare su
uno schema a metà. `alembic upgrade head` non va mai lanciato a mano.
*Codice:* `Dockerfile`
- **[DAT-55]** Una colonna `NOT NULL` aggiunta a una tabella che può avere già
righe deve dare un valore a quelle righe nella migrazione stessa, in uno di
due modi: un `server_default` temporaneo, rimosso subito dopo in modo che le
righe nuove prendano il default dell'ORM (`round_config`, `users.token_version`);
oppure un backfill esplicito calcolato dai dati (`last_broadcast_at` copiato da
`broadcast_at`; `rounds.duration_seconds`/`cooldown_seconds` copiati da
`round_config`, così il round in corso non cambia durata con la migrazione).
*Codice:* `migrations/versions/53cc70d16e63_add_operational_params_to_round_config.py` ·
`migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py` ·
`migrations/versions/283844a44b4a_snapshot_round_timing_onto_the_round_.py`
- **[DAT-56]** Gli indici su espressioni (`ix_rounds_single_active`,
`ix_users_username_lower`) vanno scritti a mano nella migrazione:
l'autogenerate su SQLite non li sa confrontare e li ignora con un avviso.
Inoltre `op.batch_alter_table` su SQLite ricrea la tabella e **perde senza
errori** gli indici su espressioni, perché SQLAlchemy non li legge dallo
schema. Una migrazione futura che usi `batch_alter_table` su `rounds` o
`users` deve quindi ricreare esplicitamente l'indice relativo. Oggi nessuna
migrazione lo fa dopo la creazione di quegli indici.
*Codice:* `migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py` ·
`migrations/versions/c1d4a97b5e10_case_insensitive_usernames.py`
- **[DAT-57]** Una migrazione che incontra dati incompatibili con un nuovo
vincolo si comporta in uno di due modi. Se esiste una correzione **senza
ambiguità**, la applica e procede. Se correggere significherebbe decidere
sulla proprietà dei fondi, **si rifiuta**, e il server non parte ([DAT-54]).
Le due migrazioni di questo tipo sono [DAT-58] e [DAT-59].
- **[DAT-58]** `c1d4a97b5e10` (username senza distinzione di maiuscole, B-57)
**si rifiuta di procedere** se due account esistenti hanno username che
differiscono solo per maiuscole/minuscole: solleva un errore che elenca i
gruppi in collisione e non crea l'indice. Non sceglie quale account
rinominare, perché entrambi possono contenere fondi: l'operatore rinomina a
mano un account (e avvisa l'utente), poi riavvia (vedi
[operativita.md](operativita.md)).
*Codice:* `migrations/versions/c1d4a97b5e10_case_insensitive_usernames.py`
- **[DAT-59]** `8a1c4e7b2d90` (indice del round attivo unico, B-09), se trova più
round attivi, porta a `closed` tutti tranne il più recente prima di creare
l'indice. È la stessa scelta che `get_active_round` faceva già in silenzio.
*Codice:* `migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py`
Nessun test esegue le migrazioni. I test unitari creano lo schema direttamente
dal modello (`Base.metadata.create_all`), quindi verificano il modello, non la
catena di revisioni.
### Storia dello schema
In ordine di applicazione (ogni riga rivede la precedente):
| Revisione | Contenuto |
|---|---|
| `274efdcbfbcc` | Schema iniziale: `round_config` (solo `fee_address`, `bet_amount_sats`, `updated_at`), `users`, `rounds`, `utxo_events`, `withdrawals`, `audit_log`, `pending_transactions`, `round_participants` |
| `53cc70d16e63` | `round_config`: `round_duration_seconds`, `round_cooldown_seconds`, `min_amount_sats`, `fee_rate_sat_vb`, `rbf_timeout_seconds` (valori prima presi da `app/config.py`) |
| `1db52f3a7c67` | `round_config.draw_animation_seconds` |
| `5f2079b95b33` | `round_config.paused` |
| `6cb50b29f64c` | Rimosso `round_config.min_amount_sats`: il prelievo minimo è il costo della scommessa |
| `8a1c4e7b2d90` | `pending_transactions.failure_reason`; indice `ix_rounds_single_active` (B-04, B-09) |
| `861e76aaf34c` | `pending_transactions.last_broadcast_at`, con backfill da `broadcast_at` (B-27) |
| `943dbd74d983` | `users.token_version` (B-34) |
| `9ef6a51509f7` | `rounds.drawing_started_at` (B-36) |
| `87a0c640355c` | `audit_log.payload_json` e `pending_transactions.raw_tx_hex` da `VARCHAR` a `Text` (B-47) |
| `ee8508d98d34` | Tabella `bug_reports`, con un booleano `resolved` |
| `be71fdac734e` | `bug_reports.resolved` sostituito da `status` a tre valori (`resolved` → `'resolved'`, il resto → `'open'`) |
| `c1d4a97b5e10` | Indice `ix_users_username_lower` (B-57), [DAT-58] |
| `283844a44b4a` | `rounds.duration_seconds`/`cooldown_seconds` (B-61), backfill dalla configurazione attuale — **head** |
### Differenze note tra modello e schema migrato
- `users.token_version`: il modello dichiara `server_default="0"`, ma la
migrazione `943dbd74d983` rimuove il default del server dopo il backfill. Un
database migrato non ha quindi un default a livello SQL. Non ha effetti
pratici, perché ogni inserimento passa dall'ORM che fornisce `0`, e
l'autogenerate non confronta i default del server, quindi non lo segnala.
- `rounds.duration_seconds`/`cooldown_seconds`: `server_default` presente sia
nel modello sia nel database migrato.
## Scelte specifiche di SQLite
- **Un solo file, un solo processo.** SQLite con WAL regge i sei task in
background e le richieste HTTP di un singolo processo uvicorn ([DAT-52]). I
lock per utente e il canale SSE sono comunque in memoria, quindi più
processi non sono supportati in nessun caso (vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
- **Serializzazione degli scrittori.** Il compare-and-set di [DAT-16] si basa sul
lock di scrittura di SQLite: un conflitto tra snapshot arriva come
`OperationalError` e viene trattato come "round non più aperto".
- **Indice parziale su espressione.** `ix_rounds_single_active` usa sia un'espressione
(`(1)`) sia una clausola `WHERE`; SQLite e PostgreSQL supportano entrambe, e il
modello dichiara la clausola per tutti e due (`sqlite_where`,
`postgresql_where`). La migrazione la crea invece con SQL grezzo
(`CREATE UNIQUE INDEX ... ON rounds ((1)) WHERE ...`).
- **Timestamp senza fuso.** SQLite non conserva il fuso orario ([DAT-02]).
- **Chiavi esterne non applicate** ([DAT-04]).
- **`Text` invece di `VARCHAR` senza lunghezza** per i contenuti di lunghezza
arbitraria (`raw_tx_hex`, `payload_json`, `description`), così lo schema resta
portabile ad altri database (B-47).
- **Portabilità.** I pragma sono limitati al dialetto `sqlite` e l'indice del
round attivo è dichiarato anche per PostgreSQL. Un `DATABASE_URL` PostgreSQL
non è però mai stato provato, e il database supportato resta SQLite.