1088 lines
61 KiB
Markdown
1088 lines
61 KiB
Markdown
# 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.
|