Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto the round when it opened. The cost of a bet and the fee address were read live, so an edit from /admin in the middle of a round gave the players of that round different stakes for the same chance and redirected the commission of the round in progress. - `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column, existing rounds backfilled with the current configuration, i.e. what they were using; the expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the round's fee address (an empty one on the row still stops the payout, as before), `GET /rounds/current` advertises the round's cost while one is active and the configured one otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10). - `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included, has reached the round's cap, before anything moves; the pool can exceed it by one bet at most. A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`. - PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the bet cost the round in progress opened with, which can be lower than the configured one. - `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the seven translations are generic. Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and "applies immediately, also to the round in progress" in the round_config table of modello-dati; the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line). Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and the old "fee address missing" payout test now empties the round's address (clearing it in the configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open, edits reaching only the next round, every bet of a round paying the same stake whether the cost is raised or lowered meanwhile, the payout following the round's address (and surviving it being cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap, a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route, the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the migration backfill. Mutation-checked: charging the live cost, paying the live address, not copying the cap and `>` instead of `>=` each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1500 lines
93 KiB
Markdown
1500 lines
93 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) ·
|
||
[`pending_transaction_txids`](#pending_transaction_txids) ·
|
||
[`withdrawals`](#withdrawals) · [`bug_reports`](#bug_reports) ·
|
||
[`audit_log`](#audit_log) · [`alembic_version`](#alembic_version)
|
||
- [Indici e vincoli](#indici-e-vincoli)
|
||
- [Invarianti tra tabelle](#invarianti-tra-tabelle)
|
||
- [Motore e sessioni](#motore-e-sessioni)
|
||
- [Migrazioni](#migrazioni)
|
||
- [Scelte specifiche di SQLite](#scelte-specifiche-di-sqlite)
|
||
|
||
## Diagramma delle relazioni
|
||
|
||
Dieci tabelle applicative. `round_config` non ha relazioni: è una
|
||
riga singola di parametri. Gli indirizzi della piattaforma che non
|
||
appartengono a un utente sono derivati dalla master key, e i loro UTXO si
|
||
leggono dalla rete, non da una tabella:
|
||
|
||
| Indirizzo | Percorso | Dove è registrato |
|
||
|---|---|---|
|
||
| Utente | `m/84'/746'/0'/0/<derivation_index>` | `users.address` |
|
||
| Riserva del pool (il "pool" unico di oggi) | `m/84'/746'/0'/1/0` | nessuna tabella; `app/wallet/hd.py:derive_pool_key` |
|
||
| Montepremi di un round | `m/84'/746'/0'/2/<round_id>` | `rounds.pool_address` ([DAT-60]) |
|
||
|
||
Il diagramma descrive lo schema **atteso**; le colonne e la tabella marcate
|
||
"da implementare" non esistono ancora (vedi le tabelle di dettaglio).
|
||
|
||
```mermaid
|
||
erDiagram
|
||
users ||--o{ utxo_events : "possiede"
|
||
users ||--o{ round_participants : "scommette"
|
||
rounds ||--o{ round_participants : "raccoglie"
|
||
users |o--o{ rounds : "vince (winner_user_id)"
|
||
users ||--o{ withdrawals : "chiede"
|
||
rounds |o--o{ pending_transactions : "bet / payout"
|
||
withdrawals |o--o{ pending_transactions : "withdrawal"
|
||
users |o--o{ pending_transactions : "bet / withdrawal"
|
||
pending_transactions ||--|{ pending_transaction_txids : "ogni versione"
|
||
users |o--o{ bug_reports : "segnala"
|
||
users |o--o{ audit_log : "riguarda"
|
||
rounds |o--o{ audit_log : "riguarda"
|
||
|
||
users {
|
||
int id PK
|
||
string username UK "unico anche su lower(username)"
|
||
string password_hash "Argon2"
|
||
int derivation_index UK
|
||
string address UK "P2WPKH plm1..."
|
||
bigint cached_balance_sats "cache di utxo_events"
|
||
int token_version
|
||
datetime created_at
|
||
}
|
||
utxo_events {
|
||
int id PK
|
||
int user_id FK
|
||
string txid "UK con vout"
|
||
int vout
|
||
bigint amount_sats
|
||
int confirmed_height
|
||
datetime confirmed_at
|
||
string spent_txid "NULL, txid o external-spend"
|
||
}
|
||
rounds {
|
||
int id PK
|
||
string status "open closing drawing paying_out closed"
|
||
datetime opened_at
|
||
datetime closed_at
|
||
int duration_seconds "snapshot"
|
||
int cooldown_seconds "snapshot"
|
||
bigint bet_amount_sats "snapshot"
|
||
string fee_address "snapshot"
|
||
bigint max_round_pool_sats "snapshot"
|
||
int draw_confirmations "snapshot"
|
||
string pool_address
|
||
string server_seed_enc "cifrato fino alla rivelazione (da implementare)"
|
||
string server_seed "in chiaro solo dopo la rivelazione (da implementare)"
|
||
string server_seed_hash "da implementare"
|
||
string participants_hash "da implementare"
|
||
int draw_height "da implementare"
|
||
datetime drawing_started_at
|
||
int draw_block_height
|
||
string draw_block_hash
|
||
string seed_int
|
||
int winner_user_id FK
|
||
bigint pool_amount_sats
|
||
bigint winner_amount_sats
|
||
bigint fee_amount_sats
|
||
bigint bump_reserve_sats
|
||
string payout_txid
|
||
datetime payout_blocked_at "da implementare"
|
||
}
|
||
round_participants {
|
||
int id PK
|
||
int round_id FK "UK con user_id"
|
||
int user_id FK
|
||
bigint bet_amount_sats
|
||
string bet_txid
|
||
int bet_vout "da implementare"
|
||
datetime broadcast_at
|
||
datetime confirmed_at
|
||
int draw_position "UK con round_id (da implementare)"
|
||
string status "building broadcast confirmed reorged_out"
|
||
}
|
||
round_config {
|
||
int id PK
|
||
string fee_address
|
||
bigint bet_amount_sats
|
||
int round_duration_seconds
|
||
int round_cooldown_seconds
|
||
int draw_animation_seconds
|
||
int fee_rate_sat_vb
|
||
int rbf_timeout_seconds
|
||
int deposit_confirmations
|
||
int draw_confirmations "da implementare"
|
||
bigint max_round_pool_sats
|
||
bool paused
|
||
string paused_reason "da implementare"
|
||
datetime updated_at
|
||
}
|
||
pending_transactions {
|
||
int id PK
|
||
string kind "bet payout withdrawal"
|
||
int round_id FK
|
||
int withdrawal_id FK
|
||
int user_id FK
|
||
string current_txid
|
||
int fee_rate_sat_vb
|
||
text raw_tx_hex
|
||
datetime broadcast_at
|
||
datetime last_broadcast_at
|
||
string status "building pending confirmed failed"
|
||
string replaced_by_txid
|
||
int attempt_count
|
||
string failure_reason
|
||
datetime failed_at
|
||
datetime updated_at
|
||
}
|
||
pending_transaction_txids {
|
||
int id PK
|
||
int pending_transaction_id FK
|
||
string txid UK
|
||
text raw_hex
|
||
datetime created_at
|
||
}
|
||
withdrawals {
|
||
int id PK
|
||
int user_id FK
|
||
string external_address
|
||
bigint amount_requested_sats
|
||
bigint amount_sent_sats
|
||
string txid
|
||
string status "building broadcast confirmed failed"
|
||
datetime created_at
|
||
datetime confirmed_at
|
||
}
|
||
bug_reports {
|
||
int id PK
|
||
text description
|
||
string contact
|
||
int user_id FK
|
||
string status "open read resolved"
|
||
datetime created_at
|
||
}
|
||
audit_log {
|
||
int id PK
|
||
string event_type
|
||
text payload_json
|
||
int user_id FK
|
||
int round_id FK
|
||
datetime created_at
|
||
}
|
||
```
|
||
|
||
## Convenzioni comuni
|
||
|
||
- **[DAT-01]** Ogni importo è un numero intero di satoshi (1 PLM =
|
||
100 000 000 sat), in una colonna `BigInteger` il cui nome finisce in `_sats`.
|
||
Nessun importo passa mai per un `float`: anche il valore degli input letti
|
||
dalla rete per un RBF è preso come intero dalla transazione grezza (B-40).
|
||
*Codice:* `app/db/models.py` · `app/tx/broadcast.py:_prevout_amount` ·
|
||
*Test:* `tests/unit/test_broadcast.py`
|
||
|
||
- **[DAT-02]** Ogni timestamp è in UTC. Si scrive con `app/db/models.py:utcnow`
|
||
(default di colonna) o con `datetime.now(timezone.utc)`; le colonne sono
|
||
`DateTime` senza fuso, e SQLite restituisce valori *naive*. Chi legge un
|
||
timestamp per confrontarlo con l'ora corrente lo marca UTC con
|
||
`.replace(tzinfo=timezone.utc)`, e ogni risposta API lo serializza con
|
||
`isoformat_utc`, che aggiunge l'offset (B-35).
|
||
*Codice:* `app/db/models.py:utcnow` · `app/api/timeutil.py:isoformat_utc` ·
|
||
`app/rounds/service.py:round_deadline` ·
|
||
*Test:* `tests/unit/test_api_timeutil.py`, `tests/unit/test_scheduler.py`
|
||
|
||
- **[DAT-03]** I campi di stato (`status`, `kind`) sono stringhe libere
|
||
(`String(16)`), senza `CHECK` né enum nel database. I valori ammessi sono solo
|
||
quelli elencati in questo documento, e a garantirlo è esclusivamente il codice
|
||
che li scrive; l'unico vincolo del database che dipende da un valore di stato
|
||
è l'indice `ix_rounds_single_active` ([DAT-17]).
|
||
*Codice:* `app/db/models.py`
|
||
|
||
- **[DAT-04]** Le chiavi esterne sono dichiarate ma, su SQLite, **non
|
||
applicate**: `PRAGMA foreign_keys` non viene attivato
|
||
([DAT-52]). Nessuna chiave esterna ha `ON DELETE`. Le sole cancellazioni
|
||
fisiche nel codice sono quelle di una scommessa fallita (la riga
|
||
`round_participants` e la sua `pending_transactions`, con le sue versioni in
|
||
`pending_transaction_txids`,
|
||
[DAT-24], [DAT-38], [DAT-70]); nessun'altra tabella perde righe, quindi i riferimenti
|
||
restano validi per costruzione, non per vincolo.
|
||
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
|
||
`app/bets/service.py:_release_failed_bet` · `app/tx/reconcile.py:_abandon`
|
||
|
||
- **[DAT-05]** Ogni chiave primaria è un intero autoincrementale `id`, assegnato
|
||
dal database al `flush`/`commit`. Nessuna tabella usa chiavi naturali come
|
||
chiave primaria; le chiavi naturali (`txid`+`vout`, `round_id`+`user_id`,
|
||
`username`) sono vincoli di unicità separati.
|
||
*Codice:* `app/db/models.py`
|
||
|
||
## Tabelle
|
||
|
||
### `users`
|
||
|
||
Un account. Nasce alla registrazione e non viene mai cancellato.
|
||
|
||
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | Identificativo; è il `sub` del JWT | database |
|
||
| `username` | String(64) | no | — | Nome scelto dall'utente, conservato **così come digitato** | `app/auth/routes.py:register` |
|
||
| `password_hash` | String(256) | no | — | Hash Argon2 della password | `register`; `app/api/routes/users.py:change_password`; `app/api/routes/admin.py:reset_user_password` |
|
||
| `derivation_index` | Integer, unico | no | — | Indice BIP84 dell'indirizzo: `m/84'/746'/0'/0/<index>` | `register` |
|
||
| `address` | String(128), unico | no | — | Indirizzo P2WPKH (`plm1…`) derivato da `derivation_index`; deposito, vincite e resto | `register` |
|
||
| `cached_balance_sats` | BigInteger | no | `0` | Saldo confermato e spendibile, cache di `utxo_events` | solo `app/wallet/balance.py:recompute_balance` |
|
||
| `token_version` | Integer | no | `0` (ORM e `server_default` nel modello) | Versione dei token di sessione; il JWT la porta nel claim `tv` | `change_password`, `reset_user_password` |
|
||
| `created_at` | DateTime | no | `utcnow` | Istante della registrazione | database/ORM |
|
||
|
||
- **[DAT-06]** Lo username è unico **senza distinzione tra maiuscole e
|
||
minuscole** (B-57): lo garantisce l'indice unico funzionale
|
||
`ix_users_username_lower` su `lower(username)`, e registrazione e login
|
||
confrontano con `func.lower(...)`. Il valore è comunque memorizzato così come
|
||
l'utente l'ha scritto, ed è quello mostrato in `/admin` e nel registro di
|
||
audit. Poiché lo username ammesso è solo ASCII (`^[A-Za-z0-9_.-]+$`, 3–32
|
||
caratteri, validato dall'API), `lower()` è l'intera normalizzazione. Resta
|
||
anche l'indice unico `ix_users_username` sulla colonna esatta, ridondante
|
||
rispetto al primo ma innocuo.
|
||
*Codice:* `app/db/models.py:User` · `app/auth/routes.py:register` ·
|
||
`app/auth/routes.py:login` · *Test:* `tests/unit/test_auth.py`
|
||
|
||
- **[DAT-07]** `derivation_index` è assegnato alla registrazione come
|
||
`max(derivation_index) + 1` (0 per il primo utente). Se il commit fallisce per
|
||
una collisione sull'indice (due registrazioni concorrenti) si ricalcola e si
|
||
riprova, fino a 5 volte; una collisione sullo username invece non si riprova e
|
||
risponde `username_taken`. `derivation_index` e `address` non vengono **mai**
|
||
modificati dopo l'inserimento: l'indirizzo è permanente.
|
||
*Codice:* `app/auth/routes.py:register` · `app/wallet/hd.py:derive_user_address` ·
|
||
*Test:* `tests/unit/test_auth.py`
|
||
|
||
- **[DAT-08]** `cached_balance_sats` è uguale, in ogni momento osservabile, alla
|
||
somma di `amount_sats` delle righe `utxo_events` dell'utente con
|
||
`spent_txid IS NULL`. La fonte di verità è `utxo_events`; la colonna è
|
||
scritta solo da `recompute_balance`, che va chiamata **nella stessa
|
||
transazione** di qualunque modifica alle righe `utxo_events` dell'utente (un
|
||
inserimento o un cambio di `spent_txid` da o verso `NULL`). I chiamanti sono:
|
||
`credit_confirmed_utxos`, `reinstate_reappeared_utxos`,
|
||
`mark_utxos_spent_externally`, `place_bet`, `_release_failed_bet`,
|
||
`request_withdrawal`, `_release_failed_withdrawal` e il `_abandon` del
|
||
riconciliatore. Il retarget di `bump_fee` ([DAT-14]) non la ricalcola perché
|
||
sostituisce un txid con un altro e non cambia la somma.
|
||
Il saldo "in attesa" mostrato all'utente non è una colonna: è calcolato al
|
||
volo (vedi [registrazione-e-depositi.md](registrazione-e-depositi.md)).
|
||
*Codice:* `app/wallet/balance.py:recompute_balance` ·
|
||
`app/deposits/service.py:credit_confirmed_utxos` ·
|
||
`app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` ·
|
||
`app/tx/reconcile.py:_abandon` ·
|
||
*Test:* `tests/unit/test_balance.py`, `tests/unit/test_deposits.py`
|
||
|
||
- **[DAT-09]** `token_version` parte da 0 e aumenta di 1 a ogni cambio password
|
||
(self-service) e a ogni reset da parte dell'amministratore; nient'altro la
|
||
modifica. Un token il cui claim `tv` non coincide con il valore corrente è
|
||
rifiutato (B-34). Un token senza claim `tv` vale come `tv = 0`.
|
||
*Codice:* `app/api/routes/users.py:change_password` ·
|
||
`app/api/routes/admin.py:reset_user_password` ·
|
||
`app/auth/dependencies.py:get_current_user` ·
|
||
`app/auth/security.py:decode_access_token` ·
|
||
*Test:* `tests/unit/test_security.py`, `tests/unit/test_admin.py`
|
||
|
||
### `utxo_events`
|
||
|
||
Un output **confermato** che paga l'indirizzo di un utente: un deposito, il
|
||
resto di una sua scommessa o di un suo prelievo, una vincita. Dal punto di vista
|
||
del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
|
||
(l'accredito dei depositi, vedi
|
||
[registrazione-e-depositi.md](registrazione-e-depositi.md)).
|
||
|
||
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | — | database |
|
||
| `user_id` | FK `users.id`, indicizzata | no | — | Proprietario dell'indirizzo pagato | `app/deposits/service.py:credit_confirmed_utxos` |
|
||
| `txid` | String(64) | no | — | Transazione che ha creato l'output | `credit_confirmed_utxos` |
|
||
| `vout` | Integer | no | — | Posizione dell'output | `credit_confirmed_utxos` |
|
||
| `amount_sats` | BigInteger | no | — | Valore dell'output (`value` di `listunspent`) | `credit_confirmed_utxos` |
|
||
| `confirmed_height` | Integer | no | — | Altezza del blocco riportata dal server (sempre > 0) | `credit_confirmed_utxos` |
|
||
| `confirmed_at` | DateTime | no | `utcnow` | Istante dell'**accredito** nel database, non l'orario del blocco | ORM |
|
||
| `spent_txid` | String(64) | sì | `NULL` | Chi ha speso l'output, vedi [DAT-12] | vedi tabella sotto |
|
||
|
||
- **[DAT-10]** Una riga si inserisce solo per un output che non è già
|
||
presente, che ha raggiunto le conferme richieste per l'accredito e che gli
|
||
altri server Electrum hanno corroborato con lo stesso importo (B-59; la
|
||
corroborazione è in [connessione-electrum.md](connessione-electrum.md)).
|
||
Le conferme richieste e la soglia sotto cui un output non si accredita
|
||
(`DUST_LIMIT_SATS`) sono regole del deposito, definite in
|
||
[registrazione-e-depositi.md](registrazione-e-depositi.md) (B-82): lo
|
||
schema non le ripete. Il numero di conferme configurabile è la colonna
|
||
`round_config.deposit_confirmations` ([DAT-62]). L'inserimento è
|
||
idempotente su (`txid`, `vout`), che è unico su tutta la tabella, non solo
|
||
per utente. Le righe non si cancellano mai e, dopo l'inserimento, solo
|
||
`spent_txid` cambia.
|
||
*Codice:* `app/deposits/service.py:find_new_credit_candidates` ·
|
||
`app/deposits/service.py:credit_confirmed_utxos` ·
|
||
`app/electrum/listener.py:ElectrumListener.refresh_user` ·
|
||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`, `tests/unit/test_deposit_confirmations.py`
|
||
|
||
- **[DAT-11]** Gli UTXO dei montepremi e della riserva **non** sono in
|
||
`utxo_events`: non appartengono a un utente. Il pagamento di un round spende
|
||
solo gli outpoint delle scommesse di quel round (`bet_txid`, `bet_vout` dei
|
||
partecipanti `confirmed`, [DAT-63]) all'indirizzo `rounds.pool_address`,
|
||
dopo averli verificati sulla rete; qualunque altro output presente allo
|
||
stesso indirizzo non viene speso. Resta l'esclusione degli outpoint già
|
||
impegnati da un pagamento non ancora risolto (letti dal `raw_tx_hex` delle
|
||
righe `pending_transactions` di tipo `payout` in `building`/`pending`). Per
|
||
lo stesso motivo l'abbandono di un pagamento non ha input da liberare.
|
||
*Codice:* `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_pool_verification.py::test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else`
|
||
Un round aperto prima dell'indirizzo per round (il cui montepremi è
|
||
l'indirizzo di riserva condiviso) conserva la scelta di prima: le monete
|
||
confermate dell'indirizzo, lette dal server attivo.
|
||
|
||
- **[DAT-12]** `spent_txid` ha tre significati:
|
||
- `NULL`: l'output è spendibile e conta nel saldo;
|
||
- un txid: l'output è impegnato da una transazione della piattaforma
|
||
(scommessa o prelievo), dal momento della sua costruzione, prima ancora
|
||
della trasmissione; resta impostato per sempre una volta confermata;
|
||
- la stringa `external-spend`: l'output è sparito dalla `listunspent` e gli
|
||
altri server hanno confermato che è stato speso fuori dalla piattaforma
|
||
(B-29).
|
||
|
||
Le transizioni ammesse sono queste, e nessun'altra funzione scrive la colonna:
|
||
|
||
| Da | A | Funzione | Quando |
|
||
|---|---|---|---|
|
||
| `NULL` | txid | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal` | fase 1, prima del broadcast |
|
||
| txid | nuovo txid | `app/tx/broadcast.py:_retarget_txid_references` | bump RBF, e conferma di una versione precedente della stessa transazione ([DAT-14]) |
|
||
| txid | `NULL` | `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal` | la rete ha rifiutato **esplicitamente** il broadcast |
|
||
| txid | `NULL` | `app/tx/reconcile.py:_release_inputs` | la transazione è stata abbandonata con una prova ([DAT-13]) |
|
||
| `NULL` | `external-spend` | `app/deposits/service.py:mark_utxos_spent_externally` | spesa esterna corroborata |
|
||
| `external-spend` | `NULL` | `app/deposits/service.py:reinstate_reappeared_utxos` | l'outpoint ricompare come non speso, corroborato dal quorum |
|
||
|
||
Chi sceglie gli input di una nuova transazione considera solo le righe con
|
||
`spent_txid IS NULL`.
|
||
*Codice:* le funzioni in tabella ·
|
||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_bets.py`,
|
||
`tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py`,
|
||
`tests/unit/test_confirmation_proof.py`
|
||
|
||
- **[DAT-13]** Il riconciliatore libera un input solo se il suo `spent_txid` è
|
||
uno dei txid della riga che sta abbandonando (`current_txid` o una sua
|
||
versione in `pending_transaction_txids`, [DAT-70]): se nel frattempo lo
|
||
stesso output risulta impegnato da un'altra transazione, quella
|
||
rivendicazione è quella valida e non va toccata.
|
||
*Codice:* `app/tx/reconcile.py:_release_inputs` ·
|
||
*Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_tx_versions.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. Quando conferma una versione **diversa** da quella corrente (una
|
||
versione precedente registrata in `pending_transaction_txids`, [DAT-70]),
|
||
`current_txid`, `raw_tx_hex`, lo `spent_txid` degli input e il txid della
|
||
riga di dominio vengono riportati a quella versione, nella stessa
|
||
transazione del database che segna la conferma. Senza questo allineamento
|
||
[DAT-13] non riconoscerebbe più gli input di una transazione rimpiazzata.
|
||
*Codice:* `app/tx/broadcast.py:bump_fee` ·
|
||
`app/tx/broadcast.py:_retarget_txid_references` ·
|
||
`app/tx/confirmation.py:poll_once` ·
|
||
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_tx_versions.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: `open`, `closing`, `drawing`, `paying_out`, `closed` ([RND-02](ciclo-di-vita-del-round.md)) | vedi [RND-02](ciclo-di-vita-del-round.md) |
|
||
| `opened_at` | DateTime | no | `utcnow` | Apertura; base della scadenza | `app/rounds/service.py:open_new_round_if_needed` (default) |
|
||
| `closed_at` | DateTime | sì | `NULL` | Chiusura delle scommesse, poi conferma del pagamento ([RND-28](ciclo-di-vita-del-round.md)) | `app/rounds/scheduler.py:RoundScheduler._tick`; `app/rounds/confirmation.py:_on_payout_confirmed` |
|
||
| `duration_seconds` | Integer | no | `600` (anche `server_default`) | Durata di **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` |
|
||
| `cooldown_seconds` | Integer | no | `30` (anche `server_default`) | Pausa dopo **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` |
|
||
| `bet_amount_sats` | BigInteger | no | backfill da `round_config` nella migrazione | Costo della scommessa di **questo** round, copiato all'apertura ([DAT-61]) | `open_new_round_if_needed` |
|
||
| `fee_address` | String(128) | no | backfill da `round_config` nella migrazione | Destinatario della commissione di **questo** round, copiato all'apertura ([DAT-61]) | `open_new_round_if_needed` |
|
||
| `max_round_pool_sats` | BigInteger | no | `0` (`server_default`); backfill `0` | Tetto alla somma delle scommesse di **questo** round, copiato all'apertura ([DAT-61], [DAT-27]); `0` = nessun tetto | `open_new_round_if_needed` |
|
||
| `draw_confirmations` | Integer | no | `3` (`server_default`) | Conferme richieste sul blocco di estrazione di **questo** round, copiate all'apertura ([DAT-61]) | `open_new_round_if_needed` |
|
||
| `pool_address` | String(128) | no | `""` solo fino alla fine della transazione di apertura; backfill con l'indirizzo di riserva per i round esistenti | Indirizzo del montepremi di **questo** round ([DAT-60]) | `open_new_round_if_needed` |
|
||
| `server_seed_enc` | Text | sì | `NULL` | Il segreto del round cifrato con Fernet, con la stessa chiave della master xprv; scritto all'apertura ([DAT-64]) | `open_new_round_if_needed` |
|
||
| `server_seed` | String(64) | sì | `NULL` | Il segreto in chiaro (32 byte in esadecimale), scritto **solo** alla rivelazione ([DAT-64]) | `RoundScheduler._complete_draw`; `RoundScheduler._close_and_draw` (round senza partecipanti) |
|
||
| `server_seed_hash` | String(64) | sì | `NULL` | `sha256(server_seed)` in esadecimale, pubblicato dall'apertura ([DAT-64]) | `open_new_round_if_needed` |
|
||
| `participants_hash` | String(64) | sì | `NULL` | Impronta dell'elenco ordinato dei partecipanti ([DAT-65]) | `RoundScheduler._close_and_draw` |
|
||
| `draw_height` | Integer | sì | `NULL` | Altezza del blocco di estrazione, fissata all'ingresso in `drawing` ([DAT-65]) | `_close_and_draw` |
|
||
| `drawing_started_at` | DateTime | sì | `NULL` | Istante del passaggio a `drawing` | `RoundScheduler._close_and_draw` |
|
||
| `draw_block_height` | Integer | sì | `NULL` | Altezza del blocco usato per l'estrazione; è sempre uguale a `draw_height` | `_complete_draw` |
|
||
| `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_complete_draw` |
|
||
| `seed_int` | String(128) | sì | `NULL` | Il seme in decimale (fino a 78 cifre, per questo è una stringa). Il valore intero di `sha256(server_seed ‖ block_hash ‖ participants_hash)`, con la codifica definita in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (RND-17) | `_complete_draw` |
|
||
| `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_complete_draw` |
|
||
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`, `_complete_draw`; `RoundScheduler._trigger_payout` (la ridefinisce la verifica, solo al ribasso) |
|
||
| `winner_amount_sats` | BigInteger | sì | `NULL` | Quanto riceve il vincitore (70% meno la commissione di rete) | `RoundScheduler._trigger_payout` |
|
||
| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` | `_trigger_payout` |
|
||
| `bump_reserve_sats` | BigInteger | sì | `NULL` | Output di riserva per l'RBF del pagamento, verso l'indirizzo di riserva ([DAT-73]) | `_trigger_payout`; `app/tx/broadcast.py:bump_fee` |
|
||
| `payout_txid` | String(64) | sì | `NULL` | Txid corrente della transazione di pagamento | `_trigger_payout`; `_retarget_txid_references`; `app/tx/reconcile.py:_abandon` |
|
||
| `payout_blocked_at` | DateTime | sì | `NULL` | Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) | `_trigger_payout`; `app/api/routes/admin.py:resume_lottery` |
|
||
|
||
- ~~**[DAT-15]**~~ Assorbito da
|
||
[RND-02](ciclo-di-vita-del-round.md): i valori di `status` e le transizioni
|
||
sono comportamento del round, e il loro unico proprietario è il documento
|
||
del ciclo di vita. Qui restano i fatti di schema: `status` è una
|
||
`String(16)` senza `CHECK` ([DAT-03]); gli stati `open`, `closing`,
|
||
`drawing`, `paying_out` sono quelli **attivi** coperti dall'indice
|
||
`ix_rounds_single_active` ([DAT-17]); la sola scrittura di `status` fuori
|
||
dal codice applicativo è la migrazione `8a1c4e7b2d90` ([DAT-59]). Il
|
||
trattenimento di un pagamento (B-82) **non** è un nuovo stato ma la colonna
|
||
`payout_blocked_at` ([DAT-68]), così l'elenco degli stati attivi, e con
|
||
esso l'indice su espressione, non cambia.
|
||
|
||
- **[DAT-16]** `place_bet` protegge l'inserimento del partecipante con un
|
||
compare-and-set sulla riga del round, nella stessa transazione:
|
||
`UPDATE rounds SET status = 'open' WHERE id = :id AND status = 'open'`. Se
|
||
l'aggiornamento non tocca esattamente una riga la transazione viene annullata
|
||
e la scommessa rifiutata prima di qualunque broadcast (B-53). L'aggiornamento
|
||
non cambia alcun valore: garantisce che, al commit, il round fosse ancora
|
||
`open` nella stessa transazione che inserisce il partecipante, così il
|
||
passaggio a `closing` e l'inserimento non possono intrecciarsi. La
|
||
transazione è aperta con `BEGIN IMMEDIATE` ([DAT-74]), quindi le letture che
|
||
precedono il compare-and-set (saldo, partecipanti già presenti) vedono lo
|
||
stesso stato su cui si scrive. Un `OperationalError` in questo punto è il
|
||
`busy_timeout` scaduto (`database is locked`), non un conflitto di snapshot:
|
||
la scommessa è rifiutata allo stesso modo, perché nulla è ancora stato
|
||
trasmesso.
|
||
*Codice:* `app/bets/service.py:place_bet` · *Test:* `tests/unit/test_bets.py` ·
|
||
`tests/unit/test_sqlite_transactions.py`
|
||
|
||
- **[DAT-17]** Al massimo **un round attivo** alla volta, come vincolo del
|
||
database: `ix_rounds_single_active` è un indice unico sull'espressione
|
||
costante `(1)`, parziale sugli stati attivi
|
||
(`WHERE status IN ('open', 'closing', 'drawing', 'paying_out')`). La tabella
|
||
può quindi contenere quanti round `closed` si vuole, ma un secondo inserimento
|
||
attivo fallisce con `IntegrityError` (B-09). `open_new_round_if_needed`
|
||
gestisce il conflitto annullando il proprio inserimento e adottando il round
|
||
del concorrente, con al massimo 3 tentativi. L'indice è dichiarato sia con
|
||
`sqlite_where` sia con `postgresql_where`, così resta parziale anche su
|
||
PostgreSQL. `get_active_round` ordina comunque per `id` decrescente e registra
|
||
un errore se ne trova più di uno.
|
||
*Codice:* `app/db/models.py:Round` ·
|
||
`app/rounds/service.py:open_new_round_if_needed` ·
|
||
`app/rounds/service.py:get_active_round` ·
|
||
*Test:* `tests/unit/test_rounds_service.py`
|
||
|
||
- ~~**[DAT-18]**~~ Assorbito da [RND-05](ciclo-di-vita-del-round.md) e
|
||
[RND-04](ciclo-di-vita-del-round.md): la copia della durata e della pausa
|
||
all'apertura, la scadenza e il conteggio della pausa dal `closed_at` sono
|
||
regole del round. Qui resta il fatto di schema: `duration_seconds` e
|
||
`cooldown_seconds` sono colonne `NOT NULL` con `server_default` (tabella
|
||
sopra), scritte solo all'inserimento del round.
|
||
|
||
- ~~**[DAT-19]**~~ Assorbito da [RND-28](ciclo-di-vita-del-round.md): le due
|
||
scritture di `closed_at` (chiusura delle scommesse, poi conferma del
|
||
pagamento) sono comportamento del round. Qui resta il fatto di schema:
|
||
`closed_at` è l'unica colonna di `rounds` scritta due volte.
|
||
|
||
- **[DAT-20]** `drawing_started_at` si scrive una sola volta, al passaggio a
|
||
`drawing`. Da lì si misura quanto l'estrazione sta aspettando un blocco
|
||
(`draw_waiting_since` in `GET /rounds/current`, le voci `draw_stalled` del
|
||
registro, B-36).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
`app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_rounds_route.py`
|
||
|
||
- **[DAT-21]** `draw_block_height`, `draw_block_hash`, `seed_int`,
|
||
`winner_user_id` e `pool_amount_sats` si scrivono insieme, nella stessa
|
||
transazione che porta il round a `paying_out`. Poi `draw_block_height`,
|
||
`draw_block_hash`, `seed_int` e `winner_user_id` non cambiano più.
|
||
`pool_amount_sats` è la somma di `bet_amount_sats` dei soli partecipanti
|
||
`confirmed`, gli stessi fra cui si estrae il vincitore; può essere solo
|
||
**ridotto**, una volta, dalla verifica che precede il pagamento ([DAT-67]).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
|
||
- **[DAT-22]** `winner_amount_sats`, `fee_amount_sats`, `bump_reserve_sats` e
|
||
`payout_txid` si scrivono nella fase 3 di `_trigger_payout`, insieme alla
|
||
riga `pending_transactions` del pagamento e **prima** del broadcast (B-25).
|
||
`payout_txid` segue poi il txid corrente a ogni bump RBF. Quando il
|
||
riconciliatore abbandona il pagamento tornano **tutti** a `NULL`, nella
|
||
stessa transazione del database, così un round in `paying_out` senza
|
||
pagamento in volo non mostra importi di un tentativo che non esiste più.
|
||
Un bump RBF non cambia `winner_amount_sats` né `fee_amount_sats`: riduce solo
|
||
l'output di riserva ([DAT-73]).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/tx/broadcast.py:_retarget_txid_references` · `app/tx/reconcile.py:_abandon` ·
|
||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`,
|
||
`tests/unit/test_reconcile.py`
|
||
*Stato:* da implementare (B-96) · *Oggi:* l'abbandono azzera solo
|
||
`payout_txid` e lascia `winner_amount_sats`, `fee_amount_sats` e
|
||
`bump_reserve_sats` del tentativo abbandonato (le riscrive il tentativo
|
||
successivo).
|
||
|
||
- **[DAT-60]** Ogni round ha il proprio indirizzo del montepremi,
|
||
`pool_address`, derivato all'apertura da `m/84'/746'/0'/2/<id>` e scritto
|
||
nella stessa transazione che inserisce il round (l'`id` è noto dopo il
|
||
`flush`, prima del commit); non cambia più. Le scommesse del round pagano
|
||
questo indirizzo. I round aperti prima della migrazione ricevono come
|
||
`pool_address` l'indirizzo unico di oggi (`m/84'/746'/0'/1/0`), che da
|
||
allora è l'indirizzo di **riserva**: il loro pagamento spende, come per ogni
|
||
round, gli outpoint delle loro scommesse a quell'indirizzo ([DAT-11]). Per
|
||
questo la colonna non ha un vincolo `UNIQUE`: i round nuovi hanno
|
||
indirizzi distinti per costruzione (percorso diverso per ogni `id`), i round
|
||
vecchi condividono l'indirizzo di riserva.
|
||
*Codice:* `app/rounds/service.py:open_new_round_if_needed` ·
|
||
`app/wallet/hd.py:derive_pool_key` ·
|
||
`migrations/versions/477bddae1a84_add_pool_address_to_rounds.py` ·
|
||
*Test:* `tests/unit/test_round_pool_address.py`
|
||
|
||
La colonna ha `""` come valore di inserimento (l'`id` non esiste prima del
|
||
`flush`) e `""` come `server_default`, che serve solo ad aggiungerla a una
|
||
tabella con righe; il codice lo riscrive prima del commit e ogni consumatore
|
||
rifiuta un indirizzo vuoto.
|
||
|
||
- **[DAT-61]** `bet_amount_sats`, `fee_address`, `max_round_pool_sats` e
|
||
`draw_confirmations` di `rounds` sono una **copia** dei valori di `round_config` presa quando il
|
||
round si apre, nella stessa transazione dell'inserimento, e non cambiano
|
||
più. Le scommesse del round costano `rounds.bet_amount_sats`, il suo
|
||
pagamento paga la commissione a `rounds.fee_address`, la sua estrazione
|
||
attende `rounds.draw_confirmations` conferme, le scommesse oltre il tetto
|
||
`rounds.max_round_pool_sats` sono rifiutate. La regola di dominio è in
|
||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-95). La
|
||
migrazione riempie le righe esistenti con i valori attuali di
|
||
`round_config` ([DAT-55]).
|
||
*Codice:* `app/rounds/service.py:open_new_round_if_needed` ·
|
||
`app/bets/service.py:place_bet` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
*Test:* `tests/unit/test_round_snapshot.py`, `tests/unit/test_rounds_service.py`, `tests/unit/test_migrations.py`
|
||
|
||
- **[DAT-64]** Il segreto del round sono 32 byte casuali generati con
|
||
`secrets.token_bytes(32)`. All'inserimento del round si scrivono insieme
|
||
`server_seed_hash` (`sha256` di quei 32 byte, esadecimale minuscolo) e
|
||
`server_seed_enc`: il segreto **cifrato con Fernet** usando la stessa chiave
|
||
che cifra la master xprv (`XPRV_ENCRYPTION_KEY`, dopo B-91 il file di
|
||
[sicurezza.md](sicurezza.md#sec-53) SEC-53). Il segreto in chiaro non viene
|
||
mai scritto nel database prima della rivelazione: `server_seed` resta
|
||
`NULL` e si valorizza (esadecimale minuscolo, 64 caratteri) nella stessa
|
||
transazione che scrive `winner_user_id`, oppure alla chiusura di un round
|
||
senza partecipanti. Così una copia del database o un suo backup, senza la
|
||
chiave, non rivela il segreto di un round ancora aperto. Nessun endpoint e
|
||
nessuna voce del registro di audit contengono il segreto prima della
|
||
rivelazione ([api.md](api.md), `GET /rounds/{id}/proof`). Nessuna colonna
|
||
cambia più dopo la rivelazione; `server_seed_enc` resta com'è. Se
|
||
all'avvio la chiave non decifra il `server_seed_enc` di un round non
|
||
ancora rivelato, il server rifiuta di partire
|
||
([sicurezza.md](sicurezza.md#sec-49) SEC-49). I round aperti prima della
|
||
migrazione hanno le tre colonne a `NULL`.
|
||
*Codice:* `app/rounds/service.py:open_new_round_if_needed` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._complete_draw` ·
|
||
`app/api/routes/rounds.py:round_proof` ·
|
||
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_draw_proof.py`
|
||
|
||
- **[DAT-65]** All'ingresso in `drawing`, nella **stessa transazione** che
|
||
cambia lo stato, si scrivono `draw_height`, `participants_hash` e il
|
||
`draw_position` di ogni partecipante `confirmed` ([DAT-66]). `draw_height`
|
||
è l'altezza della punta nota in quel momento più 1; `participants_hash` è
|
||
l'impronta dell'elenco ordinato dei partecipanti con la codifica di
|
||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73). Nessuno
|
||
dei tre cambia più. Sono ciò che permette di riprendere l'estrazione dopo un
|
||
errore o un riavvio ottenendo lo stesso vincitore (B-81): la ripresa legge
|
||
questi valori dal database e non ricalcola nulla.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
|
||
- **[DAT-67]** Prima di costruire il pagamento, gli outpoint delle scommesse
|
||
vengono verificati sulla rete; i partecipanti la cui scommessa non c'è più
|
||
passano a `reorged_out` ([DAT-24]) e `pool_amount_sats` viene riscritto come
|
||
somma dei partecipanti rimasti `confirmed`, **nella stessa transazione del
|
||
database**. Il valore scritto è sempre minore o uguale al precedente.
|
||
La regola di dominio (quorum, ripartizione su ciò che c'è) è in
|
||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-82).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
|
||
*Test:* `tests/unit/test_pool_verification.py`
|
||
|
||
- **[DAT-68]** `payout_blocked_at` si scrive quando la verifica di [DAT-67]
|
||
trova assente la scommessa del **vincitore**: il round resta `paying_out`,
|
||
non si costruisce alcun pagamento finché la colonna non è `NULL`, e nella
|
||
stessa transazione si attiva la pausa con `paused_reason = 'payout_blocked'`
|
||
([DAT-69]). Torna `NULL` solo con `POST /admin/resume` ([api.md](api.md)),
|
||
dopo di che la verifica si ripete al tentativo successivo.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/api/routes/admin.py:resume_lottery` ·
|
||
*Test:* `tests/unit/test_pool_verification.py::test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery`, `tests/unit/test_pool_verification.py::test_after_the_resume_the_verification_repeats`, `tests/unit/test_admin.py::test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain`
|
||
|
||
- **[DAT-73]** Ogni pagamento ha un output di **riserva** verso l'indirizzo
|
||
di riserva (`m/84'/746'/0'/1/0`), di valore
|
||
`max(DUST_LIMIT_SATS, vsize stimata × 10 sat/vB)`, preso dalla quota delle
|
||
commissioni; `bump_reserve_sats` ne registra il valore iniziale. Un bump RBF
|
||
del pagamento riduce questo output (non `winner_amount_sats` né
|
||
`fee_amount_sats`) e non riscrive `bump_reserve_sats`. La regola di dominio
|
||
è in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-79).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/wallet/psbt_builder.py:build_payout_transaction` ·
|
||
`app/tx/broadcast.py:bump_fee` ·
|
||
*Test:* `tests/unit/test_scheduler.py::test_trigger_payout_always_carries_a_bump_reserve_to_the_reserve_address`, `tests/unit/test_broadcast.py::test_a_payout_is_bumped_out_of_its_reserve_output_and_nothing_else`, `tests/unit/test_migrations.py::test_the_bump_reserve_column_exists_at_head_and_is_nullable`
|
||
|
||
### `round_participants`
|
||
|
||
Una scommessa: un utente in un round.
|
||
|
||
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | — | database |
|
||
| `round_id` | FK `rounds.id`, indicizzata | no | — | Il round | `app/bets/service.py:place_bet` |
|
||
| `user_id` | FK `users.id`, indicizzata | no | — | Chi scommette | `place_bet` |
|
||
| `bet_amount_sats` | BigInteger | no | — | Quanto arriva davvero al pool: costo della scommessa **meno** la commissione di rete | `place_bet` |
|
||
| `bet_txid` | String(64) | no | — | Txid corrente della scommessa; dopo la conferma, il txid della versione confermata ([DAT-14]) | `place_bet`; `app/tx/broadcast.py:_retarget_txid_references` |
|
||
| `bet_vout` | Integer | no | `0` (`server_default`, backfill `0`) | Posizione dell'output verso il montepremi nella transazione della scommessa ([DAT-63]) | `place_bet` |
|
||
| `broadcast_at` | DateTime | no | `utcnow` | Istante della fase 1 di `place_bet`, vedi [DAT-25] | `place_bet` |
|
||
| `confirmed_at` | DateTime | sì | `NULL` | Istante in cui la scommessa è risultata confermata | `app/bets/confirmation.py:_on_bet_confirmed` |
|
||
| `draw_position` | Integer | sì | `NULL` | Posizione (da 0) nell'elenco ordinato dell'estrazione; valorizzata solo per i partecipanti `confirmed` all'ingresso in `drawing` ([DAT-66]) | `app/rounds/scheduler.py:RoundScheduler._close_and_draw` |
|
||
| `status` | String(16) | no | `"broadcast"` (mai usato: si scrive sempre esplicitamente) | Vedi [DAT-24] | vedi diagramma |
|
||
|
||
- **[DAT-23]** Al massimo una scommessa per utente per round: vincolo unico su
|
||
(`round_id`, `user_id`). `place_bet` lo verifica già prima di costruire la
|
||
transazione (`already_betting`). Il vincolo è anche la chiave con cui i
|
||
gestori di conferma, bump e riconciliazione ritrovano la riga, invece del txid
|
||
che cambia.
|
||
*Codice:* `app/db/models.py:RoundParticipant` · `app/bets/service.py:place_bet` ·
|
||
`app/bets/confirmation.py:_on_bet_confirmed` · *Test:* `tests/unit/test_bets.py`
|
||
|
||
- **[DAT-24]** `status` assume solo i valori `building`, `broadcast`,
|
||
`confirmed`, `reorged_out`. Una scommessa che non va a buon fine prima della
|
||
conferma viene **cancellata**, e l'utente non risulta nel round.
|
||
`reorged_out` è terminale e riguarda solo una scommessa già `confirmed` il
|
||
cui outpoint non è più sulla catena alla verifica che precede il pagamento
|
||
([DAT-67]): la riga **non** si cancella, perché ha già un `draw_position` e
|
||
fa parte dell'elenco pubblicato con `participants_hash`, ma non conta nel
|
||
montepremi. Diagramma del comportamento atteso (oggi manca `reorged_out`):
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> building: place_bet fase 1
|
||
building --> broadcast: place_bet fase 2 / _promote
|
||
building --> confirmed: _on_bet_confirmed
|
||
broadcast --> confirmed: _on_bet_confirmed
|
||
building --> [*]: cancellata (_release_failed_bet / _abandon)
|
||
broadcast --> [*]: cancellata (_abandon)
|
||
confirmed --> reorged_out: verifica prima del pagamento
|
||
confirmed --> [*]
|
||
reorged_out --> [*]
|
||
```
|
||
|
||
| Da | A | Funzione |
|
||
|---|---|---|
|
||
| — | `building` | `app/bets/service.py:place_bet` (fase 1, prima del broadcast) |
|
||
| `building` | `broadcast` | `place_bet` (fase 2, broadcast riuscito); `app/tx/reconcile.py:_promote` |
|
||
| `building`, `broadcast` | `confirmed` | `app/bets/confirmation.py:_on_bet_confirmed` |
|
||
| `building` | cancellata | `app/bets/service.py:_release_failed_bet` (broadcast rifiutato esplicitamente) |
|
||
| `building`, `broadcast` | cancellata | `app/tx/reconcile.py:_abandon` (abbandono con prova) |
|
||
| `confirmed` | `reorged_out` | `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` (da `_trigger_payout`), per ogni partecipante tranne il vincitore ([RND-39](ciclo-di-vita-del-round.md#4--pagamento)) |
|
||
|
||
Se il compare-and-set di [DAT-16] fallisce, la riga `building` non arriva
|
||
nemmeno al commit. Un broadcast dall'esito ambiguo (timeout, connessione
|
||
caduta) lascia la riga in `building` per il riconciliatore
|
||
([scommesse-e-prelievi.md](scommesse-e-prelievi.md) B-76).
|
||
*Codice:* `app/bets/service.py:place_bet` ·
|
||
`app/bets/service.py:_release_failed_bet` · `app/tx/reconcile.py:_promote` ·
|
||
`app/tx/reconcile.py:_abandon` · `app/bets/confirmation.py:_on_bet_confirmed` ·
|
||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_confirmation.py`,
|
||
`tests/unit/test_reconcile.py`, `tests/unit/test_pool_verification.py`
|
||
*Stato:* da implementare (B-76) · *Oggi:* qualunque eccezione del broadcast
|
||
cancella la riga come un rifiuto.
|
||
|
||
- **[DAT-25]** `broadcast_at` è l'istante della fase 1 di `place_bet`, appena
|
||
prima del broadcast, e non viene mai riscritto (nemmeno da un bump RBF). Non
|
||
determina più l'ordine di estrazione, che è quello di [DAT-66]: resta
|
||
un'informazione per `/admin` e per il registro.
|
||
*Codice:* `app/bets/service.py:place_bet` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
|
||
- **[DAT-63]** `bet_vout` è l'indice dell'output della scommessa che paga
|
||
`rounds.pool_address`; con il costruttore attuale è sempre `0`
|
||
(`recipient_sats` è il primo output). Insieme a `bet_txid` identifica
|
||
l'outpoint che l'elenco dell'estrazione pubblica e che il pagamento spende
|
||
([DAT-11]).
|
||
*Codice:* `app/bets/service.py:place_bet` ·
|
||
`app/wallet/psbt_builder.py:RECIPIENT_VOUT` · *Test:* `tests/unit/test_bets.py`
|
||
|
||
- **[DAT-66]** `draw_position` si scrive una sola volta, all'ingresso in
|
||
`drawing` ([DAT-65]), per ogni partecipante `confirmed` del round: i
|
||
partecipanti sono ordinati per `bet_txid` crescente (confronto delle
|
||
stringhe esadecimali minuscole) e numerati da 0. Il vincolo unico
|
||
(`round_id`, `draw_position`) impedisce due partecipanti nella stessa
|
||
posizione; i partecipanti senza posizione hanno `NULL`, ammesso più volte
|
||
dal vincolo. `draw_position` non cambia nemmeno quando la riga passa a
|
||
`reorged_out`.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
`app/db/models.py:RoundParticipant` ·
|
||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_migrations.py`
|
||
|
||
- **[DAT-26]** `bet_amount_sats` è l'importo del primo output della scommessa
|
||
(`recipient_sats`), cioè il costo della scommessa del round
|
||
(`rounds.bet_amount_sats`, [DAT-61]) meno la commissione di rete. Non cambia
|
||
più: un bump RBF riduce il resto dell'utente, non l'output verso il pool.
|
||
Per questo il montepremi si calcola sommando questa colonna, non
|
||
moltiplicando il numero di partecipanti per il costo (B-11).
|
||
*Codice:* `app/bets/service.py:place_bet` ·
|
||
`app/wallet/psbt_builder.py:build_signed_transaction` ·
|
||
`app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.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). Con
|
||
`rounds.max_round_pool_sats` (copiato da `round_config` all'apertura,
|
||
[DAT-61], [DAT-62]) diverso da 0, una scommessa è rifiutata con `round_full`
|
||
anche quando la somma dei `bet_amount_sats` del round, contando tutti gli
|
||
stati, ha già raggiunto quel tetto; la regola di
|
||
dominio è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95).
|
||
*Codice:* `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py` ·
|
||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py`
|
||
|
||
### `round_config`
|
||
|
||
I parametri operativi, in **una sola riga**, modificabili a caldo da `/admin`
|
||
senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
|
||
|
||
| Colonna | Tipo | Default | Limiti accettati da `PUT /admin/config` | Significato | Quando ha effetto |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | autoincrement | — | — | — |
|
||
| `fee_address` | String(128), non null | nessun default nel modello; la riga nasce con `""` | indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Dal round **successivo**, copiato all'apertura ([DAT-61]); il round in corso paga la commissione all'indirizzo con cui si è aperto |
|
||
| `bet_amount_sats` | BigInteger | `1_000_000_000` (10 PLM) | `> 0`, `≤ 100 000 PLM` | Costo della scommessa; anche il prelievo minimo | Per le scommesse, dal round **successivo** ([DAT-61]); per il prelievo minimo, subito ([ADR-20](adr/ADR-20-prelievo-minimo-costo-corrente-scommessa.md)). Le scommesse già piazzate tengono il proprio importo ([DAT-26]) |
|
||
| `round_duration_seconds` | Integer | `600` | 30 s – 7 giorni | Durata del round | Dal round **successivo** ([RND-05](ciclo-di-vita-del-round.md)) |
|
||
| `round_cooldown_seconds` | Integer | `30` | 0 s – 24 h | Pausa dopo la chiusura | Dal round **successivo**: la pausa dopo il round in corso è quella copiata alla sua apertura ([RND-05](ciclo-di-vita-del-round.md)) |
|
||
| `draw_animation_seconds` | Integer | `20` | 0 – 600 s | Durata minima dell'animazione dell'estrazione nel frontend; non condiziona l'estrazione vera | Subito (letto da `GET /rounds/current`) |
|
||
| `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (10 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` |
|
||
| `rbf_timeout_seconds` | Integer | `900` | 60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) |
|
||
| `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) | Subito, per gli accrediti successivi; gli output già accreditati restano |
|
||
| `draw_confirmations` | Integer | `3` | 1 – 20 | Conferme richieste sul blocco di estrazione ([DAT-62]) | Dal round **successivo**, copiato all'apertura ([DAT-61]) |
|
||
| `max_round_pool_sats` | BigInteger | `0` (tetto disattivato) | `0`, oppure `≥ bet_amount_sats` e `≤ 4 000 000 000 000 000` (400 × 100 000 PLM) | Tetto alla somma delle scommesse di un round ([DAT-27], [DAT-62]) | Dal round **successivo**, copiato all'apertura ([DAT-61]): un round tiene il tetto con cui si è aperto |
|
||
| `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa della lotteria | Subito per l'apertura del round successivo; il round in corso non è toccato |
|
||
| `paused_reason` | String(16) | `NULL` | non modificabile da `PUT /admin/config` | Perché la lotteria è in pausa ([DAT-69]) | Insieme a `paused` |
|
||
| `updated_at` | DateTime | `utcnow`, aggiornato a ogni modifica (`onupdate`) | — | Ultima modifica della riga | ORM |
|
||
|
||
- **[DAT-28]** La tabella contiene al più una riga, creata alla prima lettura
|
||
con i default di colonna di `RoundConfig` e `fee_address = ""`. Tutti leggono
|
||
la configurazione attraverso `get_round_config`, che prende la prima riga
|
||
trovata. Il database non impone l'unicità della riga: la garanzia è che solo
|
||
`get_round_config` ne inserisce una. I default di un'istanza nuova sono quelli
|
||
del modello, non variabili d'ambiente.
|
||
*Codice:* `app/rounds/config.py:get_round_config` · `app/db/models.py:RoundConfig` ·
|
||
*Test:* `tests/unit/test_admin.py`
|
||
|
||
- **[DAT-29]** Le colonne modificabili da `PUT /admin/config` sono esattamente
|
||
`fee_address`, `bet_amount_sats`, `round_duration_seconds`,
|
||
`round_cooldown_seconds`, `fee_rate_sat_vb`, `rbf_timeout_seconds`,
|
||
`draw_animation_seconds`, `deposit_confirmations`, `draw_confirmations`,
|
||
`max_round_pool_sats`, entro i limiti della tabella sopra. Ogni modifica
|
||
effettiva scrive una voce `config_updated` con il valore prima e dopo, e un
|
||
valore uguale a quello attuale non conta come modifica.
|
||
*Codice:* `app/api/routes/admin.py:update_config` ·
|
||
`app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py`
|
||
|
||
|
||
- **[DAT-30]** `paused` e `paused_reason` si modificano solo con
|
||
`POST /admin/pause` e `POST /admin/resume`, che scrivono `lottery_paused` e
|
||
`lottery_resumed` nel registro (B-10), e con le pause automatiche di
|
||
[DAT-69], che scrivono la propria voce di audit.
|
||
*Codice:* `app/api/routes/admin.py:pause_lottery` ·
|
||
`app/api/routes/admin.py:resume_lottery` · *Test:* `tests/unit/test_admin.py`
|
||
|
||
- **[DAT-62]** `deposit_confirmations` (predefinito 6) è il numero di
|
||
conferme dopo cui un output che paga un utente si accredita ([DAT-10]);
|
||
`draw_confirmations` (predefinito 3) è il numero di blocchi che la catena
|
||
deve avere a partire da `draw_height` prima che si legga l'hash del blocco
|
||
di estrazione, copiato su ogni round all'apertura ([DAT-61]);
|
||
`max_round_pool_sats` (predefinito 0) è il tetto di [DAT-27], disattivato
|
||
quando vale 0. Il significato di dominio è in
|
||
[registrazione-e-depositi.md](registrazione-e-depositi.md) (B-82),
|
||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73) e
|
||
[scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95). La migrazione le
|
||
aggiunge con un `server_default` temporaneo pari al predefinito ([DAT-55]).
|
||
*Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` ·
|
||
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_migrations.py`
|
||
|
||
- **[DAT-69]** `paused_reason` è `NULL` esattamente quando `paused` è falso;
|
||
con `paused` vero vale uno di questi valori, e nessun altro:
|
||
|
||
| Valore | Chi lo scrive | Quando |
|
||
|---|---|---|
|
||
| `operator` | `app/api/routes/admin.py:pause_lottery`; l'avvio con `START_PAUSED=true` ([operativita.md](operativita.md) B-90) | pausa decisa dall'operatore |
|
||
| `reorg` | il listener Electrum | riorganizzazione di profondità ≥ 2 ([connessione-electrum.md](connessione-electrum.md) B-85) |
|
||
| `payout_blocked` | `app/rounds/scheduler.py:RoundScheduler._trigger_payout` | la scommessa del vincitore non è più sulla catena ([DAT-68], B-82) |
|
||
| `resurrected_tx` | `app/tx/reconcile.py:reconcile_once` | una riga `failed` risulta confermata ([DAT-72], B-78) |
|
||
|
||
Una pausa automatica (`reorg`, `payout_blocked`, `resurrected_tx`) scrive
|
||
sempre il proprio motivo, anche sopra una pausa già attiva; una pausa
|
||
`operator` non sovrascrive un motivo automatico già presente, così la
|
||
ragione che richiede un controllo non si perde. `POST /admin/resume` è
|
||
l'unico modo di tornare a `paused = false`, `paused_reason = NULL`, per
|
||
qualunque motivo. La pausa, come oggi, impedisce solo l'apertura del round
|
||
successivo.
|
||
*Codice:* `app/api/routes/admin.py:pause_lottery` ·
|
||
`app/api/routes/admin.py:resume_lottery` ·
|
||
`app/electrum/listener.py:ElectrumListener._record_reorg` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/tx/reconcile.py:reconcile_once` · *Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`
|
||
*Stato:* da implementare (B-90) · *Oggi:* si scrivono
|
||
`operator` (`POST /admin/pause`), `resurrected_tx` (il riconciliatore),
|
||
`reorg` (il listener) e `payout_blocked` (lo scheduler); `START_PAUSED` non ha
|
||
ancora chi lo scrive.
|
||
|
||
- **[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). La cronologia completa è in `pending_transaction_txids` ([DAT-70]) e questa colonna resta solo informativa | `bump_fee` |
|
||
| `attempt_count` | Integer | no | `1` | 1 + numero di bump riusciti | `bump_fee` |
|
||
| `failure_reason` | String(128) | sì | `NULL` | Perché la transazione è fallita; solo con `status = failed` | `_abandon`; `_release_failed_withdrawal` |
|
||
| `failed_at` | DateTime | sì | `NULL` | Istante del passaggio a `failed`; da lì si contano i 14 giorni di sorveglianza ([DAT-72]) | `_abandon`; `_release_failed_withdrawal` |
|
||
| `updated_at` | DateTime | no | `utcnow`, `onupdate` | Ultima modifica | ORM |
|
||
|
||
Le funzioni citate sono `app/bets/service.py:place_bet`,
|
||
`app/withdrawals/service.py:request_withdrawal`,
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
|
||
`app/tx/broadcast.py:bump_fee`, `app/tx/reconcile.py:_abandon` e
|
||
`app/withdrawals/service.py:_release_failed_withdrawal`.
|
||
|
||
- **[DAT-32]** Le colonne di collegamento dipendono da `kind`:
|
||
|
||
| `kind` | `round_id` | `user_id` | `withdrawal_id` | Firmata con | Riga di dominio collegata |
|
||
|---|---|---|---|---|---|
|
||
| `bet` | il round | lo scommettitore | `NULL` | chiave dell'utente | `round_participants` (`round_id`, `user_id`) |
|
||
| `withdrawal` | `NULL` | chi preleva | il prelievo | chiave dell'utente | `withdrawals` (`withdrawal_id`) |
|
||
| `payout` | il round | `NULL` | `NULL` | chiave del montepremi del round (`m/84'/746'/0'/2/<round_id>`; la chiave di riserva per i round anteriori a B-80) | `rounds` (`round_id`) |
|
||
|
||
I gestori delle conferme e del riconciliatore ritrovano la riga di dominio da
|
||
questi identificativi, che non cambiano, e non dal txid, che cambia a ogni
|
||
bump (B-02). L'indirizzo di cui controllare la cronologia si ricava da
|
||
`kind` e `user_id` e, per un `payout`, da `rounds.pool_address` del
|
||
`round_id` (B-80).
|
||
*Codice:* `app/tx/pending_address.py:own_address_for` ·
|
||
`app/tx/broadcast.py:_signing_context` ·
|
||
`app/bets/confirmation.py:_on_bet_confirmed` ·
|
||
`app/withdrawals/confirmation.py:_on_withdrawal_confirmed` ·
|
||
`app/rounds/confirmation.py:_on_payout_confirmed` ·
|
||
*Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_pending_address.py`
|
||
|
||
- **[DAT-33]** `status` assume solo i valori `building`, `pending`, `confirmed`,
|
||
`failed`. `confirmed` è terminale; `failed` è terminale per il bumper e per il
|
||
poller ordinario, ma per 14 giorni da `failed_at` la riga resta sorvegliata e
|
||
può ancora passare a `confirmed` se una delle sue versioni conferma
|
||
([DAT-72]). Diagramma del comportamento atteso (oggi `failed` è definitivo e
|
||
il broadcast ambiguo non esiste come caso a sé):
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> building: creazione, prima del broadcast
|
||
building --> building: broadcast ambiguo (timeout, connessione caduta)
|
||
building --> pending: broadcast accettato / _promote
|
||
building --> failed: rifiuto esplicito / _abandon con prova
|
||
building --> [*]: cancellata (_release_failed_bet, rifiuto esplicito)
|
||
pending --> confirmed: poll_once
|
||
pending --> failed: _abandon con prova
|
||
failed --> confirmed: conferma tardiva entro 14 giorni (pending_tx_resurrected)
|
||
confirmed --> [*]
|
||
failed --> [*]: dopo 14 giorni da failed_at
|
||
```
|
||
|
||
| Da | A | Funzione |
|
||
|---|---|---|
|
||
| — | `building` | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` |
|
||
| `building` | `pending` | le stesse tre, dopo che il server attivo ha accettato il broadcast (anche con un rifiuto "già noto / già nel mempool"); `app/tx/reconcile.py:_promote` |
|
||
| `pending` | `confirmed` | `app/tx/confirmation.py:poll_once` (poi il gestore del `kind`) |
|
||
| `building`, `pending` | `failed` | `app/tx/reconcile.py:_abandon`, solo con una prova ([transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) B-78) |
|
||
| `building` | `failed` | `app/withdrawals/service.py:_release_failed_withdrawal`, solo per un rifiuto esplicito |
|
||
| `building` | cancellata | `app/bets/service.py:_release_failed_bet`, solo per un rifiuto esplicito |
|
||
| `failed` | `confirmed` | `app/tx/reconcile.py:reconcile_once` ([DAT-72]) |
|
||
|
||
Un broadcast dall'esito ambiguo lascia la riga in `building` per il
|
||
riconciliatore, per tutti e tre i `kind` (B-76). Il poller delle conferme
|
||
guarda le righe `pending`, più le `failed` entro 14 giorni.
|
||
*Codice:* le funzioni in tabella ·
|
||
*Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_reconcile.py`,
|
||
`tests/unit/test_reconcile_proof.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`, e `current_txid`/`raw_tx_hex` anche nel
|
||
riallineamento a una versione precedente confermata ([DAT-14]). `bump_fee`
|
||
procede in quest'ordine:
|
||
1. scrive la nuova versione in `pending_transaction_txids` come
|
||
**candidata**, con un commit, prima di trasmetterla ([DAT-71]);
|
||
2. la trasmette;
|
||
3. aggiorna la riga con
|
||
`UPDATE ... WHERE id = :id AND status = 'pending' AND current_txid = :vecchio`,
|
||
insieme al retarget di [DAT-14], in una sola transazione. Se
|
||
l'aggiornamento tocca zero righe (la riga è stata confermata,
|
||
abbandonata o già riaccelerata nel frattempo) non scrive nulla e registra
|
||
il conflitto, che il riconciliatore risolve.
|
||
|
||
`fee_rate_sat_vb` registra la commissione effettivamente pagata dalla nuova
|
||
versione, non quella obiettivo.
|
||
*Codice:* `app/tx/broadcast.py:bump_fee` · *Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_tx_versions.py`
|
||
|
||
- **[DAT-35]** `broadcast_at` è la **prima** trasmissione e non viene mai
|
||
riscritta: è il riferimento da cui il riconciliatore misura i propri tempi
|
||
di attesa, definiti in
|
||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
|
||
`last_broadcast_at` è l'ultima trasmissione: la aggiorna `bump_fee` e da lì
|
||
`should_bump` misura se serve un altro bump. Tenerle separate impedisce che
|
||
bump ripetuti rinviino all'infinito il controllo del riconciliatore (B-27).
|
||
*Codice:* `app/tx/reconcile.py:_is_due` · `app/tx/broadcast.py:should_bump` ·
|
||
`app/tx/broadcast.py:bump_fee` ·
|
||
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_reconcile.py`
|
||
|
||
- **[DAT-36]** `failure_reason` e `failed_at` si scrivono solo quando la riga
|
||
passa a `failed`, `failure_reason` troncato a 128 caratteri. Il motivo nomina
|
||
la prova su cui si è abbandonato (un input speso da un'altra transazione
|
||
confermata, con il suo txid, oppure il rifiuto esplicito della
|
||
ritrasmissione, con il messaggio della rete, B-78), o il messaggio d'errore
|
||
della rete per un broadcast rifiutato. Una riga che risorge ([DAT-72])
|
||
conserva `failure_reason` e `failed_at`.
|
||
*Codice:* `app/tx/reconcile.py:_abandon` ·
|
||
`app/withdrawals/service.py:_release_failed_withdrawal` ·
|
||
*Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_reconcile_proof.py`
|
||
|
||
- **[DAT-72]** Una riga `failed` con `failed_at` negli ultimi 14 giorni resta
|
||
tra quelle che il riconciliatore controlla a ogni giro (120 s,
|
||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) TX-23), su tutti i txid di
|
||
`pending_transaction_txids`. Se una versione risulta confermata, la riga
|
||
passa a `confirmed`, si scrive `pending_tx_resurrected` e la lotteria va in
|
||
pausa con `paused_reason = 'resurrected_tx'` ([DAT-69]), tutto nella stessa
|
||
transazione. Le righe di dominio già riportate indietro dall'abbandono
|
||
(partecipante cancellato, prelievo `failed`, input liberati) **non** vengono
|
||
ricostruite automaticamente: le riallinea l'operatore
|
||
([operativita.md](operativita.md) B-78).
|
||
*Codice:* `app/tx/reconcile.py:reconcile_once` · `app/tx/reconcile.py:_abandon` ·
|
||
*Test:* `tests/unit/test_reconcile_proof.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`
|
||
|
||
### `pending_transaction_txids`
|
||
|
||
Ogni versione che una riga
|
||
`pending_transactions` abbia mai avuto: la prima, ogni sostituzione RBF
|
||
trasmessa e ogni candidata scritta prima di un bump. Serve perché una
|
||
qualunque di queste versioni può essere quella che la rete conferma.
|
||
|
||
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | — | database |
|
||
| `pending_transaction_id` | FK `pending_transactions.id`, indicizzata | no | — | La transazione di cui questa è una versione | `place_bet`; `request_withdrawal`; `_trigger_payout`; `bump_fee` |
|
||
| `txid` | String(64), unico | no | — | Txid della versione | come sopra |
|
||
| `raw_hex` | Text | no | — | Transazione firmata di questa versione; stringa vuota solo per un `replaced_by_txid` ricostruito dal backfill della migrazione, di cui la transazione grezza non è più disponibile | come sopra |
|
||
| `created_at` | DateTime | no | `utcnow` | Istante in cui la versione è stata scritta, prima della sua trasmissione | ORM |
|
||
|
||
- **[DAT-70]** Ogni txid che una riga `pending_transactions` abbia mai avuto
|
||
come versione ha una riga qui, scritta nello **stesso commit** che precede
|
||
la sua trasmissione: la prima versione insieme alla riga
|
||
`pending_transactions` in `building` ([DAT-44]), ogni sostituzione nella
|
||
fase 1 di `bump_fee` ([DAT-34]). Le righe non si modificano e non si
|
||
cancellano; `txid` è unico su tutta la tabella, quindi un txid appartiene a
|
||
una sola transazione della piattaforma. Il poller delle conferme e il
|
||
riconciliatore cercano **tutti** i txid della riga, non solo
|
||
`current_txid`: la conferma di uno qualunque conferma la riga ([DAT-14]).
|
||
La riga `bet` cancellata da `_release_failed_bet` ([DAT-38]) porta con sé
|
||
le proprie versioni, nella stessa transazione.
|
||
*Codice:* `app/db/models.py:PendingTransactionTxid` · `app/tx/versions.py` · `app/tx/broadcast.py:bump_fee` ·
|
||
`app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` ·
|
||
*Test:* `tests/unit/test_tx_versions.py`, `tests/unit/test_migrations.py`
|
||
|
||
- **[DAT-71]** Una versione **candidata** è una riga di questa tabella il cui
|
||
txid non è (ancora) `current_txid`: `bump_fee` l'ha scritta ma la fase 3 non
|
||
l'ha ancora adottata, perché il processo si è fermato, la trasmissione è
|
||
fallita o l'aggiornamento condizionale ha trovato un conflitto. Una
|
||
candidata mai trasmessa è innocua (nessuno la conferma); una candidata
|
||
trasmessa e poi confermata viene riconosciuta come qualunque altra versione.
|
||
Il riconciliatore, quando ritrasmette, usa il `raw_tx_hex` corrente, non
|
||
quello di una candidata.
|
||
*Codice:* `app/tx/broadcast.py:bump_fee` · `app/tx/reconcile.py:reconcile_once` ·
|
||
*Test:* `tests/unit/test_tx_versions.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 e da ogni indirizzo di montepremi, di riserva e delle commissioni | `request_withdrawal` |
|
||
| `amount_requested_sats` | BigInteger | no | — | Importo chiesto dall'utente | `request_withdrawal` |
|
||
| `amount_sent_sats` | BigInteger | sì (sempre valorizzata dal codice) | `NULL` | Quanto riceve il destinatario, vedi [DAT-40] | `request_withdrawal` |
|
||
| `txid` | String(64) | sì | `NULL` | Txid corrente; `NULL` dopo un fallimento | `request_withdrawal`; `_retarget_txid_references`; `_release_failed_withdrawal`; `_abandon` |
|
||
| `status` | String(16) | no | `"pending"` (mai usato: si crea sempre `building`) | Vedi [DAT-39] | vedi diagramma |
|
||
| `created_at` | DateTime | no | `utcnow` | Istante della richiesta | ORM |
|
||
| `confirmed_at` | DateTime | sì | `NULL` | Istante in cui il prelievo è risultato confermato | `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` |
|
||
|
||
- **[DAT-39]** `status` assume solo i valori `building`, `broadcast`,
|
||
`confirmed`, `failed`; gli ultimi due sono terminali. Il passaggio a `failed`
|
||
azzera `txid`. Diagramma del comportamento atteso (oggi un broadcast
|
||
ambiguo porta a `failed` come un rifiuto):
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> building: request_withdrawal
|
||
building --> building: broadcast ambiguo (risposta 202)
|
||
building --> broadcast: broadcast accettato / _promote
|
||
building --> confirmed: _on_withdrawal_confirmed
|
||
broadcast --> confirmed: _on_withdrawal_confirmed
|
||
building --> failed: rifiuto esplicito / _abandon con prova
|
||
broadcast --> failed: _abandon con prova
|
||
confirmed --> [*]
|
||
failed --> [*]
|
||
```
|
||
|
||
| Da | A | Funzione |
|
||
|---|---|---|
|
||
| — | `building` | `app/withdrawals/service.py:request_withdrawal` |
|
||
| `building` | `broadcast` | `request_withdrawal` (broadcast accettato); `app/tx/reconcile.py:_promote` |
|
||
| `building`, `broadcast` | `confirmed` | `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` |
|
||
| `building` | `failed` | `app/withdrawals/service.py:_release_failed_withdrawal` (rifiuto esplicito) |
|
||
| `building`, `broadcast` | `failed` | `app/tx/reconcile.py:_abandon` (abbandono con prova) |
|
||
|
||
Se la riga `pending_transactions` di un prelievo `failed` risorge
|
||
([DAT-72]), il prelievo resta `failed` finché l'operatore non lo riallinea.
|
||
*Codice:* le funzioni in tabella ·
|
||
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py`, `tests/unit/test_reconcile_proof.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] |
|
||
| unico (`round_id`, `draw_position`) | `round_participants` | `UNIQUE` (i `NULL` non collidono) | una posizione di estrazione per partecipante, [DAT-66]; creato come indice (`ux_round_participants_round_draw_position`) |
|
||
| unico `txid` | `pending_transaction_txids` | `UNIQUE` | un txid appartiene a una sola transazione, [DAT-70] |
|
||
| `ix_pending_transaction_txids_pending_transaction_id` | `pending_transaction_txids` | indice | versioni di una transazione |
|
||
| `ix_round_participants_round_id` | `round_participants` | indice | partecipanti di un round |
|
||
| `ix_round_participants_user_id` | `round_participants` | indice | round di un utente (`last-round-result`) |
|
||
| `ix_withdrawals_user_id` | `withdrawals` | indice | prelievi di un utente |
|
||
|
||
Non ci sono indici su `pending_transactions`, `audit_log` o `bug_reports` oltre
|
||
alla chiave primaria. In particolare `pending_transactions.status` e
|
||
`audit_log.event_type`/`round_id`, usati dai filtri dei task in background, sono
|
||
letti con una scansione completa.
|
||
|
||
## Invarianti tra tabelle
|
||
|
||
- **[DAT-44]** **Prima si scrive, poi si trasmette.** Ogni **versione** di
|
||
una transazione in uscita esiste nel database prima di essere trasmessa. Per
|
||
la prima versione: la riga `pending_transactions` in `building`, la sua riga
|
||
in `pending_transaction_txids`, la riga di dominio (partecipante in
|
||
`building`, prelievo in `building`, oppure `payout_txid` sul round) e, per
|
||
scommesse e prelievi, gli input già marcati con `spent_txid`, tutto in un
|
||
commit che precede il broadcast (B-08, B-25). Per una sostituzione RBF: la
|
||
sua riga candidata in `pending_transaction_txids`, in un commit che precede
|
||
il broadcast ([DAT-34], [DAT-71]). Nessuna sessione del database resta
|
||
aperta durante una chiamata di rete.
|
||
*Codice:* `app/bets/service.py:place_bet` ·
|
||
`app/withdrawals/service.py:request_withdrawal` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/tx/broadcast.py:bump_fee` ·
|
||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_scheduler.py`, `tests/unit/test_tx_versions.py`
|
||
|
||
- **[DAT-45]** **Ogni versione è riconoscibile, una sola è corrente.** Per ogni
|
||
transazione non terminale, `pending_transactions.current_txid` coincide con
|
||
il txid registrato sulla riga di dominio (`round_participants.bet_txid`,
|
||
`withdrawals.txid`, `rounds.payout_txid`) e con lo `spent_txid` di ogni input
|
||
impegnato ([DAT-14]); ogni altro txid che la transazione abbia avuto è in
|
||
`pending_transaction_txids` ([DAT-70]), e la conferma di uno qualunque di
|
||
essi conferma la transazione. `current_txid` cambia solo con un
|
||
aggiornamento condizionato al valore precedente ([DAT-34]), che aggiorna
|
||
tutti i riferimenti nella stessa transazione del database. Per un
|
||
pagamento, `payout_txid` si aggiorna solo se era ancora uguale al vecchio
|
||
txid.
|
||
*Codice:* `app/tx/broadcast.py:_retarget_txid_references` ·
|
||
`app/tx/broadcast.py:bump_fee` · `app/tx/confirmation.py:poll_once` ·
|
||
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_tx_versions.py`
|
||
|
||
- **[DAT-46]** **Il montepremi di ogni round si conserva, separato dagli
|
||
altri.** Per un round pagato, con i soli partecipanti rimasti `confirmed`
|
||
dopo la verifica di [DAT-67]:
|
||
- gli input del pagamento sono esattamente gli outpoint (`bet_txid`,
|
||
`bet_vout`) di quei partecipanti, all'indirizzo `rounds.pool_address` del
|
||
round ([DAT-11]); nessun input viene da un altro round o da un altro
|
||
indirizzo;
|
||
- `pool_amount_sats` = somma dei loro `bet_amount_sats` = somma dei valori
|
||
di quegli input;
|
||
- 70% (arrotondato per difetto) di `pool_amount_sats` = `winner_amount_sats`
|
||
+ commissione di rete del pagamento;
|
||
- il resto (30% più l'arrotondamento) = `fee_amount_sats` +
|
||
`bump_reserve_sats` ([DAT-73]).
|
||
|
||
Quindi `pool_amount_sats` = `winner_amount_sats` + `fee_amount_sats` +
|
||
`bump_reserve_sats` + commissione di rete, al satoshi. La commissione di
|
||
rete esce dalla quota del vincitore, la riserva da quella della
|
||
piattaforma. La ripartizione è nel codice, non in `round_config`.
|
||
*Codice:* `app/rounds/service.py:winner_share` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/wallet/psbt_builder.py:build_payout_transaction` ·
|
||
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py`, `tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there`
|
||
|
||
- ~~**[DAT-47]**~~ Assorbito da [RND-12](ciclo-di-vita-del-round.md): l'attesa
|
||
delle scommesse in volo prima dell'estrazione è comportamento del round. Il
|
||
testo di qui diceva anche che l'attesa finisce perché ogni riga in volo
|
||
viene "prima o poi" confermata o cancellata: non è una garanzia (una riga
|
||
non viene abbandonata senza una prova, B-78) e non va ripetuta.
|
||
|
||
- ~~**[DAT-48]**~~ Assorbito da [RND-26](ciclo-di-vita-del-round.md): le due
|
||
protezioni contro il doppio pagamento sono comportamento del round. Lo
|
||
schema non ha un vincolo che le imponga; ciò che il database fornisce è il
|
||
`raw_tx_hex` da cui si leggono gli outpoint impegnati ([DAT-37]).
|
||
|
||
- **[DAT-49]** **Le transazioni ritrovano la loro riga senza il txid.** Le righe
|
||
collegate a una `pending_transactions` si ritrovano tramite `round_id`,
|
||
`user_id` e `withdrawal_id` ([DAT-32]); il txid serve solo come ripiego per
|
||
righe senza collegamento.
|
||
*Codice:* `app/bets/confirmation.py:_on_bet_confirmed` ·
|
||
`app/rounds/confirmation.py:_on_payout_confirmed` ·
|
||
*Test:* `tests/unit/test_confirmation.py`
|
||
|
||
## Motore e sessioni
|
||
|
||
- **[DAT-50]** C'è un solo motore asincrono per processo, creato
|
||
all'importazione di `app/db/base.py` da `settings.database_url`
|
||
(`DATABASE_URL`; predefinito `sqlite+aiosqlite:///./plm_lottery.db`, e nel
|
||
container `sqlite+aiosqlite:////app/db_data/plm_lottery.db`, montato da
|
||
`./data/db`). Le sessioni si creano con `AsyncSessionLocal`
|
||
(`async_sessionmaker` con `expire_on_commit=False`): gli oggetti letti restano
|
||
utilizzabili dopo il commit.
|
||
*Codice:* `app/db/base.py` · `app/config.py`
|
||
|
||
- **[DAT-51]** Gli endpoint HTTP ricevono una sessione per richiesta dalla
|
||
dipendenza `get_session`. I task in background ricevono `AsyncSessionLocal`
|
||
come factory e aprono sessioni brevi, chiuse prima di ogni chiamata di rete.
|
||
Il commit è sempre esplicito: `get_session` non ne fa alla fine della
|
||
richiesta.
|
||
*Codice:* `app/db/session.py:get_session` · `app/main.py` ·
|
||
*Test:* `tests/unit/test_broadcast.py`
|
||
|
||
- **[DAT-52]** Su SQLite ogni nuova connessione esegue
|
||
`PRAGMA journal_mode=WAL`, `PRAGMA synchronous=FULL` e
|
||
`PRAGMA busy_timeout=5000` (B-39): lettori e scrittori non si bloccano a
|
||
vicenda, un secondo scrittore aspetta fino a 5 s invece di fallire subito, e
|
||
ogni commit è sul disco prima che il commit ritorni, così un'interruzione di
|
||
corrente non perde un commit già confermato al chiamante (una
|
||
registrazione, e con essa il suo `derivation_index`, B-88). Con un altro
|
||
dialetto non viene eseguito nulla. `PRAGMA foreign_keys` non è tra questi,
|
||
da cui [DAT-04].
|
||
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
|
||
*Test:* `tests/unit/test_db_base.py` · `tests/unit/test_sqlite_transactions.py`
|
||
|
||
- **[DAT-74]** Ogni transazione del database che sposta denaro o ne fissa lo
|
||
stato inizia con `BEGIN IMMEDIATE`, che prende il lock di scrittura di SQLite
|
||
**prima** della prima lettura: le letture su cui la transazione decide
|
||
(saldo, UTXO liberi, partecipanti, stato del round o della riga
|
||
`pending_transactions`) e le scritture che ne seguono vedono lo stesso
|
||
stato, senza che un altro scrittore si inserisca fra le due. Riguarda almeno
|
||
le fasi di scrittura di `place_bet`, `request_withdrawal`, `_trigger_payout`,
|
||
`bump_fee`, l'accredito dei depositi (accredito, marcatura come speso
|
||
all'esterno, reintegro: quest'ultimo solo quando c'è qualcosa da reintegrare,
|
||
perché gira a ogni aggiornamento), le transizioni di stato dello scheduler,
|
||
i gestori di conferma, il riconciliatore e l'allocazione dell'indice di
|
||
derivazione alla registrazione (senza il lock due registrazioni simultanee
|
||
leggevano lo stesso massimo). Si ottiene con `begin_immediate(session)`, che
|
||
chiude l'eventuale transazione di sola lettura già aperta (di solito la
|
||
ricerca dell'utente autenticato) e apre quella nuova con `BEGIN IMMEDIATE`;
|
||
le altre transazioni, in sola lettura, restano `BEGIN` semplice e in WAL non
|
||
aspettano nessuno. Un `OperationalError`
|
||
`database is locked` è il `busy_timeout` di [DAT-52] scaduto: prima di un
|
||
broadcast la transazione viene annullata e l'operazione rifiutata o
|
||
rimandata al giro successivo; **dopo** un broadcast non si annulla nulla
|
||
sulla rete, la riga resta nello stato scritto prima del broadcast
|
||
(`building`) e il riconciliatore la risolve
|
||
([transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) B-88).
|
||
*Codice:* `app/db/base.py:begin_immediate` · `app/db/base.py:_register_sqlite_pragmas` ·
|
||
`app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/broadcast.py:bump_fee` ·
|
||
`app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` ·
|
||
`app/deposits/service.py:credit_confirmed_utxos` · `app/auth/routes.py:register` ·
|
||
*Test:* `tests/unit/test_sqlite_transactions.py`
|
||
|
||
## Migrazioni
|
||
|
||
### Politica
|
||
|
||
- **[DAT-53]** Lo schema si cambia solo con una revisione Alembic in
|
||
`migrations/versions/`. Si modifica `app/db/models.py`, poi si genera la
|
||
revisione con `alembic revision --autogenerate -m "..."` e la si **rilegge e
|
||
corregge a mano** prima del commit. La catena è lineare, senza rami.
|
||
`migrations/env.py` prende l'URL da `settings.database_url` e i metadati da
|
||
`Base.metadata`, e non abilita `render_as_batch`: le modifiche a colonne
|
||
esistenti su SQLite vanno scritte a mano dentro `op.batch_alter_table`.
|
||
*Codice:* `migrations/env.py` · `app/db/models.py`
|
||
|
||
- **[DAT-54]** Le migrazioni si applicano **solo** all'avvio del container: il
|
||
comando del `Dockerfile` controlla che esista la master key, esegue
|
||
`alembic upgrade head` e solo se riesce avvia `uvicorn`. Una migrazione che
|
||
fallisce impedisce quindi al server di partire invece di lasciarlo girare su
|
||
uno schema a metà. `alembic upgrade head` non va mai lanciato a mano.
|
||
*Codice:* `Dockerfile`
|
||
|
||
- **[DAT-55]** Una colonna `NOT NULL` aggiunta a una tabella che può avere già
|
||
righe deve dare un valore a quelle righe nella migrazione stessa, in uno di
|
||
due modi: un `server_default` temporaneo, rimosso subito dopo in modo che le
|
||
righe nuove prendano il default dell'ORM (`round_config`, `users.token_version`);
|
||
oppure un backfill esplicito calcolato dai dati (`last_broadcast_at` copiato da
|
||
`broadcast_at`; `rounds.duration_seconds`/`cooldown_seconds` copiati da
|
||
`round_config`, così il round in corso non cambia durata con la migrazione).
|
||
*Codice:* `migrations/versions/53cc70d16e63_add_operational_params_to_round_config.py` ·
|
||
`migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py` ·
|
||
`migrations/versions/283844a44b4a_snapshot_round_timing_onto_the_round_.py`
|
||
|
||
- **[DAT-56]** Gli indici su espressioni (`ix_rounds_single_active`,
|
||
`ix_users_username_lower`) vanno scritti a mano nella migrazione:
|
||
l'autogenerate su SQLite non li sa confrontare e li ignora con un avviso.
|
||
Inoltre `op.batch_alter_table` su SQLite ricrea la tabella e **perde senza
|
||
errori** gli indici su espressioni, perché SQLAlchemy non li legge dallo
|
||
schema. Una migrazione che usi `batch_alter_table` su `rounds` o `users`
|
||
deve quindi, nella stessa `upgrade()`, eliminare e ricreare esplicitamente
|
||
l'indice relativo con SQL grezzo, dopo il blocco `batch_alter_table`. Le
|
||
migrazioni di B-73/B-81/B-95 (e quella di B-80, già scritta) aggiungono colonne a `rounds`: se
|
||
aggiungono solo colonne con `op.add_column` (che su SQLite non ricrea la
|
||
tabella) l'indice resta; se usano `batch_alter_table`, devono ricrearlo. Il
|
||
test di [DAT-75] è ciò che rende questa regola verificabile.
|
||
*Codice:* `migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py` ·
|
||
`migrations/versions/c1d4a97b5e10_case_insensitive_usernames.py`
|
||
|
||
- **[DAT-75]** Un test esegue `alembic upgrade head` su un database SQLite
|
||
vuoto e verifica che, alla fine della catena, esistano
|
||
`ix_rounds_single_active` e `ix_users_username_lower` con la loro
|
||
definizione (lette da `sqlite_master`), e che un secondo round attivo e un
|
||
secondo username uguale a meno delle maiuscole vengano rifiutati con
|
||
`IntegrityError`. Il test gira con la suite unitaria, quindi una migrazione
|
||
che perde uno dei due indici non entra nel repository.
|
||
*Codice:* `migrations/env.py` ·
|
||
*Test:* `tests/unit/test_migrations.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`
|
||
|
||
I test unitari creano lo schema direttamente dal modello
|
||
(`Base.metadata.create_all`), quindi verificano il modello, non la catena di
|
||
revisioni; [DAT-75] aggiunge il test che esegue la catena su un database vuoto.
|
||
|
||
### 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 |
|
||
| `477bddae1a84` | `rounds.pool_address` (B-80, [DAT-60]), backfill con l'indirizzo di riserva per i round esistenti — **head** |
|
||
|
||
Revisioni attese, non ancora scritte (ciascuna nel commit della sua voce,
|
||
con i backfill di [DAT-55]): `rounds` (`bet_amount_sats`,
|
||
`fee_address`, `draw_confirmations`, `server_seed`, `server_seed_hash`,
|
||
`participants_hash`, `draw_height`, `payout_blocked_at`),
|
||
`round_participants` (`bet_vout`, `draw_position` e il vincolo unico),
|
||
`round_config` (`deposit_confirmations`, `draw_confirmations`,
|
||
`max_round_pool_sats`, `paused_reason`, con `paused_reason = 'operator'` per
|
||
un'istanza già in pausa), `pending_transactions.failed_at` (backfill da
|
||
`updated_at` per le righe `failed`), e la tabella `pending_transaction_txids`
|
||
(backfill: una riga per `current_txid` e una per `replaced_by_txid` non nullo
|
||
di ogni riga esistente; per la seconda il `raw_hex` non è più disponibile e si
|
||
scrive la stringa vuota).
|
||
|
||
### Differenze note tra modello e schema migrato
|
||
|
||
- `users.token_version`: il modello dichiara `server_default="0"`, ma la
|
||
migrazione `943dbd74d983` rimuove il default del server dopo il backfill. Un
|
||
database migrato non ha quindi un default a livello SQL. Non ha effetti
|
||
pratici, perché ogni inserimento passa dall'ORM che fornisce `0`, e
|
||
l'autogenerate non confronta i default del server, quindi non lo segnala.
|
||
- `rounds.duration_seconds`/`cooldown_seconds`: `server_default` presente sia
|
||
nel modello sia nel database migrato.
|
||
|
||
## Scelte specifiche di SQLite
|
||
|
||
- **Un solo file, un solo processo.** SQLite con WAL regge i sei task in
|
||
background e le richieste HTTP di un singolo processo uvicorn ([DAT-52]). I
|
||
lock per utente e il canale SSE sono comunque in memoria, quindi più
|
||
processi non sono supportati in nessun caso (vedi
|
||
[limiti-noti.md](limiti-noti.md)).
|
||
- **Serializzazione degli scrittori.** SQLite ha un solo scrittore alla volta
|
||
per file; un secondo scrittore aspetta il lock fino al `busy_timeout`
|
||
([DAT-52]) e poi riceve `OperationalError: database is locked`. Non esistono
|
||
conflitti fra snapshot: un `OperationalError` è sempre un'attesa scaduta.
|
||
Le transazioni che spostano denaro prendono il lock con `BEGIN IMMEDIATE`
|
||
prima di leggere ([DAT-74]), quindi il compare-and-set di [DAT-16] e le
|
||
letture che lo precedono stanno nella stessa transazione. Il driver
|
||
(pysqlite) non apre più le transazioni da solo (`isolation_level = None`):
|
||
è l'evento `begin` del motore a emettere `BEGIN` o `BEGIN IMMEDIATE`, così
|
||
ogni lettura sta dentro una transazione vera (B-88).
|
||
- **Indice parziale su espressione.** `ix_rounds_single_active` usa sia un'espressione
|
||
(`(1)`) sia una clausola `WHERE`; SQLite e PostgreSQL supportano entrambe, e il
|
||
modello dichiara la clausola per tutti e due (`sqlite_where`,
|
||
`postgresql_where`). La migrazione la crea invece con SQL grezzo
|
||
(`CREATE UNIQUE INDEX ... ON rounds ((1)) WHERE ...`).
|
||
- **Timestamp senza fuso.** SQLite non conserva il fuso orario ([DAT-02]).
|
||
- **Chiavi esterne non applicate** ([DAT-04]).
|
||
- **`Text` invece di `VARCHAR` senza lunghezza** per i contenuti di lunghezza
|
||
arbitraria (`raw_tx_hex`, `payload_json`, `description`), così lo schema resta
|
||
portabile ad altri database (B-47).
|
||
- **Portabilità.** I pragma sono limitati al dialetto `sqlite` e l'indice del
|
||
round attivo è dichiarato anche per PostgreSQL. Un `DATABASE_URL` PostgreSQL
|
||
non è però mai stato provato, e il database supportato resta SQLite.
|