place_bet and request_withdrawal released everything on any exception from the broadcast, including the 15s timeout and a connection dropped after the write. The tx may have propagated: a bet's sats landed in the pool with no participant, a withdrawal left while its row said failed and its coins were spendable again. - client: ElectrumRpcError (subclass of ElectrumError) is raised only for a reply carrying a JSON-RPC `error`; timeouts, drops and write errors stay plain ElectrumError. - tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused / uncertain. A refusal is verified first: ElectrumListener.transaction_known asks blockchain.transaction.get of the active server and every other one (10s each) — any server returning the tx means accepted; the active server plus MIN_QUORUM_RESPONSES others all denying it means refused (ALLOW_NO_QUORUM keeps the reduced rule); too few answers is uncertain. "already known" wording is only a shortcut that skips the check. With no network to ask, a refusal is uncertain. - place_bet / request_withdrawal / _trigger_payout use it: only a verified refusal releases; uncertain leaves the rows building for the reconciler, publishes SSE and POST /bets and POST /withdrawals answer 202 with status building; "already known" promotes a payout (payout_sent) like any accepted broadcast. - An accepted transaction is fanned out to every other server (ElectrumListener.fan_out_transaction, best effort, off the caller's path). The RBF replacement in bump_fee is left for B-77, which rewrites that function. Tests changed because the spec changed: the existing "refused broadcast" tests raised a bare RuntimeError (now an uncertain outcome) and are made explicit refusals (rpc_error) with a network that denies the tx; direct route calls pass a Response; FakePayoutListener gains the network surface. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1560 lines
96 KiB
Markdown
1560 lines
96 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 nello schema atteso (nove oggi: manca
|
||
`pending_transaction_txids`, B-77). `round_config` non ha relazioni: è una
|
||
riga singola di parametri. Gli indirizzi della piattaforma che non
|
||
appartengono a un utente sono derivati dalla master key, e i loro UTXO si
|
||
leggono dalla rete, non da una tabella:
|
||
|
||
| Indirizzo | Percorso | Dove è registrato |
|
||
|---|---|---|
|
||
| Utente | `m/84'/746'/0'/0/<derivation_index>` | `users.address` |
|
||
| Riserva del pool (il "pool" unico di oggi) | `m/84'/746'/0'/1/0` | nessuna tabella; `app/wallet/hd.py:derive_pool_key` |
|
||
| Montepremi di un round | `m/84'/746'/0'/2/<round_id>` | `rounds.pool_address` ([DAT-60]) |
|
||
|
||
Il diagramma descrive lo schema **atteso**; le colonne e la tabella marcate
|
||
"da implementare" non esistono ancora (vedi le tabelle di dettaglio).
|
||
|
||
```mermaid
|
||
erDiagram
|
||
users ||--o{ utxo_events : "possiede"
|
||
users ||--o{ round_participants : "scommette"
|
||
rounds ||--o{ round_participants : "raccoglie"
|
||
users |o--o{ rounds : "vince (winner_user_id)"
|
||
users ||--o{ withdrawals : "chiede"
|
||
rounds |o--o{ pending_transactions : "bet / payout"
|
||
withdrawals |o--o{ pending_transactions : "withdrawal"
|
||
users |o--o{ pending_transactions : "bet / withdrawal"
|
||
pending_transactions ||--|{ pending_transaction_txids : "ogni versione (da implementare)"
|
||
users |o--o{ bug_reports : "segnala"
|
||
users |o--o{ audit_log : "riguarda"
|
||
rounds |o--o{ audit_log : "riguarda"
|
||
|
||
users {
|
||
int id PK
|
||
string username UK "unico anche su lower(username)"
|
||
string password_hash "Argon2"
|
||
int derivation_index UK
|
||
string address UK "P2WPKH plm1..."
|
||
bigint cached_balance_sats "cache di utxo_events"
|
||
int token_version
|
||
datetime created_at
|
||
}
|
||
utxo_events {
|
||
int id PK
|
||
int user_id FK
|
||
string txid "UK con vout"
|
||
int vout
|
||
bigint amount_sats
|
||
int confirmed_height
|
||
datetime confirmed_at
|
||
string spent_txid "NULL, txid o external-spend"
|
||
}
|
||
rounds {
|
||
int id PK
|
||
string status "open closing drawing paying_out closed"
|
||
datetime opened_at
|
||
datetime closed_at
|
||
int duration_seconds "snapshot"
|
||
int cooldown_seconds "snapshot"
|
||
bigint bet_amount_sats "snapshot (da implementare)"
|
||
string fee_address "snapshot (da implementare)"
|
||
int draw_confirmations "snapshot (da implementare)"
|
||
string pool_address
|
||
string server_seed_enc "cifrato fino alla rivelazione (da implementare)"
|
||
string server_seed "in chiaro solo dopo la rivelazione (da implementare)"
|
||
string server_seed_hash "da implementare"
|
||
string participants_hash "da implementare"
|
||
int draw_height "da implementare"
|
||
datetime drawing_started_at
|
||
int draw_block_height
|
||
string draw_block_hash
|
||
string seed_int
|
||
int winner_user_id FK
|
||
bigint pool_amount_sats
|
||
bigint winner_amount_sats
|
||
bigint fee_amount_sats
|
||
bigint bump_reserve_sats "da implementare"
|
||
string payout_txid
|
||
datetime payout_blocked_at "da implementare"
|
||
}
|
||
round_participants {
|
||
int id PK
|
||
int round_id FK "UK con user_id"
|
||
int user_id FK
|
||
bigint bet_amount_sats
|
||
string bet_txid
|
||
int bet_vout "da implementare"
|
||
datetime broadcast_at
|
||
datetime confirmed_at
|
||
int draw_position "UK con round_id (da implementare)"
|
||
string status "building broadcast confirmed reorged_out"
|
||
}
|
||
round_config {
|
||
int id PK
|
||
string fee_address
|
||
bigint bet_amount_sats
|
||
int round_duration_seconds
|
||
int round_cooldown_seconds
|
||
int draw_animation_seconds
|
||
int fee_rate_sat_vb
|
||
int rbf_timeout_seconds
|
||
int deposit_confirmations "da implementare"
|
||
int draw_confirmations "da implementare"
|
||
bigint max_round_pool_sats "da implementare"
|
||
bool paused
|
||
string paused_reason "da implementare"
|
||
datetime updated_at
|
||
}
|
||
pending_transactions {
|
||
int id PK
|
||
string kind "bet payout withdrawal"
|
||
int round_id FK
|
||
int withdrawal_id FK
|
||
int user_id FK
|
||
string current_txid
|
||
int fee_rate_sat_vb
|
||
text raw_tx_hex
|
||
datetime broadcast_at
|
||
datetime last_broadcast_at
|
||
string status "building pending confirmed failed"
|
||
string replaced_by_txid
|
||
int attempt_count
|
||
string failure_reason
|
||
datetime failed_at "da implementare"
|
||
datetime updated_at
|
||
}
|
||
pending_transaction_txids {
|
||
int id PK
|
||
int pending_transaction_id FK
|
||
string txid UK
|
||
text raw_hex
|
||
datetime created_at
|
||
}
|
||
withdrawals {
|
||
int id PK
|
||
int user_id FK
|
||
string external_address
|
||
bigint amount_requested_sats
|
||
bigint amount_sent_sats
|
||
string txid
|
||
string status "building broadcast confirmed failed"
|
||
datetime created_at
|
||
datetime confirmed_at
|
||
}
|
||
bug_reports {
|
||
int id PK
|
||
text description
|
||
string contact
|
||
int user_id FK
|
||
string status "open read resolved"
|
||
datetime created_at
|
||
}
|
||
audit_log {
|
||
int id PK
|
||
string event_type
|
||
text payload_json
|
||
int user_id FK
|
||
int round_id FK
|
||
datetime created_at
|
||
}
|
||
```
|
||
|
||
## Convenzioni comuni
|
||
|
||
- **[DAT-01]** Ogni importo è un numero intero di satoshi (1 PLM =
|
||
100 000 000 sat), in una colonna `BigInteger` il cui nome finisce in `_sats`.
|
||
Nessun importo passa mai per un `float`: anche il valore degli input letti
|
||
dalla rete per un RBF è preso come intero dalla transazione grezza (B-40).
|
||
*Codice:* `app/db/models.py` · `app/tx/broadcast.py:_prevout_amount` ·
|
||
*Test:* `tests/unit/test_broadcast.py`
|
||
|
||
- **[DAT-02]** Ogni timestamp è in UTC. Si scrive con `app/db/models.py:utcnow`
|
||
(default di colonna) o con `datetime.now(timezone.utc)`; le colonne sono
|
||
`DateTime` senza fuso, e SQLite restituisce valori *naive*. Chi legge un
|
||
timestamp per confrontarlo con l'ora corrente lo marca UTC con
|
||
`.replace(tzinfo=timezone.utc)`, e ogni risposta API lo serializza con
|
||
`isoformat_utc`, che aggiunge l'offset (B-35).
|
||
*Codice:* `app/db/models.py:utcnow` · `app/api/timeutil.py:isoformat_utc` ·
|
||
`app/rounds/service.py:round_deadline` ·
|
||
*Test:* `tests/unit/test_api_timeutil.py`, `tests/unit/test_scheduler.py`
|
||
|
||
- **[DAT-03]** I campi di stato (`status`, `kind`) sono stringhe libere
|
||
(`String(16)`), senza `CHECK` né enum nel database. I valori ammessi sono solo
|
||
quelli elencati in questo documento, e a garantirlo è esclusivamente il codice
|
||
che li scrive; l'unico vincolo del database che dipende da un valore di stato
|
||
è l'indice `ix_rounds_single_active` ([DAT-17]).
|
||
*Codice:* `app/db/models.py`
|
||
|
||
- **[DAT-04]** Le chiavi esterne sono dichiarate ma, su SQLite, **non
|
||
applicate**: `PRAGMA foreign_keys` non viene attivato
|
||
([DAT-52]). Nessuna chiave esterna ha `ON DELETE`. Le sole cancellazioni
|
||
fisiche nel codice sono quelle di una scommessa fallita (la riga
|
||
`round_participants` e la sua `pending_transactions`, con le sue versioni in
|
||
`pending_transaction_txids` nello schema atteso,
|
||
[DAT-24], [DAT-38], [DAT-70]); nessun'altra tabella perde righe, quindi i riferimenti
|
||
restano validi per costruzione, non per vincolo.
|
||
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
|
||
`app/bets/service.py:_release_failed_bet` · `app/tx/reconcile.py:_abandon`
|
||
|
||
- **[DAT-05]** Ogni chiave primaria è un intero autoincrementale `id`, assegnato
|
||
dal database al `flush`/`commit`. Nessuna tabella usa chiavi naturali come
|
||
chiave primaria; le chiavi naturali (`txid`+`vout`, `round_id`+`user_id`,
|
||
`username`) sono vincoli di unicità separati.
|
||
*Codice:* `app/db/models.py`
|
||
|
||
## Tabelle
|
||
|
||
### `users`
|
||
|
||
Un account. Nasce alla registrazione e non viene mai cancellato.
|
||
|
||
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | Identificativo; è il `sub` del JWT | database |
|
||
| `username` | String(64) | no | — | Nome scelto dall'utente, conservato **così come digitato** | `app/auth/routes.py:register` |
|
||
| `password_hash` | String(256) | no | — | Hash Argon2 della password | `register`; `app/api/routes/users.py:change_password`; `app/api/routes/admin.py:reset_user_password` |
|
||
| `derivation_index` | Integer, unico | no | — | Indice BIP84 dell'indirizzo: `m/84'/746'/0'/0/<index>` | `register` |
|
||
| `address` | String(128), unico | no | — | Indirizzo P2WPKH (`plm1…`) derivato da `derivation_index`; deposito, vincite e resto | `register` |
|
||
| `cached_balance_sats` | BigInteger | no | `0` | Saldo confermato e spendibile, cache di `utxo_events` | solo `app/wallet/balance.py:recompute_balance` |
|
||
| `token_version` | Integer | no | `0` (ORM e `server_default` nel modello) | Versione dei token di sessione; il JWT la porta nel claim `tv` | `change_password`, `reset_user_password` |
|
||
| `created_at` | DateTime | no | `utcnow` | Istante della registrazione | database/ORM |
|
||
|
||
- **[DAT-06]** Lo username è unico **senza distinzione tra maiuscole e
|
||
minuscole** (B-57): lo garantisce l'indice unico funzionale
|
||
`ix_users_username_lower` su `lower(username)`, e registrazione e login
|
||
confrontano con `func.lower(...)`. Il valore è comunque memorizzato così come
|
||
l'utente l'ha scritto, ed è quello mostrato in `/admin` e nel registro di
|
||
audit. Poiché lo username ammesso è solo ASCII (`^[A-Za-z0-9_.-]+$`, 3–32
|
||
caratteri, validato dall'API), `lower()` è l'intera normalizzazione. Resta
|
||
anche l'indice unico `ix_users_username` sulla colonna esatta, ridondante
|
||
rispetto al primo ma innocuo.
|
||
*Codice:* `app/db/models.py:User` · `app/auth/routes.py:register` ·
|
||
`app/auth/routes.py:login` · *Test:* `tests/unit/test_auth.py`
|
||
|
||
- **[DAT-07]** `derivation_index` è assegnato alla registrazione come
|
||
`max(derivation_index) + 1` (0 per il primo utente). Se il commit fallisce per
|
||
una collisione sull'indice (due registrazioni concorrenti) si ricalcola e si
|
||
riprova, fino a 5 volte; una collisione sullo username invece non si riprova e
|
||
risponde `username_taken`. `derivation_index` e `address` non vengono **mai**
|
||
modificati dopo l'inserimento: l'indirizzo è permanente.
|
||
*Codice:* `app/auth/routes.py:register` · `app/wallet/hd.py:derive_user_address` ·
|
||
*Test:* `tests/unit/test_auth.py`
|
||
|
||
- **[DAT-08]** `cached_balance_sats` è uguale, in ogni momento osservabile, alla
|
||
somma di `amount_sats` delle righe `utxo_events` dell'utente con
|
||
`spent_txid IS NULL`. La fonte di verità è `utxo_events`; la colonna è
|
||
scritta solo da `recompute_balance`, che va chiamata **nella stessa
|
||
transazione** di qualunque modifica alle righe `utxo_events` dell'utente (un
|
||
inserimento o un cambio di `spent_txid` da o verso `NULL`). I chiamanti sono:
|
||
`credit_confirmed_utxos`, `reinstate_reappeared_utxos`,
|
||
`mark_utxos_spent_externally`, `place_bet`, `_release_failed_bet`,
|
||
`request_withdrawal`, `_release_failed_withdrawal` e il `_abandon` del
|
||
riconciliatore. Il retarget di `bump_fee` ([DAT-14]) non la ricalcola perché
|
||
sostituisce un txid con un altro e non cambia la somma.
|
||
Il saldo "in attesa" mostrato all'utente non è una colonna: è calcolato al
|
||
volo (vedi [registrazione-e-depositi.md](registrazione-e-depositi.md)).
|
||
*Codice:* `app/wallet/balance.py:recompute_balance` ·
|
||
`app/deposits/service.py:credit_confirmed_utxos` ·
|
||
`app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` ·
|
||
`app/tx/reconcile.py:_abandon` ·
|
||
*Test:* `tests/unit/test_balance.py`, `tests/unit/test_deposits.py`
|
||
|
||
- **[DAT-09]** `token_version` parte da 0 e aumenta di 1 a ogni cambio password
|
||
(self-service) e a ogni reset da parte dell'amministratore; nient'altro la
|
||
modifica. Un token il cui claim `tv` non coincide con il valore corrente è
|
||
rifiutato (B-34). Un token senza claim `tv` vale come `tv = 0`.
|
||
*Codice:* `app/api/routes/users.py:change_password` ·
|
||
`app/api/routes/admin.py:reset_user_password` ·
|
||
`app/auth/dependencies.py:get_current_user` ·
|
||
`app/auth/security.py:decode_access_token` ·
|
||
*Test:* `tests/unit/test_security.py`, `tests/unit/test_admin.py`
|
||
|
||
### `utxo_events`
|
||
|
||
Un output **confermato** che paga l'indirizzo di un utente: un deposito, il
|
||
resto di una sua scommessa o di un suo prelievo, una vincita. Dal punto di vista
|
||
del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
|
||
(l'accredito dei depositi, vedi
|
||
[registrazione-e-depositi.md](registrazione-e-depositi.md)).
|
||
|
||
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | — | database |
|
||
| `user_id` | FK `users.id`, indicizzata | no | — | Proprietario dell'indirizzo pagato | `app/deposits/service.py:credit_confirmed_utxos` |
|
||
| `txid` | String(64) | no | — | Transazione che ha creato l'output | `credit_confirmed_utxos` |
|
||
| `vout` | Integer | no | — | Posizione dell'output | `credit_confirmed_utxos` |
|
||
| `amount_sats` | BigInteger | no | — | Valore dell'output (`value` di `listunspent`) | `credit_confirmed_utxos` |
|
||
| `confirmed_height` | Integer | no | — | Altezza del blocco riportata dal server (sempre > 0) | `credit_confirmed_utxos` |
|
||
| `confirmed_at` | DateTime | no | `utcnow` | Istante dell'**accredito** nel database, non l'orario del blocco | ORM |
|
||
| `spent_txid` | String(64) | sì | `NULL` | Chi ha speso l'output, vedi [DAT-12] | vedi tabella sotto |
|
||
|
||
- **[DAT-10]** Una riga si inserisce solo per un output che non è già
|
||
presente, che ha raggiunto le conferme richieste per l'accredito e che gli
|
||
altri server Electrum hanno corroborato con lo stesso importo (B-59; la
|
||
corroborazione è in [connessione-electrum.md](connessione-electrum.md)).
|
||
Le conferme richieste e la soglia sotto cui un output non si accredita
|
||
(`DUST_LIMIT_SATS`) sono regole del deposito, definite in
|
||
[registrazione-e-depositi.md](registrazione-e-depositi.md) (B-82): lo
|
||
schema non le ripete. Il numero di conferme configurabile è la colonna
|
||
`round_config.deposit_confirmations` ([DAT-62]). L'inserimento è
|
||
idempotente su (`txid`, `vout`), che è unico su tutta la tabella, non solo
|
||
per utente. Le righe non si cancellano mai e, dopo l'inserimento, solo
|
||
`spent_txid` cambia.
|
||
*Codice:* `app/deposits/service.py:find_new_credit_candidates` ·
|
||
`app/deposits/service.py:credit_confirmed_utxos` ·
|
||
`app/electrum/listener.py:ElectrumListener.refresh_user` ·
|
||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`
|
||
*Stato:* da implementare (B-82) · *Oggi:* si accredita ogni output
|
||
con `height > 0` (1 conferma), coinbase comprese.
|
||
|
||
- **[DAT-11]** Gli UTXO dei montepremi e della riserva **non** sono in
|
||
`utxo_events`: non appartengono a un utente. Il pagamento di un round spende
|
||
solo gli outpoint delle scommesse di quel round (`bet_txid`, `bet_vout` dei
|
||
partecipanti `confirmed`, [DAT-63]) all'indirizzo `rounds.pool_address`,
|
||
dopo averli verificati sulla rete; qualunque altro output presente allo
|
||
stesso indirizzo non viene speso. Resta l'esclusione degli outpoint già
|
||
impegnati da un pagamento non ancora risolto (letti dal `raw_tx_hex` delle
|
||
righe `pending_transactions` di tipo `payout` in `building`/`pending`). Per
|
||
lo stesso motivo l'abbandono di un pagamento non ha input da liberare.
|
||
*Codice:* `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`
|
||
*Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie fra tutti gli
|
||
UTXO confermati dell'indirizzo del round (per un round vecchio, del
|
||
condiviso), lette dal solo server attivo, e non solo fra le uscite delle
|
||
scommesse dei partecipanti.
|
||
|
||
- **[DAT-12]** `spent_txid` ha tre significati:
|
||
- `NULL`: l'output è spendibile e conta nel saldo;
|
||
- un txid: l'output è impegnato da una transazione della piattaforma
|
||
(scommessa o prelievo), dal momento della sua costruzione, prima ancora
|
||
della trasmissione; resta impostato per sempre una volta confermata;
|
||
- la stringa `external-spend`: l'output è sparito dalla `listunspent` e gli
|
||
altri server hanno confermato che è stato speso fuori dalla piattaforma
|
||
(B-29).
|
||
|
||
Le transizioni ammesse sono queste, e nessun'altra funzione scrive la colonna:
|
||
|
||
| Da | A | Funzione | Quando |
|
||
|---|---|---|---|
|
||
| `NULL` | txid | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal` | fase 1, prima del broadcast |
|
||
| txid | nuovo txid | `app/tx/broadcast.py:_retarget_txid_references` | bump RBF, e conferma di una versione precedente della stessa transazione ([DAT-14]) |
|
||
| txid | `NULL` | `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal` | la rete ha rifiutato **esplicitamente** il broadcast |
|
||
| txid | `NULL` | `app/tx/reconcile.py:_release_inputs` | la transazione è stata abbandonata con una prova ([DAT-13]) |
|
||
| `NULL` | `external-spend` | `app/deposits/service.py:mark_utxos_spent_externally` | spesa esterna corroborata |
|
||
| `external-spend` | `NULL` | `app/deposits/service.py:reinstate_reappeared_utxos` | l'outpoint ricompare come non speso, corroborato dal quorum |
|
||
|
||
Chi sceglie gli input di una nuova transazione considera solo le righe con
|
||
`spent_txid IS NULL`.
|
||
*Codice:* le funzioni in tabella ·
|
||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_bets.py`,
|
||
`tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py`
|
||
*Stato:* da implementare (B-78, B-83) · *Oggi:* l'abbandono avviene sul
|
||
silenzio del solo server attivo, il reintegro sulla parola del solo server
|
||
attivo.
|
||
|
||
- **[DAT-13]** Il riconciliatore libera un input solo se il suo `spent_txid` è
|
||
uno dei txid della riga che sta abbandonando (`current_txid` o una sua
|
||
versione in `pending_transaction_txids`, [DAT-70]): se nel frattempo lo
|
||
stesso output risulta impegnato da un'altra transazione, quella
|
||
rivendicazione è quella valida e non va toccata.
|
||
*Codice:* `app/tx/reconcile.py:_release_inputs` ·
|
||
*Test:* `tests/unit/test_reconcile.py`
|
||
*Stato:* da implementare (B-77), per le versioni precedenti · *Oggi:* il
|
||
confronto è solo con `current_txid`.
|
||
|
||
- **[DAT-14]** Per ogni output impegnato, `spent_txid` è sempre il txid
|
||
**corrente** della transazione che lo spende, cioè il `current_txid` della sua
|
||
riga `pending_transactions`. Un bump RBF cambia il txid e quindi riscrive,
|
||
nella stessa transazione del database, ogni `spent_txid` uguale al vecchio
|
||
txid. Quando conferma una versione **diversa** da quella corrente (una
|
||
versione precedente registrata in `pending_transaction_txids`, [DAT-70]),
|
||
`current_txid`, `raw_tx_hex`, lo `spent_txid` degli input e il txid della
|
||
riga di dominio vengono riportati a quella versione, nella stessa
|
||
transazione del database che segna la conferma. Senza questo allineamento
|
||
[DAT-13] non riconoscerebbe più gli input di una transazione rimpiazzata.
|
||
*Codice:* `app/tx/broadcast.py:bump_fee` ·
|
||
`app/tx/broadcast.py:_retarget_txid_references` ·
|
||
`app/tx/confirmation.py:poll_once` ·
|
||
*Test:* `tests/unit/test_broadcast.py`
|
||
*Stato:* da implementare (B-77) · *Oggi:* solo il bump riallinea; la
|
||
conferma di una versione precedente non viene riconosciuta.
|
||
|
||
### `rounds`
|
||
|
||
Un round della lotteria, dall'apertura al pagamento confermato. I round chiusi
|
||
restano nella tabella come storico.
|
||
|
||
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | — | database |
|
||
| `status` | String(16) | no | `"open"` | Fase del round: `open`, `closing`, `drawing`, `paying_out`, `closed` ([RND-02](ciclo-di-vita-del-round.md)) | vedi [RND-02](ciclo-di-vita-del-round.md) |
|
||
| `opened_at` | DateTime | no | `utcnow` | Apertura; base della scadenza | `app/rounds/service.py:open_new_round_if_needed` (default) |
|
||
| `closed_at` | DateTime | sì | `NULL` | Chiusura delle scommesse, poi conferma del pagamento ([RND-28](ciclo-di-vita-del-round.md)) | `app/rounds/scheduler.py:RoundScheduler._tick`; `app/rounds/confirmation.py:_on_payout_confirmed` |
|
||
| `duration_seconds` | Integer | no | `600` (anche `server_default`) | Durata di **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` |
|
||
| `cooldown_seconds` | Integer | no | `30` (anche `server_default`) | Pausa dopo **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` |
|
||
| `bet_amount_sats` | BigInteger | no | backfill da `round_config` nella migrazione | Costo della scommessa di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` |
|
||
| `fee_address` | String(128) | no | backfill da `round_config` nella migrazione | Destinatario della commissione di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` |
|
||
| `draw_confirmations` | Integer | no | `3` (`server_default`) | Conferme richieste sul blocco di estrazione di **questo** round, copiate all'apertura ([DAT-61]) — *da implementare (B-73, B-95)* | `open_new_round_if_needed` |
|
||
| `pool_address` | String(128) | no | `""` 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]) — *da implementare (B-73)* | `open_new_round_if_needed` |
|
||
| `server_seed` | String(64) | sì | `NULL` | Il segreto in chiaro (32 byte in esadecimale), scritto **solo** alla rivelazione ([DAT-64]) — *da implementare (B-73)* | `RoundScheduler._close_and_draw` |
|
||
| `server_seed_hash` | String(64) | sì | `NULL` | `sha256(server_seed)` in esadecimale, pubblicato dall'apertura ([DAT-64]) — *da implementare (B-73)* | `open_new_round_if_needed` |
|
||
| `participants_hash` | String(64) | sì | `NULL` | Impronta dell'elenco ordinato dei partecipanti ([DAT-65]) — *da implementare (B-73, B-81)* | `RoundScheduler._close_and_draw` |
|
||
| `draw_height` | Integer | sì | `NULL` | Altezza del blocco di estrazione, fissata all'ingresso in `drawing` ([DAT-65]) — *da implementare (B-73, B-81)* | `_close_and_draw` |
|
||
| `drawing_started_at` | DateTime | sì | `NULL` | Istante del passaggio a `drawing` | `RoundScheduler._close_and_draw` |
|
||
| `draw_block_height` | Integer | sì | `NULL` | Altezza del blocco usato per l'estrazione; nello schema atteso è sempre uguale a `draw_height` | `_close_and_draw` |
|
||
| `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_close_and_draw` |
|
||
| `seed_int` | String(128) | sì | `NULL` | Il seme in decimale (fino a 78 cifre, per questo è una stringa). Atteso: il valore intero di `sha256(server_seed ‖ block_hash ‖ participants_hash)`, con la codifica definita in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73); oggi `int(draw_block_hash, 16)` | `_close_and_draw` |
|
||
| `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_close_and_draw` |
|
||
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`; `RoundScheduler._trigger_payout` (atteso, B-82) |
|
||
| `winner_amount_sats` | BigInteger | sì | `NULL` | Quanto riceve il vincitore (70% meno la commissione di rete) | `RoundScheduler._trigger_payout` |
|
||
| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` nello schema atteso | `_trigger_payout` |
|
||
| `bump_reserve_sats` | BigInteger | sì | `NULL` | Output di riserva per l'RBF del pagamento, verso l'indirizzo di riserva ([DAT-73]) — *da implementare (B-79)* | `_trigger_payout`; `app/tx/broadcast.py:bump_fee` |
|
||
| `payout_txid` | String(64) | sì | `NULL` | Txid corrente della transazione di pagamento | `_trigger_payout`; `_retarget_txid_references`; `app/tx/reconcile.py:_abandon` |
|
||
| `payout_blocked_at` | DateTime | sì | `NULL` | Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) — *da implementare (B-82)* | `_trigger_payout`; `app/api/routes/admin.py:resume_lottery` |
|
||
|
||
- ~~**[DAT-15]**~~ Assorbito da
|
||
[RND-02](ciclo-di-vita-del-round.md): i valori di `status` e le transizioni
|
||
sono comportamento del round, e il loro unico proprietario è il documento
|
||
del ciclo di vita. Qui restano i fatti di schema: `status` è una
|
||
`String(16)` senza `CHECK` ([DAT-03]); gli stati `open`, `closing`,
|
||
`drawing`, `paying_out` sono quelli **attivi** coperti dall'indice
|
||
`ix_rounds_single_active` ([DAT-17]); la sola scrittura di `status` fuori
|
||
dal codice applicativo è la migrazione `8a1c4e7b2d90` ([DAT-59]). Il
|
||
trattenimento di un pagamento (B-82) **non** è un nuovo stato ma la colonna
|
||
`payout_blocked_at` ([DAT-68]), così l'elenco degli stati attivi, e con
|
||
esso l'indice su espressione, non cambia.
|
||
|
||
- **[DAT-16]** `place_bet` protegge l'inserimento del partecipante con un
|
||
compare-and-set sulla riga del round, nella stessa transazione:
|
||
`UPDATE rounds SET status = 'open' WHERE id = :id AND status = 'open'`. Se
|
||
l'aggiornamento non tocca esattamente una riga la transazione viene annullata
|
||
e la scommessa rifiutata prima di qualunque broadcast (B-53). L'aggiornamento
|
||
non cambia alcun valore: garantisce che, al commit, il round fosse ancora
|
||
`open` nella stessa transazione che inserisce il partecipante, così il
|
||
passaggio a `closing` e l'inserimento non possono intrecciarsi. La
|
||
transazione è aperta con `BEGIN IMMEDIATE` ([DAT-74]), quindi le letture che
|
||
precedono il compare-and-set (saldo, partecipanti già presenti) vedono lo
|
||
stesso stato su cui si scrive. Un `OperationalError` in questo punto è il
|
||
`busy_timeout` scaduto (`database is locked`), non un conflitto di snapshot:
|
||
la scommessa è rifiutata allo stesso modo, perché nulla è ancora stato
|
||
trasmesso.
|
||
*Codice:* `app/bets/service.py:place_bet` · *Test:* `tests/unit/test_bets.py` ·
|
||
`tests/unit/test_sqlite_transactions.py`
|
||
|
||
- **[DAT-17]** Al massimo **un round attivo** alla volta, come vincolo del
|
||
database: `ix_rounds_single_active` è un indice unico sull'espressione
|
||
costante `(1)`, parziale sugli stati attivi
|
||
(`WHERE status IN ('open', 'closing', 'drawing', 'paying_out')`). La tabella
|
||
può quindi contenere quanti round `closed` si vuole, ma un secondo inserimento
|
||
attivo fallisce con `IntegrityError` (B-09). `open_new_round_if_needed`
|
||
gestisce il conflitto annullando il proprio inserimento e adottando il round
|
||
del concorrente, con al massimo 3 tentativi. L'indice è dichiarato sia con
|
||
`sqlite_where` sia con `postgresql_where`, così resta parziale anche su
|
||
PostgreSQL. `get_active_round` ordina comunque per `id` decrescente e registra
|
||
un errore se ne trova più di uno.
|
||
*Codice:* `app/db/models.py:Round` ·
|
||
`app/rounds/service.py:open_new_round_if_needed` ·
|
||
`app/rounds/service.py:get_active_round` ·
|
||
*Test:* `tests/unit/test_rounds_service.py`
|
||
|
||
- ~~**[DAT-18]**~~ Assorbito da [RND-05](ciclo-di-vita-del-round.md) e
|
||
[RND-04](ciclo-di-vita-del-round.md): la copia della durata e della pausa
|
||
all'apertura, la scadenza e il conteggio della pausa dal `closed_at` sono
|
||
regole del round. Qui resta il fatto di schema: `duration_seconds` e
|
||
`cooldown_seconds` sono colonne `NOT NULL` con `server_default` (tabella
|
||
sopra), scritte solo all'inserimento del round.
|
||
|
||
- ~~**[DAT-19]**~~ Assorbito da [RND-28](ciclo-di-vita-del-round.md): le due
|
||
scritture di `closed_at` (chiusura delle scommesse, poi conferma del
|
||
pagamento) sono comportamento del round. Qui resta il fatto di schema:
|
||
`closed_at` è l'unica colonna di `rounds` scritta due volte.
|
||
|
||
- **[DAT-20]** `drawing_started_at` si scrive una sola volta, al passaggio a
|
||
`drawing`. Da lì si misura quanto l'estrazione sta aspettando un blocco
|
||
(`draw_waiting_since` in `GET /rounds/current`, le voci `draw_stalled` del
|
||
registro, B-36).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
`app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_rounds_route.py`
|
||
|
||
- **[DAT-21]** `draw_block_height`, `draw_block_hash`, `seed_int`,
|
||
`winner_user_id` e `pool_amount_sats` si scrivono insieme, nella stessa
|
||
transazione che porta il round a `paying_out`. Poi `draw_block_height`,
|
||
`draw_block_hash`, `seed_int` e `winner_user_id` non cambiano più.
|
||
`pool_amount_sats` è la somma di `bet_amount_sats` dei soli partecipanti
|
||
`confirmed`, gli stessi fra cui si estrae il vincitore; può essere solo
|
||
**ridotto**, una volta, dalla verifica che precede il pagamento ([DAT-67]).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
|
||
- **[DAT-22]** `winner_amount_sats`, `fee_amount_sats`, `bump_reserve_sats` e
|
||
`payout_txid` si scrivono nella fase 3 di `_trigger_payout`, insieme alla
|
||
riga `pending_transactions` del pagamento e **prima** del broadcast (B-25).
|
||
`payout_txid` segue poi il txid corrente a ogni bump RBF. Quando il
|
||
riconciliatore abbandona il pagamento tornano **tutti** a `NULL`, nella
|
||
stessa transazione del database, così un round in `paying_out` senza
|
||
pagamento in volo non mostra importi di un tentativo che non esiste più.
|
||
Un bump RBF non cambia `winner_amount_sats` né `fee_amount_sats`: riduce solo
|
||
l'output di riserva ([DAT-73]).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/tx/broadcast.py:_retarget_txid_references` · `app/tx/reconcile.py:_abandon` ·
|
||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`,
|
||
`tests/unit/test_reconcile.py`
|
||
*Stato:* da implementare (B-96, B-79) · *Oggi:* l'abbandono azzera solo
|
||
`payout_txid` e lascia `winner_amount_sats`/`fee_amount_sats` del tentativo
|
||
abbandonato; non esiste un output di riserva e il bump riduce un resto del
|
||
pool che normalmente non c'è.
|
||
|
||
- **[DAT-60]** Ogni round ha il proprio indirizzo del montepremi,
|
||
`pool_address`, derivato all'apertura da `m/84'/746'/0'/2/<id>` e scritto
|
||
nella stessa transazione che inserisce il round (l'`id` è noto dopo il
|
||
`flush`, prima del commit); non cambia più. Le scommesse del round pagano
|
||
questo indirizzo. I round aperti prima della migrazione ricevono come
|
||
`pool_address` l'indirizzo unico di oggi (`m/84'/746'/0'/1/0`), che da
|
||
allora è l'indirizzo di **riserva**: il loro pagamento spende, come per ogni
|
||
round, gli outpoint delle loro scommesse a quell'indirizzo ([DAT-11]). Per
|
||
questo la colonna non ha un vincolo `UNIQUE`: i round nuovi hanno
|
||
indirizzi distinti per costruzione (percorso diverso per ogni `id`), i round
|
||
vecchi condividono l'indirizzo di riserva.
|
||
*Codice:* `app/rounds/service.py:open_new_round_if_needed` ·
|
||
`app/wallet/hd.py:derive_pool_key` ·
|
||
`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` e `draw_confirmations` di
|
||
`rounds` sono una **copia** dei valori di `round_config` presa quando il
|
||
round si apre, nella stessa transazione dell'inserimento, e non cambiano
|
||
più. Le scommesse del round costano `rounds.bet_amount_sats`, il suo
|
||
pagamento paga la commissione a `rounds.fee_address`, la sua estrazione
|
||
attende `rounds.draw_confirmations` conferme. La regola di dominio è in
|
||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-95). La
|
||
migrazione riempie le righe esistenti con i valori attuali di
|
||
`round_config` ([DAT-55]).
|
||
*Codice:* `app/rounds/service.py:open_new_round_if_needed` ·
|
||
`app/bets/service.py:place_bet` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* —
|
||
*Stato:* da implementare (B-95) · *Oggi:* `place_bet` e `_trigger_payout`
|
||
leggono `bet_amount_sats` e `fee_address` da `round_config` al momento
|
||
dell'uso, quindi una modifica a metà round cambia il round in corso.
|
||
|
||
- **[DAT-64]** Il segreto del round sono 32 byte casuali generati con
|
||
`secrets.token_bytes(32)`. All'inserimento del round si scrivono insieme
|
||
`server_seed_hash` (`sha256` di quei 32 byte, esadecimale minuscolo) e
|
||
`server_seed_enc`: il segreto **cifrato con Fernet** usando la stessa chiave
|
||
che cifra la master xprv (`XPRV_ENCRYPTION_KEY`, dopo B-91 il file di
|
||
[sicurezza.md](sicurezza.md#sec-53) SEC-53). Il segreto in chiaro non viene
|
||
mai scritto nel database prima della rivelazione: `server_seed` resta
|
||
`NULL` e si valorizza (esadecimale minuscolo, 64 caratteri) nella stessa
|
||
transazione che scrive `winner_user_id`, oppure alla chiusura di un round
|
||
senza partecipanti. Così una copia del database o un suo backup, senza la
|
||
chiave, non rivela il segreto di un round ancora aperto. Nessun endpoint e
|
||
nessuna voce del registro di audit contengono il segreto prima della
|
||
rivelazione ([api.md](api.md), `GET /rounds/{id}/proof`). Nessuna colonna
|
||
cambia più dopo la rivelazione; `server_seed_enc` resta com'è. Se
|
||
all'avvio la chiave non decifra il `server_seed_enc` di un round non
|
||
ancora rivelato, il server rifiuta di partire
|
||
([sicurezza.md](sicurezza.md#sec-49) SEC-49). I round aperti prima della
|
||
migrazione hanno le tre colonne a `NULL`.
|
||
*Codice:* `app/rounds/service.py:open_new_round_if_needed` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
`app/api/routes/rounds.py:current_round` · *Test:* —
|
||
*Stato:* da implementare (B-73) · *Oggi:* le colonne non esistono; il seme
|
||
è l'hash del blocco da solo.
|
||
|
||
- **[DAT-65]** All'ingresso in `drawing`, nella **stessa transazione** che
|
||
cambia lo stato, si scrivono `draw_height`, `participants_hash` e il
|
||
`draw_position` di ogni partecipante `confirmed` ([DAT-66]). `draw_height`
|
||
è l'altezza della punta nota in quel momento più 1; `participants_hash` è
|
||
l'impronta dell'elenco ordinato dei partecipanti con la codifica di
|
||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73). Nessuno
|
||
dei tre cambia più. Sono ciò che permette di riprendere l'estrazione dopo un
|
||
errore o un riavvio ottenendo lo stesso vincitore (B-81): la ripresa legge
|
||
questi valori dal database e non ricalcola nulla.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* —
|
||
*Stato:* da implementare (B-73, B-81) · *Oggi:* le colonne non esistono;
|
||
l'elenco dei partecipanti e la punta alla chiusura restano solo in memoria.
|
||
|
||
- **[DAT-67]** Prima di costruire il pagamento, gli outpoint delle scommesse
|
||
vengono verificati sulla rete; i partecipanti la cui scommessa non c'è più
|
||
passano a `reorged_out` ([DAT-24]) e `pool_amount_sats` viene riscritto come
|
||
somma dei partecipanti rimasti `confirmed`, **nella stessa transazione del
|
||
database**. Il valore scritto è sempre minore o uguale al precedente.
|
||
La regola di dominio (quorum, ripartizione su ciò che c'è) è in
|
||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-82).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* —
|
||
*Stato:* da implementare (B-82) · *Oggi:* `pool_amount_sats` non cambia dopo
|
||
l'estrazione, e un pagamento che non trova tutte le scommesse fallisce con
|
||
"insufficient pool UTXOs" a ogni tentativo.
|
||
|
||
- **[DAT-68]** `payout_blocked_at` si scrive quando la verifica di [DAT-67]
|
||
trova assente la scommessa del **vincitore**: il round resta `paying_out`,
|
||
non si costruisce alcun pagamento finché la colonna non è `NULL`, e nella
|
||
stessa transazione si attiva la pausa con `paused_reason = 'payout_blocked'`
|
||
([DAT-69]). Torna `NULL` solo con `POST /admin/resume` ([api.md](api.md)),
|
||
dopo di che la verifica si ripete al tentativo successivo.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/api/routes/admin.py:resume_lottery` · *Test:* —
|
||
*Stato:* da implementare (B-82) · *Oggi:* la colonna non esiste.
|
||
|
||
- **[DAT-73]** Ogni pagamento ha un output di **riserva** verso l'indirizzo
|
||
di riserva (`m/84'/746'/0'/1/0`), di valore
|
||
`max(DUST_LIMIT_SATS, vsize stimata × 10 sat/vB)`, preso dalla quota delle
|
||
commissioni; `bump_reserve_sats` ne registra il valore iniziale. Un bump RBF
|
||
del pagamento riduce questo output (non `winner_amount_sats` né
|
||
`fee_amount_sats`) e non riscrive `bump_reserve_sats`. La regola di dominio
|
||
è in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-79).
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/wallet/psbt_builder.py:build_payout_transaction` ·
|
||
`app/tx/broadcast.py:bump_fee` · *Test:* —
|
||
*Stato:* da implementare (B-79) · *Oggi:* la colonna non esiste; il
|
||
pagamento non ha di norma alcun resto e quindi non si può accelerare.
|
||
|
||
### `round_participants`
|
||
|
||
Una scommessa: un utente in un round.
|
||
|
||
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | — | database |
|
||
| `round_id` | FK `rounds.id`, indicizzata | no | — | Il round | `app/bets/service.py:place_bet` |
|
||
| `user_id` | FK `users.id`, indicizzata | no | — | Chi scommette | `place_bet` |
|
||
| `bet_amount_sats` | BigInteger | no | — | Quanto arriva davvero al pool: costo della scommessa **meno** la commissione di rete | `place_bet` |
|
||
| `bet_txid` | String(64) | no | — | Txid corrente della scommessa; dopo la conferma, il txid della versione confermata ([DAT-14]) | `place_bet`; `app/tx/broadcast.py:_retarget_txid_references` |
|
||
| `bet_vout` | Integer | no | `0` (`server_default`, backfill `0`) | Posizione dell'output verso il montepremi nella transazione della scommessa ([DAT-63]) — *da implementare (B-73)* | `place_bet` |
|
||
| `broadcast_at` | DateTime | no | `utcnow` | Istante della fase 1 di `place_bet`, vedi [DAT-25] | `place_bet` |
|
||
| `confirmed_at` | DateTime | sì | `NULL` | Istante in cui la scommessa è risultata confermata | `app/bets/confirmation.py:_on_bet_confirmed` |
|
||
| `draw_position` | Integer | sì | `NULL` | Posizione (da 0) nell'elenco ordinato dell'estrazione; valorizzata solo per i partecipanti `confirmed` all'ingresso in `drawing` ([DAT-66]) — *da implementare (B-81)* | `app/rounds/scheduler.py:RoundScheduler._close_and_draw` |
|
||
| `status` | String(16) | no | `"broadcast"` (mai usato: si scrive sempre esplicitamente) | Vedi [DAT-24] | vedi diagramma |
|
||
|
||
- **[DAT-23]** Al massimo una scommessa per utente per round: vincolo unico su
|
||
(`round_id`, `user_id`). `place_bet` lo verifica già prima di costruire la
|
||
transazione (`already_betting`). Il vincolo è anche la chiave con cui i
|
||
gestori di conferma, bump e riconciliazione ritrovano la riga, invece del txid
|
||
che cambia.
|
||
*Codice:* `app/db/models.py:RoundParticipant` · `app/bets/service.py:place_bet` ·
|
||
`app/bets/confirmation.py:_on_bet_confirmed` · *Test:* `tests/unit/test_bets.py`
|
||
|
||
- **[DAT-24]** `status` assume solo i valori `building`, `broadcast`,
|
||
`confirmed`, `reorged_out`. Una scommessa che non va a buon fine prima della
|
||
conferma viene **cancellata**, e l'utente non risulta nel round.
|
||
`reorged_out` è terminale e riguarda solo una scommessa già `confirmed` il
|
||
cui outpoint non è più sulla catena alla verifica che precede il pagamento
|
||
([DAT-67]): la riga **non** si cancella, perché ha già un `draw_position` e
|
||
fa parte dell'elenco pubblicato con `participants_hash`, ma non conta nel
|
||
montepremi. Diagramma del comportamento atteso (oggi manca `reorged_out`):
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> building: place_bet fase 1
|
||
building --> broadcast: place_bet fase 2 / _promote
|
||
building --> confirmed: _on_bet_confirmed
|
||
broadcast --> confirmed: _on_bet_confirmed
|
||
building --> [*]: cancellata (_release_failed_bet / _abandon)
|
||
broadcast --> [*]: cancellata (_abandon)
|
||
confirmed --> reorged_out: verifica prima del pagamento (da implementare)
|
||
confirmed --> [*]
|
||
reorged_out --> [*]
|
||
```
|
||
|
||
| Da | A | Funzione |
|
||
|---|---|---|
|
||
| — | `building` | `app/bets/service.py:place_bet` (fase 1, prima del broadcast) |
|
||
| `building` | `broadcast` | `place_bet` (fase 2, broadcast riuscito); `app/tx/reconcile.py:_promote` |
|
||
| `building`, `broadcast` | `confirmed` | `app/bets/confirmation.py:_on_bet_confirmed` |
|
||
| `building` | cancellata | `app/bets/service.py:_release_failed_bet` (broadcast rifiutato esplicitamente) |
|
||
| `building`, `broadcast` | cancellata | `app/tx/reconcile.py:_abandon` (abbandono con prova) |
|
||
| `confirmed` | `reorged_out` | `app/rounds/scheduler.py:RoundScheduler._trigger_payout` (da implementare) |
|
||
|
||
Se il compare-and-set di [DAT-16] fallisce, la riga `building` non arriva
|
||
nemmeno al commit. Un broadcast dall'esito ambiguo (timeout, connessione
|
||
caduta) lascia la riga in `building` per il riconciliatore
|
||
([scommesse-e-prelievi.md](scommesse-e-prelievi.md) B-76).
|
||
*Codice:* `app/bets/service.py:place_bet` ·
|
||
`app/bets/service.py:_release_failed_bet` · `app/tx/reconcile.py:_promote` ·
|
||
`app/tx/reconcile.py:_abandon` · `app/bets/confirmation.py:_on_bet_confirmed` ·
|
||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_confirmation.py`,
|
||
`tests/unit/test_reconcile.py`
|
||
*Stato:* da implementare (B-82, B-76) · *Oggi:* `reorged_out` non esiste (un
|
||
partecipante confermato la cui scommessa sparisce resta `confirmed`), e
|
||
qualunque eccezione del broadcast cancella la riga come un rifiuto.
|
||
|
||
- **[DAT-25]** `broadcast_at` è l'istante della fase 1 di `place_bet`, appena
|
||
prima del broadcast, e non viene mai riscritto (nemmeno da un bump RBF). Non
|
||
determina più l'ordine di estrazione, che è quello di [DAT-66]: resta
|
||
un'informazione per `/admin` e per il registro.
|
||
*Codice:* `app/bets/service.py:place_bet` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
*Stato:* da implementare (B-73) · *Oggi:* l'estrazione ordina i partecipanti
|
||
confermati per `broadcast_at` crescente, senza criterio di parità.
|
||
|
||
- **[DAT-63]** `bet_vout` è l'indice dell'output della scommessa che paga
|
||
`rounds.pool_address`; con il costruttore attuale è sempre `0`
|
||
(`recipient_sats` è il primo output). Insieme a `bet_txid` identifica
|
||
l'outpoint che l'elenco dell'estrazione pubblica e che il pagamento spende
|
||
([DAT-11]).
|
||
*Codice:* `app/bets/service.py:place_bet` ·
|
||
`app/wallet/psbt_builder.py:build_signed_transaction` · *Test:* —
|
||
*Stato:* da implementare (B-73) · *Oggi:* la colonna non esiste;
|
||
l'outpoint della scommessa non è registrato.
|
||
|
||
- **[DAT-66]** `draw_position` si scrive una sola volta, all'ingresso in
|
||
`drawing` ([DAT-65]), per ogni partecipante `confirmed` del round: i
|
||
partecipanti sono ordinati per `bet_txid` crescente (confronto delle
|
||
stringhe esadecimali minuscole) e numerati da 0. Il vincolo unico
|
||
(`round_id`, `draw_position`) impedisce due partecipanti nella stessa
|
||
posizione; i partecipanti senza posizione hanno `NULL`, ammesso più volte
|
||
dal vincolo. `draw_position` non cambia nemmeno quando la riga passa a
|
||
`reorged_out`.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||
`app/db/models.py:RoundParticipant` · *Test:* —
|
||
*Stato:* da implementare (B-73, B-81) · *Oggi:* la colonna non esiste;
|
||
l'ordine esiste solo in memoria durante l'attesa del blocco.
|
||
|
||
- **[DAT-26]** `bet_amount_sats` è l'importo del primo output della scommessa
|
||
(`recipient_sats`), cioè il costo della scommessa del round
|
||
(`rounds.bet_amount_sats`, [DAT-61]) meno la commissione di rete. Non cambia
|
||
più: un bump RBF riduce il resto dell'utente, non l'output verso il pool.
|
||
Per questo il montepremi si calcola sommando questa colonna, non
|
||
moltiplicando il numero di partecipanti per il costo (B-11).
|
||
*Codice:* `app/bets/service.py:place_bet` ·
|
||
`app/wallet/psbt_builder.py:build_signed_transaction` ·
|
||
`app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_bets.py`
|
||
*Stato:* da implementare (B-95) · *Oggi:* il costo è
|
||
`round_config.bet_amount_sats` letto al momento della scommessa.
|
||
|
||
- **[DAT-27]** Il numero di righe di un round, contando tutti gli stati, non
|
||
supera `MAX_PARTICIPANTS_PER_ROUND` (400): la 401ª scommessa è rifiutata con
|
||
`round_full` prima che si muova denaro (B-52). Con
|
||
`round_config.max_round_pool_sats` diverso da 0 ([DAT-62]), una scommessa è
|
||
rifiutata con `round_full` anche quando la somma dei `bet_amount_sats` del
|
||
round, contando tutti gli stati, ha già raggiunto quel tetto; la regola di
|
||
dominio è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95).
|
||
*Codice:* `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py` ·
|
||
*Test:* `tests/unit/test_bets.py`
|
||
*Stato:* da implementare (B-95), solo per il tetto sul montepremi · *Oggi:*
|
||
esiste solo il limite dei 400 partecipanti.
|
||
|
||
### `round_config`
|
||
|
||
I parametri operativi, in **una sola riga**, modificabili a caldo da `/admin`
|
||
senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
|
||
|
||
| Colonna | Tipo | Default | Limiti accettati da `PUT /admin/config` | Significato | Quando ha effetto |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | autoincrement | — | — | — |
|
||
| `fee_address` | String(128), non null | nessun default nel modello; la riga nasce con `""` | indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Atteso: dal round **successivo**, copiato all'apertura ([DAT-61]). Oggi: subito, si legge a ogni costruzione del pagamento, quindi vale anche per il round in corso se il suo pagamento non è ancora stato costruito |
|
||
| `bet_amount_sats` | BigInteger | `1_000_000_000` (10 PLM) | `> 0`, `≤ 100 000 PLM` | Costo della scommessa; anche il prelievo minimo | Atteso: per le scommesse dal round **successivo** ([DAT-61]), per il prelievo minimo subito. Oggi: subito, per le scommesse e i prelievi successivi; le scommesse già piazzate tengono il proprio importo ([DAT-26]) |
|
||
| `round_duration_seconds` | Integer | `600` | 30 s – 7 giorni | Durata del round | Dal round **successivo** ([RND-05](ciclo-di-vita-del-round.md)) |
|
||
| `round_cooldown_seconds` | Integer | `30` | 0 s – 24 h | Pausa dopo la chiusura | Dal round **successivo**: la pausa dopo il round in corso è quella copiata alla sua apertura ([RND-05](ciclo-di-vita-del-round.md)) |
|
||
| `draw_animation_seconds` | Integer | `20` | 0 – 600 s | Durata minima dell'animazione dell'estrazione nel frontend; non condiziona l'estrazione vera | Subito (letto da `GET /rounds/current`) |
|
||
| `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (10 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` |
|
||
| `rbf_timeout_seconds` | Integer | `900` | 60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) |
|
||
| `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) — *da implementare (B-82)* | Subito, per gli accrediti successivi; gli output già accreditati restano |
|
||
| `draw_confirmations` | Integer | `3` | 1 – 20 | Conferme richieste sul blocco di estrazione ([DAT-62]) — *da implementare (B-73)* | Dal round **successivo**, copiato all'apertura ([DAT-61]) |
|
||
| `max_round_pool_sats` | BigInteger | `0` (tetto disattivato) | `0`, oppure `≥ bet_amount_sats` e `≤ 4 000 000 000 000 000` (400 × 100 000 PLM) | Tetto alla somma delle scommesse di un round ([DAT-27], [DAT-62]) — *da implementare (B-95)* | Subito, anche per il round in corso: vale per le scommesse successive |
|
||
| `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa della lotteria | Subito per l'apertura del round successivo; il round in corso non è toccato |
|
||
| `paused_reason` | String(16) | `NULL` | non modificabile da `PUT /admin/config` | Perché la lotteria è in pausa ([DAT-69]) — *da implementare (B-78, B-82, B-85)* | Insieme a `paused` |
|
||
| `updated_at` | DateTime | `utcnow`, aggiornato a ogni modifica (`onupdate`) | — | Ultima modifica della riga | ORM |
|
||
|
||
- **[DAT-28]** La tabella contiene al più una riga, creata alla prima lettura
|
||
con i default di colonna di `RoundConfig` e `fee_address = ""`. Tutti leggono
|
||
la configurazione attraverso `get_round_config`, che prende la prima riga
|
||
trovata. Il database non impone l'unicità della riga: la garanzia è che solo
|
||
`get_round_config` ne inserisce una. I default di un'istanza nuova sono quelli
|
||
del modello, non variabili d'ambiente.
|
||
*Codice:* `app/rounds/config.py:get_round_config` · `app/db/models.py:RoundConfig` ·
|
||
*Test:* `tests/unit/test_admin.py`
|
||
|
||
- **[DAT-29]** Le colonne modificabili da `PUT /admin/config` sono esattamente
|
||
`fee_address`, `bet_amount_sats`, `round_duration_seconds`,
|
||
`round_cooldown_seconds`, `fee_rate_sat_vb`, `rbf_timeout_seconds`,
|
||
`draw_animation_seconds`, `deposit_confirmations`, `draw_confirmations`,
|
||
`max_round_pool_sats`, entro i limiti della tabella sopra. Ogni modifica
|
||
effettiva scrive una voce `config_updated` con il valore prima e dopo, e un
|
||
valore uguale a quello attuale non conta come modifica.
|
||
*Codice:* `app/api/routes/admin.py:update_config` ·
|
||
`app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py`
|
||
*Stato:* da implementare (B-73, B-82, B-95), per le ultime tre colonne ·
|
||
*Oggi:* le colonne modificabili sono le prime sette.
|
||
|
||
- **[DAT-30]** `paused` e `paused_reason` si modificano solo con
|
||
`POST /admin/pause` e `POST /admin/resume`, che scrivono `lottery_paused` e
|
||
`lottery_resumed` nel registro (B-10), e con le pause automatiche di
|
||
[DAT-69], che scrivono la propria voce di audit.
|
||
*Codice:* `app/api/routes/admin.py:pause_lottery` ·
|
||
`app/api/routes/admin.py:resume_lottery` · *Test:* `tests/unit/test_admin.py`
|
||
*Stato:* da implementare (B-78, B-82, B-85) · *Oggi:* `paused_reason` non
|
||
esiste e nessun codice mette in pausa la lotteria da solo.
|
||
|
||
- **[DAT-62]** `deposit_confirmations` (predefinito 6) è il numero di
|
||
conferme dopo cui un output che paga un utente si accredita ([DAT-10]);
|
||
`draw_confirmations` (predefinito 3) è il numero di blocchi che la catena
|
||
deve avere a partire da `draw_height` prima che si legga l'hash del blocco
|
||
di estrazione, copiato su ogni round all'apertura ([DAT-61]);
|
||
`max_round_pool_sats` (predefinito 0) è il tetto di [DAT-27], disattivato
|
||
quando vale 0. Il significato di dominio è in
|
||
[registrazione-e-depositi.md](registrazione-e-depositi.md) (B-82),
|
||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73) e
|
||
[scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95). La migrazione le
|
||
aggiunge con un `server_default` temporaneo pari al predefinito ([DAT-55]).
|
||
*Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` ·
|
||
*Test:* —
|
||
*Stato:* da implementare (B-73, B-82, B-95) · *Oggi:* le colonne non
|
||
esistono; depositi ed estrazione usano 1 conferma e non c'è tetto.
|
||
|
||
- **[DAT-69]** `paused_reason` è `NULL` esattamente quando `paused` è falso;
|
||
con `paused` vero vale uno di questi valori, e nessun altro:
|
||
|
||
| Valore | Chi lo scrive | Quando |
|
||
|---|---|---|
|
||
| `operator` | `app/api/routes/admin.py:pause_lottery`; l'avvio con `START_PAUSED=true` ([operativita.md](operativita.md) B-90) | pausa decisa dall'operatore |
|
||
| `reorg` | il listener Electrum | riorganizzazione di profondità ≥ 2 ([connessione-electrum.md](connessione-electrum.md) B-85) |
|
||
| `payout_blocked` | `app/rounds/scheduler.py:RoundScheduler._trigger_payout` | la scommessa del vincitore non è più sulla catena ([DAT-68], B-82) |
|
||
| `resurrected_tx` | `app/tx/reconcile.py:reconcile_once` | una riga `failed` risulta confermata ([DAT-72], B-78) |
|
||
|
||
Una pausa automatica (`reorg`, `payout_blocked`, `resurrected_tx`) scrive
|
||
sempre il proprio motivo, anche sopra una pausa già attiva; una pausa
|
||
`operator` non sovrascrive un motivo automatico già presente, così la
|
||
ragione che richiede un controllo non si perde. `POST /admin/resume` è
|
||
l'unico modo di tornare a `paused = false`, `paused_reason = NULL`, per
|
||
qualunque motivo. La pausa, come oggi, impedisce solo l'apertura del round
|
||
successivo.
|
||
*Codice:* `app/api/routes/admin.py:pause_lottery` ·
|
||
`app/api/routes/admin.py:resume_lottery` ·
|
||
`app/electrum/listener.py:ElectrumListener._apply_header` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/tx/reconcile.py:reconcile_once` · *Test:* —
|
||
*Stato:* da implementare (B-78, B-82, B-85, B-90) · *Oggi:* esiste solo
|
||
`paused`, scritto solo dai due endpoint admin.
|
||
|
||
- **[DAT-31]** Con `fee_address` vuoto (o di soli spazi) non si apre nessun round
|
||
(B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la
|
||
validazione rifiuta la stringa vuota come indirizzo non valido.
|
||
*Codice:* `app/rounds/service.py:rounds_can_open` ·
|
||
`app/api/routes/admin.py:RoundConfigUpdate` ·
|
||
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_admin.py`
|
||
|
||
### `pending_transactions`
|
||
|
||
Ogni transazione in uscita dalla piattaforma: scommesse, prelievi e pagamenti.
|
||
È la riga su cui lavorano il poller delle conferme, il bumper RBF e il
|
||
riconciliatore (vedi
|
||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md)).
|
||
|
||
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | — | database |
|
||
| `kind` | String(16) | no | — | `bet`, `withdrawal` o `payout` | chi crea la riga |
|
||
| `round_id` | FK `rounds.id` | sì | `NULL` | Il round (`bet`, `payout`) | `place_bet`; `_trigger_payout` |
|
||
| `withdrawal_id` | FK `withdrawals.id` | sì | `NULL` | Il prelievo (`withdrawal`) | `request_withdrawal` |
|
||
| `user_id` | FK `users.id` | sì | `NULL` | Il mittente (`bet`, `withdrawal`); `NULL` per `payout` | `place_bet`; `request_withdrawal` |
|
||
| `current_txid` | String(64) | no | — | Txid della versione più recente della transazione | creazione; `bump_fee` |
|
||
| `fee_rate_sat_vb` | Integer | no | — | Commissione della versione corrente, in sat/vB | creazione; `bump_fee` |
|
||
| `raw_tx_hex` | Text | no | — | Transazione firmata della versione corrente | creazione; `bump_fee` |
|
||
| `broadcast_at` | DateTime | no | `utcnow` | Prima trasmissione, vedi [DAT-35] | ORM, alla creazione |
|
||
| `last_broadcast_at` | DateTime | no | `utcnow` | Ultima trasmissione, vedi [DAT-35] | ORM; `bump_fee` |
|
||
| `status` | String(16) | no | `"pending"` (mai usato: si crea sempre `building`) | Vedi [DAT-33] | vedi diagramma |
|
||
| `replaced_by_txid` | String(64) | sì | `NULL` | Il txid **sostituito** dall'ultimo bump (nonostante il nome, punta all'indietro). Nello schema atteso la cronologia completa è in `pending_transaction_txids` ([DAT-70]) e questa colonna resta solo informativa | `bump_fee` |
|
||
| `attempt_count` | Integer | no | `1` | 1 + numero di bump riusciti | `bump_fee` |
|
||
| `failure_reason` | String(128) | sì | `NULL` | Perché la transazione è fallita; solo con `status = failed` | `_abandon`; `_release_failed_withdrawal` |
|
||
| `failed_at` | DateTime | sì | `NULL` | Istante del passaggio a `failed`; da lì si contano i 14 giorni di sorveglianza ([DAT-72]) — *da implementare (B-78)* | `_abandon`; `_release_failed_withdrawal` |
|
||
| `updated_at` | DateTime | no | `utcnow`, `onupdate` | Ultima modifica | ORM |
|
||
|
||
Le funzioni citate sono `app/bets/service.py:place_bet`,
|
||
`app/withdrawals/service.py:request_withdrawal`,
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
|
||
`app/tx/broadcast.py:bump_fee`, `app/tx/reconcile.py:_abandon` e
|
||
`app/withdrawals/service.py:_release_failed_withdrawal`.
|
||
|
||
- **[DAT-32]** Le colonne di collegamento dipendono da `kind`:
|
||
|
||
| `kind` | `round_id` | `user_id` | `withdrawal_id` | Firmata con | Riga di dominio collegata |
|
||
|---|---|---|---|---|---|
|
||
| `bet` | il round | lo scommettitore | `NULL` | chiave dell'utente | `round_participants` (`round_id`, `user_id`) |
|
||
| `withdrawal` | `NULL` | chi preleva | il prelievo | chiave dell'utente | `withdrawals` (`withdrawal_id`) |
|
||
| `payout` | il round | `NULL` | `NULL` | chiave del montepremi del round (`m/84'/746'/0'/2/<round_id>`; la chiave di riserva per i round anteriori a B-80) | `rounds` (`round_id`) |
|
||
|
||
I gestori delle conferme e del riconciliatore ritrovano la riga di dominio da
|
||
questi identificativi, che non cambiano, e non dal txid, che cambia a ogni
|
||
bump (B-02). L'indirizzo di cui controllare la cronologia si ricava da
|
||
`kind` e `user_id` e, per un `payout`, da `rounds.pool_address` del
|
||
`round_id` (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_scheduler.py`, `tests/unit/test_bets.py`,
|
||
`tests/unit/test_withdrawals.py`
|
||
*Stato:* da implementare (B-78) · *Oggi:* l'abbandono avviene quando la
|
||
cronologia del solo server attivo non contiene `current_txid`; il poller
|
||
guarda solo le righe `pending` e una riga `failed` non viene più guardata.
|
||
|
||
- **[DAT-34]** Dopo la creazione, `current_txid`, `raw_tx_hex`,
|
||
`fee_rate_sat_vb`, `replaced_by_txid`, `attempt_count` e `last_broadcast_at`
|
||
cambiano solo in `bump_fee`, e `current_txid`/`raw_tx_hex` anche nel
|
||
riallineamento a una versione precedente confermata ([DAT-14]). `bump_fee`
|
||
procede in quest'ordine:
|
||
1. scrive la nuova versione in `pending_transaction_txids` come
|
||
**candidata**, con un commit, prima di trasmetterla ([DAT-71]);
|
||
2. la trasmette;
|
||
3. aggiorna la riga con
|
||
`UPDATE ... WHERE id = :id AND status = 'pending' AND current_txid = :vecchio`,
|
||
insieme al retarget di [DAT-14], in una sola transazione. Se
|
||
l'aggiornamento tocca zero righe (la riga è stata confermata,
|
||
abbandonata o già riaccelerata nel frattempo) non scrive nulla e registra
|
||
il conflitto, che il riconciliatore risolve.
|
||
|
||
`fee_rate_sat_vb` registra la commissione effettivamente pagata dalla nuova
|
||
versione, non quella obiettivo.
|
||
*Codice:* `app/tx/broadcast.py:bump_fee` · *Test:* `tests/unit/test_broadcast.py`
|
||
*Stato:* da implementare (B-77) · *Oggi:* la nuova versione è trasmessa
|
||
nella fase 2 prima di esistere nel database, e la fase 3 riscrive la riga
|
||
**senza ricontrollarne** stato e `current_txid`: un abbandono o una
|
||
conferma avvenuti nel frattempo vengono sovrascritti. `replaced_by_txid`
|
||
conserva solo il txid immediatamente precedente.
|
||
|
||
- **[DAT-35]** `broadcast_at` è la **prima** trasmissione e non viene mai
|
||
riscritta: è il riferimento da cui il riconciliatore misura i propri tempi
|
||
di attesa, definiti in
|
||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
|
||
`last_broadcast_at` è l'ultima trasmissione: la aggiorna `bump_fee` e da lì
|
||
`should_bump` misura se serve un altro bump. Tenerle separate impedisce che
|
||
bump ripetuti rinviino all'infinito il controllo del riconciliatore (B-27).
|
||
*Codice:* `app/tx/reconcile.py:_is_due` · `app/tx/broadcast.py:should_bump` ·
|
||
`app/tx/broadcast.py:bump_fee` ·
|
||
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_reconcile.py`
|
||
|
||
- **[DAT-36]** `failure_reason` e `failed_at` si scrivono solo quando la riga
|
||
passa a `failed`, `failure_reason` troncato a 128 caratteri. Il motivo nomina
|
||
la prova su cui si è abbandonato (un input speso da un'altra transazione
|
||
confermata, con il suo txid, oppure il rifiuto esplicito della
|
||
ritrasmissione, con il messaggio della rete, B-78), o il messaggio d'errore
|
||
della rete per un broadcast rifiutato. Una riga che risorge ([DAT-72])
|
||
conserva `failure_reason` e `failed_at`.
|
||
*Codice:* `app/tx/reconcile.py:_abandon` ·
|
||
`app/withdrawals/service.py:_release_failed_withdrawal` ·
|
||
*Test:* `tests/unit/test_reconcile.py`
|
||
*Stato:* da implementare (B-78) · *Oggi:* il riconciliatore scrive sempre
|
||
`"not found on chain"` e `failed_at` non esiste.
|
||
|
||
- **[DAT-72]** Una riga `failed` con `failed_at` negli ultimi 14 giorni resta
|
||
tra quelle che il riconciliatore controlla a ogni giro (120 s,
|
||
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) TX-23), su tutti i txid di
|
||
`pending_transaction_txids`. Se una versione risulta confermata, la riga
|
||
passa a `confirmed`, si scrive `pending_tx_resurrected` e la lotteria va in
|
||
pausa con `paused_reason = 'resurrected_tx'` ([DAT-69]), tutto nella stessa
|
||
transazione. Le righe di dominio già riportate indietro dall'abbandono
|
||
(partecipante cancellato, prelievo `failed`, input liberati) **non** vengono
|
||
ricostruite automaticamente: le riallinea l'operatore
|
||
([operativita.md](operativita.md) B-78).
|
||
*Codice:* `app/tx/reconcile.py:reconcile_once` · `app/tx/reconcile.py:_abandon` ·
|
||
*Test:* —
|
||
*Stato:* da implementare (B-78) · *Oggi:* una riga `failed` non viene mai
|
||
più letta.
|
||
|
||
- **[DAT-37]** Per le righe `bet` e `withdrawal` in `building`/`pending`,
|
||
`raw_tx_hex` è la fonte del saldo "in attesa" (gli output che tornano
|
||
all'indirizzo dell'utente). Per le righe `payout` in `building`/`pending`
|
||
definisce gli outpoint del pool già impegnati ([DAT-11]). Per ogni riga
|
||
abbandonata definisce gli input da liberare ([DAT-13]).
|
||
*Codice:* `app/wallet/balance.py:compute_pending_balance` ·
|
||
`app/rounds/scheduler.py:_reserved_payout_outpoints` ·
|
||
`app/tx/reconcile.py:_release_inputs` ·
|
||
*Test:* `tests/unit/test_balance.py`, `tests/unit/test_scheduler.py`,
|
||
`tests/unit/test_reconcile.py`
|
||
|
||
- **[DAT-38]** Le righe `pending_transactions` non si cancellano, con una sola
|
||
eccezione: la riga `bet` il cui broadcast è stato rifiutato, cancellata insieme
|
||
al partecipante da `_release_failed_bet`. Un prelievo rifiutato invece lascia
|
||
la riga, in `failed`.
|
||
*Codice:* `app/bets/service.py:_release_failed_bet` ·
|
||
`app/withdrawals/service.py:_release_failed_withdrawal` ·
|
||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`
|
||
|
||
### `pending_transaction_txids`
|
||
|
||
*Tabella da implementare (B-77).* Ogni versione che una riga
|
||
`pending_transactions` abbia mai avuto: la prima, ogni sostituzione RBF
|
||
trasmessa e ogni candidata scritta prima di un bump. Serve perché una
|
||
qualunque di queste versioni può essere quella che la rete conferma.
|
||
|
||
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | — | database |
|
||
| `pending_transaction_id` | FK `pending_transactions.id`, indicizzata | no | — | La transazione di cui questa è una versione | `place_bet`; `request_withdrawal`; `_trigger_payout`; `bump_fee` |
|
||
| `txid` | String(64), unico | no | — | Txid della versione | come sopra |
|
||
| `raw_hex` | Text | no | — | Transazione firmata di questa versione; stringa vuota solo per un `replaced_by_txid` ricostruito dal backfill della migrazione, di cui la transazione grezza non è più disponibile | come sopra |
|
||
| `created_at` | DateTime | no | `utcnow` | Istante in cui la versione è stata scritta, prima della sua trasmissione | ORM |
|
||
|
||
- **[DAT-70]** Ogni txid che una riga `pending_transactions` abbia mai avuto
|
||
come versione ha una riga qui, scritta nello **stesso commit** che precede
|
||
la sua trasmissione: la prima versione insieme alla riga
|
||
`pending_transactions` in `building` ([DAT-44]), ogni sostituzione nella
|
||
fase 1 di `bump_fee` ([DAT-34]). Le righe non si modificano e non si
|
||
cancellano; `txid` è unico su tutta la tabella, quindi un txid appartiene a
|
||
una sola transazione della piattaforma. Il poller delle conferme e il
|
||
riconciliatore cercano **tutti** i txid della riga, non solo
|
||
`current_txid`: la conferma di uno qualunque conferma la riga ([DAT-14]).
|
||
La riga `bet` cancellata da `_release_failed_bet` ([DAT-38]) porta con sé
|
||
le proprie versioni, nella stessa transazione.
|
||
*Codice:* `app/db/models.py` · `app/tx/broadcast.py:bump_fee` ·
|
||
`app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` ·
|
||
*Test:* —
|
||
*Stato:* da implementare (B-77) · *Oggi:* la tabella non esiste; poller e
|
||
riconciliatore confrontano solo `current_txid`, e `replaced_by_txid`
|
||
ricorda un solo passo indietro.
|
||
|
||
- **[DAT-71]** Una versione **candidata** è una riga di questa tabella il cui
|
||
txid non è (ancora) `current_txid`: `bump_fee` l'ha scritta ma la fase 3 non
|
||
l'ha ancora adottata, perché il processo si è fermato, la trasmissione è
|
||
fallita o l'aggiornamento condizionale ha trovato un conflitto. Una
|
||
candidata mai trasmessa è innocua (nessuno la conferma); una candidata
|
||
trasmessa e poi confermata viene riconosciuta come qualunque altra versione.
|
||
Il riconciliatore, quando ritrasmette, usa il `raw_tx_hex` corrente, non
|
||
quello di una candidata.
|
||
*Codice:* `app/tx/broadcast.py:bump_fee` · `app/tx/reconcile.py:reconcile_once` ·
|
||
*Test:* —
|
||
*Stato:* da implementare (B-77) · *Oggi:* non esistono candidate: la
|
||
sostituzione esiste nel database solo dopo il suo broadcast riuscito.
|
||
|
||
### `withdrawals`
|
||
|
||
Una richiesta di prelievo verso un indirizzo esterno. A differenza di una
|
||
scommessa, resta nella tabella anche quando fallisce: è un'istruzione data
|
||
dall'utente.
|
||
|
||
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | — | database |
|
||
| `user_id` | FK `users.id`, indicizzata | no | — | Chi preleva | `app/withdrawals/service.py:request_withdrawal` |
|
||
| `external_address` | String(128) | no | — | Destinatario: un indirizzo PLM valido, diverso da quello dell'utente e da ogni indirizzo di montepremi, di riserva e delle commissioni | `request_withdrawal` |
|
||
| `amount_requested_sats` | BigInteger | no | — | Importo chiesto dall'utente | `request_withdrawal` |
|
||
| `amount_sent_sats` | BigInteger | sì (sempre valorizzata dal codice) | `NULL` | Quanto riceve il destinatario, vedi [DAT-40] | `request_withdrawal` |
|
||
| `txid` | String(64) | sì | `NULL` | Txid corrente; `NULL` dopo un fallimento | `request_withdrawal`; `_retarget_txid_references`; `_release_failed_withdrawal`; `_abandon` |
|
||
| `status` | String(16) | no | `"pending"` (mai usato: si crea sempre `building`) | Vedi [DAT-39] | vedi diagramma |
|
||
| `created_at` | DateTime | no | `utcnow` | Istante della richiesta | ORM |
|
||
| `confirmed_at` | DateTime | sì | `NULL` | Istante in cui il prelievo è risultato confermato | `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` |
|
||
|
||
- **[DAT-39]** `status` assume solo i valori `building`, `broadcast`,
|
||
`confirmed`, `failed`; gli ultimi due sono terminali. Il passaggio a `failed`
|
||
azzera `txid`. Diagramma del comportamento atteso (oggi un broadcast
|
||
ambiguo porta a `failed` come un rifiuto):
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> building: request_withdrawal
|
||
building --> building: broadcast ambiguo (risposta 202)
|
||
building --> broadcast: broadcast accettato / _promote
|
||
building --> confirmed: _on_withdrawal_confirmed
|
||
broadcast --> confirmed: _on_withdrawal_confirmed
|
||
building --> failed: rifiuto esplicito / _abandon con prova
|
||
broadcast --> failed: _abandon con prova
|
||
confirmed --> [*]
|
||
failed --> [*]
|
||
```
|
||
|
||
| Da | A | Funzione |
|
||
|---|---|---|
|
||
| — | `building` | `app/withdrawals/service.py:request_withdrawal` |
|
||
| `building` | `broadcast` | `request_withdrawal` (broadcast accettato); `app/tx/reconcile.py:_promote` |
|
||
| `building`, `broadcast` | `confirmed` | `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` |
|
||
| `building` | `failed` | `app/withdrawals/service.py:_release_failed_withdrawal` (rifiuto esplicito) |
|
||
| `building`, `broadcast` | `failed` | `app/tx/reconcile.py:_abandon` (abbandono con prova) |
|
||
|
||
Se la riga `pending_transactions` di un prelievo `failed` risorge
|
||
([DAT-72]), il prelievo resta `failed` finché l'operatore non lo riallinea.
|
||
*Codice:* le funzioni in tabella ·
|
||
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py`
|
||
*Stato:* da implementare (B-78) · *Oggi:* l'abbandono non richiede una
|
||
prova.
|
||
|
||
- **[DAT-40]** `amount_requested_sats` è ciò che l'utente ha chiesto;
|
||
`amount_sent_sats` è l'output effettivo verso il destinatario: l'importo
|
||
chiesto meno la commissione di rete e, per un prelievo dell'intero saldo, meno
|
||
quanto serve a lasciare un resto di `DUST_LIMIT_SATS` (B-62). Le due colonne si
|
||
scrivono insieme alla creazione e non cambiano più: un bump RBF riduce il
|
||
resto, non l'output del destinatario.
|
||
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
|
||
`app/wallet/psbt_builder.py:build_signed_transaction` ·
|
||
*Test:* `tests/unit/test_withdrawals.py`
|
||
|
||
### `bug_reports`
|
||
|
||
Una segnalazione inviata da `/report-bug`, anche senza login.
|
||
|
||
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|
||
|---|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | — | database |
|
||
| `description` | Text | no | — | Testo della segnalazione, già ripulito dagli spazi, 1–2000 caratteri (limite dell'API) | `app/api/routes/bug_reports.py:create_bug_report` |
|
||
| `contact` | String(256) | sì | `NULL` | Recapito facoltativo; una stringa vuota diventa `NULL` | `create_bug_report` |
|
||
| `user_id` | FK `users.id` | sì | `NULL` | Chi ha segnalato, se aveva fatto il login | `create_bug_report` |
|
||
| `status` | String(16) | no | `"open"` | `open`, `read` o `resolved` | `create_bug_report` (default); `app/api/routes/admin.py:update_bug_report_status` |
|
||
| `created_at` | DateTime | no | `utcnow` | Istante della segnalazione | ORM |
|
||
|
||
- **[DAT-41]** `status` assume solo i valori `open`, `read`, `resolved`. Nasce
|
||
`open`. Poi lo cambia solo l'amministratore, verso **uno qualunque** dei tre
|
||
valori (il percorso previsto è `open → read → resolved`, ma anche i ritorni
|
||
indietro sono ammessi). Ogni cambio effettivo scrive
|
||
`bug_report_status_changed` con il valore prima e dopo, e riassegnare lo
|
||
stesso valore non scrive nulla (B-60). `read` esiste per permettere a chi ha
|
||
segnalato di distinguere "visto" da "risolto".
|
||
*Codice:* `app/api/routes/admin.py:update_bug_report_status` ·
|
||
`app/api/routes/bug_reports.py:create_bug_report` ·
|
||
*Test:* `tests/unit/test_bug_reports.py`
|
||
|
||
### `audit_log`
|
||
|
||
Il registro di audit: chi ha fatto cosa, e quando. Il catalogo dei tipi di
|
||
evento e dei loro payload è in [audit-log.md](audit-log.md).
|
||
|
||
| Colonna | Tipo | Null | Default | Significato |
|
||
|---|---|---|---|---|
|
||
| `id` | Integer PK | no | autoincrement | Ordine di scrittura |
|
||
| `event_type` | String(32) | no | — | Tipo di evento (es. `bet_placed`, `payout_failed`) |
|
||
| `payload_json` | Text | no | — | Dettagli dell'evento, oggetto JSON serializzato |
|
||
| `user_id` | FK `users.id` | sì | `NULL` | Utente interessato, se c'è |
|
||
| `round_id` | FK `rounds.id` | sì | `NULL` | Round interessato, se c'è |
|
||
| `created_at` | DateTime | no | `utcnow` | Istante di scrittura |
|
||
|
||
- **[DAT-42]** Il registro è in sola aggiunta: le righe si inseriscono solo con
|
||
`write_audit_log`, che aggiunge la riga alla sessione del chiamante senza
|
||
fare commit, così la voce entra nella stessa transazione del cambiamento che
|
||
descrive. Nessun codice aggiorna o cancella righe di `audit_log`.
|
||
`payload_json` è `Text` (B-47).
|
||
*Codice:* `app/audit/log.py:write_audit_log` ·
|
||
*Test:* `tests/unit/test_models_text_columns.py`, `tests/unit/test_admin.py`
|
||
|
||
- **[DAT-43]** Il registro non è solo informativo. Il `created_at` dell'ultima
|
||
voce `payout_failed` di un round fa da orologio per il riprovare del
|
||
pagamento: un nuovo tentativo parte solo 60 s dopo (B-26). Per questo ogni
|
||
uscita anticipata di `_trigger_payout` scrive una voce `payout_failed`.
|
||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._log_payout_failure` ·
|
||
*Test:* `tests/unit/test_scheduler.py`
|
||
|
||
### `alembic_version`
|
||
|
||
Tabella di servizio di Alembic: una riga con la revisione applicata
|
||
(`version_num`). Non è nel modello e il codice applicativo non la legge.
|
||
|
||
## Indici e vincoli
|
||
|
||
| Nome | Tabella | Definizione | Perché |
|
||
|---|---|---|---|
|
||
| PK `id` | tutte | intero autoincrementale | [DAT-05] |
|
||
| `ix_users_username` | `users` | `UNIQUE (username)` | unicità esatta, dallo schema iniziale; ridondante rispetto alla riga sotto |
|
||
| `ix_users_username_lower` | `users` | `UNIQUE (lower(username))` | username senza distinzione di maiuscole, [DAT-06] (B-57) |
|
||
| unico `derivation_index` | `users` | `UNIQUE` | un indice HD, quindi un indirizzo, per utente, [DAT-07] |
|
||
| unico `address` | `users` | `UNIQUE` | un indirizzo non appartiene a due utenti |
|
||
| unico (`txid`, `vout`) | `utxo_events` | `UNIQUE` | un outpoint si accredita una volta sola, [DAT-10] |
|
||
| `ix_utxo_events_user_id` | `utxo_events` | indice su `user_id` | calcolo del saldo e selezione degli UTXO per utente |
|
||
| `ix_rounds_single_active` | `rounds` | `UNIQUE ((1)) WHERE status IN ('open','closing','drawing','paying_out')` | al massimo un round attivo, [DAT-17] (B-09) |
|
||
| unico (`round_id`, `user_id`) | `round_participants` | `UNIQUE` | una scommessa per utente per round, [DAT-23] |
|
||
| unico (`round_id`, `draw_position`) | `round_participants` | `UNIQUE` (i `NULL` non collidono) | una posizione di estrazione per partecipante, [DAT-66] — *da implementare (B-81)* |
|
||
| unico `txid` | `pending_transaction_txids` | `UNIQUE` | un txid appartiene a una sola transazione, [DAT-70] — *da implementare (B-77)* |
|
||
| `ix_pending_transaction_txids_pending_transaction_id` | `pending_transaction_txids` | indice | versioni di una transazione — *da implementare (B-77)* |
|
||
| `ix_round_participants_round_id` | `round_participants` | indice | partecipanti di un round |
|
||
| `ix_round_participants_user_id` | `round_participants` | indice | round di un utente (`last-round-result`) |
|
||
| `ix_withdrawals_user_id` | `withdrawals` | indice | prelievi di un utente |
|
||
|
||
Non ci sono indici su `pending_transactions`, `audit_log` o `bug_reports` oltre
|
||
alla chiave primaria. In particolare `pending_transactions.status` e
|
||
`audit_log.event_type`/`round_id`, usati dai filtri dei task in background, sono
|
||
letti con una scansione completa.
|
||
|
||
## Invarianti tra tabelle
|
||
|
||
- **[DAT-44]** **Prima si scrive, poi si trasmette.** Ogni **versione** di
|
||
una transazione in uscita esiste nel database prima di essere trasmessa. Per
|
||
la prima versione: la riga `pending_transactions` in `building`, la sua riga
|
||
in `pending_transaction_txids`, la riga di dominio (partecipante in
|
||
`building`, prelievo in `building`, oppure `payout_txid` sul round) e, per
|
||
scommesse e prelievi, gli input già marcati con `spent_txid`, tutto in un
|
||
commit che precede il broadcast (B-08, B-25). Per una sostituzione RBF: la
|
||
sua riga candidata in `pending_transaction_txids`, in un commit che precede
|
||
il broadcast ([DAT-34], [DAT-71]). Nessuna sessione del database resta
|
||
aperta durante una chiamata di rete.
|
||
*Codice:* `app/bets/service.py:place_bet` ·
|
||
`app/withdrawals/service.py:request_withdrawal` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/tx/broadcast.py:bump_fee` ·
|
||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_scheduler.py`
|
||
*Stato:* da implementare (B-77), per le sostituzioni RBF · *Oggi:*
|
||
l'invariante vale per la prima versione; una sostituzione RBF viene
|
||
trasmessa nella fase 2 di `bump_fee` e scritta nel database solo nella
|
||
fase 3, quindi un arresto o un `database is locked` fra le due lascia sulla
|
||
rete una transazione che il database non conosce.
|
||
|
||
- **[DAT-45]** **Ogni versione è riconoscibile, una sola è corrente.** Per ogni
|
||
transazione non terminale, `pending_transactions.current_txid` coincide con
|
||
il txid registrato sulla riga di dominio (`round_participants.bet_txid`,
|
||
`withdrawals.txid`, `rounds.payout_txid`) e con lo `spent_txid` di ogni input
|
||
impegnato ([DAT-14]); ogni altro txid che la transazione abbia avuto è in
|
||
`pending_transaction_txids` ([DAT-70]), e la conferma di uno qualunque di
|
||
essi conferma la transazione. `current_txid` cambia solo con un
|
||
aggiornamento condizionato al valore precedente ([DAT-34]), che aggiorna
|
||
tutti i riferimenti nella stessa transazione del database. Per un
|
||
pagamento, `payout_txid` si aggiorna solo se era ancora uguale al vecchio
|
||
txid.
|
||
*Codice:* `app/tx/broadcast.py:_retarget_txid_references` ·
|
||
`app/tx/broadcast.py:bump_fee` · `app/tx/confirmation.py:poll_once` ·
|
||
*Test:* `tests/unit/test_broadcast.py`
|
||
*Stato:* da implementare (B-77) · *Oggi:* l'aggiornamento dei riferimenti
|
||
avviene in una transazione del database separata dal broadcast e non
|
||
condizionata, e i txid precedenti sono dimenticati (salvo l'ultimo, in
|
||
`replaced_by_txid`, che nessuno legge): se conferma una versione
|
||
precedente, poller e riconciliatore non la riconoscono.
|
||
|
||
- **[DAT-46]** **Il montepremi di ogni round si conserva, separato dagli
|
||
altri.** Per un round pagato, con i soli partecipanti rimasti `confirmed`
|
||
dopo la verifica di [DAT-67]:
|
||
- gli input del pagamento sono esattamente gli outpoint (`bet_txid`,
|
||
`bet_vout`) di quei partecipanti, all'indirizzo `rounds.pool_address` del
|
||
round ([DAT-11]); nessun input viene da un altro round o da un altro
|
||
indirizzo;
|
||
- `pool_amount_sats` = somma dei loro `bet_amount_sats` = somma dei valori
|
||
di quegli input;
|
||
- 70% (arrotondato per difetto) di `pool_amount_sats` = `winner_amount_sats`
|
||
+ commissione di rete del pagamento;
|
||
- il resto (30% più l'arrotondamento) = `fee_amount_sats` +
|
||
`bump_reserve_sats` ([DAT-73]).
|
||
|
||
Quindi `pool_amount_sats` = `winner_amount_sats` + `fee_amount_sats` +
|
||
`bump_reserve_sats` + commissione di rete, al satoshi. La commissione di
|
||
rete esce dalla quota del vincitore, la riserva da quella della
|
||
piattaforma. La ripartizione è nel codice, non in `round_config`.
|
||
*Codice:* `app/rounds/service.py:winner_share` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||
`app/wallet/psbt_builder.py:build_payout_transaction` ·
|
||
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py`
|
||
*Stato:* da implementare (B-79, B-82) · *Oggi:* il pagamento sceglie fra
|
||
tutti gli UTXO confermati dell'indirizzo del round e può avere un resto
|
||
verso quello stesso indirizzo; non c'è
|
||
riserva, e vale `pool_amount_sats` = `winner_amount_sats` +
|
||
`fee_amount_sats` + commissione di rete.
|
||
|
||
- ~~**[DAT-47]**~~ Assorbito da [RND-12](ciclo-di-vita-del-round.md): l'attesa
|
||
delle scommesse in volo prima dell'estrazione è comportamento del round. Il
|
||
testo di qui diceva anche che l'attesa finisce perché ogni riga in volo
|
||
viene "prima o poi" confermata o cancellata: non è una garanzia (una riga
|
||
non viene abbandonata senza una prova, B-78) e non va ripetuta.
|
||
|
||
- ~~**[DAT-48]**~~ Assorbito da [RND-26](ciclo-di-vita-del-round.md): le due
|
||
protezioni contro il doppio pagamento sono comportamento del round. Lo
|
||
schema non ha un vincolo che le imponga; ciò che il database fornisce è il
|
||
`raw_tx_hex` da cui si leggono gli outpoint impegnati ([DAT-37]).
|
||
|
||
- **[DAT-49]** **Le transazioni ritrovano la loro riga senza il txid.** Le righe
|
||
collegate a una `pending_transactions` si ritrovano tramite `round_id`,
|
||
`user_id` e `withdrawal_id` ([DAT-32]); il txid serve solo come ripiego per
|
||
righe senza collegamento.
|
||
*Codice:* `app/bets/confirmation.py:_on_bet_confirmed` ·
|
||
`app/rounds/confirmation.py:_on_payout_confirmed` ·
|
||
*Test:* `tests/unit/test_confirmation.py`
|
||
|
||
## Motore e sessioni
|
||
|
||
- **[DAT-50]** C'è un solo motore asincrono per processo, creato
|
||
all'importazione di `app/db/base.py` da `settings.database_url`
|
||
(`DATABASE_URL`; predefinito `sqlite+aiosqlite:///./plm_lottery.db`, e nel
|
||
container `sqlite+aiosqlite:////app/db_data/plm_lottery.db`, montato da
|
||
`./data/db`). Le sessioni si creano con `AsyncSessionLocal`
|
||
(`async_sessionmaker` con `expire_on_commit=False`): gli oggetti letti restano
|
||
utilizzabili dopo il commit.
|
||
*Codice:* `app/db/base.py` · `app/config.py`
|
||
|
||
- **[DAT-51]** Gli endpoint HTTP ricevono una sessione per richiesta dalla
|
||
dipendenza `get_session`. I task in background ricevono `AsyncSessionLocal`
|
||
come factory e aprono sessioni brevi, chiuse prima di ogni chiamata di rete.
|
||
Il commit è sempre esplicito: `get_session` non ne fa alla fine della
|
||
richiesta.
|
||
*Codice:* `app/db/session.py:get_session` · `app/main.py` ·
|
||
*Test:* `tests/unit/test_broadcast.py`
|
||
|
||
- **[DAT-52]** Su SQLite ogni nuova connessione esegue
|
||
`PRAGMA journal_mode=WAL`, `PRAGMA synchronous=FULL` e
|
||
`PRAGMA busy_timeout=5000` (B-39): lettori e scrittori non si bloccano a
|
||
vicenda, un secondo scrittore aspetta fino a 5 s invece di fallire subito, e
|
||
ogni commit è sul disco prima che il commit ritorni, così un'interruzione di
|
||
corrente non perde un commit già confermato al chiamante (una
|
||
registrazione, e con essa il suo `derivation_index`, B-88). Con un altro
|
||
dialetto non viene eseguito nulla. `PRAGMA foreign_keys` non è tra questi,
|
||
da cui [DAT-04].
|
||
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
|
||
*Test:* `tests/unit/test_db_base.py` · `tests/unit/test_sqlite_transactions.py`
|
||
|
||
- **[DAT-74]** Ogni transazione del database che sposta denaro o ne fissa lo
|
||
stato inizia con `BEGIN IMMEDIATE`, che prende il lock di scrittura di SQLite
|
||
**prima** della prima lettura: le letture su cui la transazione decide
|
||
(saldo, UTXO liberi, partecipanti, stato del round o della riga
|
||
`pending_transactions`) e le scritture che ne seguono vedono lo stesso
|
||
stato, senza che un altro scrittore si inserisca fra le due. Riguarda almeno
|
||
le fasi di scrittura di `place_bet`, `request_withdrawal`, `_trigger_payout`,
|
||
`bump_fee`, l'accredito dei depositi (accredito, marcatura come speso
|
||
all'esterno, reintegro: quest'ultimo solo quando c'è qualcosa da reintegrare,
|
||
perché gira a ogni aggiornamento), le transizioni di stato dello scheduler,
|
||
i gestori di conferma, il riconciliatore e l'allocazione dell'indice di
|
||
derivazione alla registrazione (senza il lock due registrazioni simultanee
|
||
leggevano lo stesso massimo). Si ottiene con `begin_immediate(session)`, che
|
||
chiude l'eventuale transazione di sola lettura già aperta (di solito la
|
||
ricerca dell'utente autenticato) e apre quella nuova con `BEGIN IMMEDIATE`;
|
||
le altre transazioni, in sola lettura, restano `BEGIN` semplice e in WAL non
|
||
aspettano nessuno. Un `OperationalError`
|
||
`database is locked` è il `busy_timeout` di [DAT-52] scaduto: prima di un
|
||
broadcast la transazione viene annullata e l'operazione rifiutata o
|
||
rimandata al giro successivo; **dopo** un broadcast non si annulla nulla
|
||
sulla rete, la riga resta nello stato scritto prima del broadcast
|
||
(`building`) e il riconciliatore la risolve
|
||
([transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) B-88).
|
||
*Codice:* `app/db/base.py:begin_immediate` · `app/db/base.py:_register_sqlite_pragmas` ·
|
||
`app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` ·
|
||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/broadcast.py:bump_fee` ·
|
||
`app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` ·
|
||
`app/deposits/service.py:credit_confirmed_utxos` · `app/auth/routes.py:register` ·
|
||
*Test:* `tests/unit/test_sqlite_transactions.py`
|
||
|
||
## Migrazioni
|
||
|
||
### Politica
|
||
|
||
- **[DAT-53]** Lo schema si cambia solo con una revisione Alembic in
|
||
`migrations/versions/`. Si modifica `app/db/models.py`, poi si genera la
|
||
revisione con `alembic revision --autogenerate -m "..."` e la si **rilegge e
|
||
corregge a mano** prima del commit. La catena è lineare, senza rami.
|
||
`migrations/env.py` prende l'URL da `settings.database_url` e i metadati da
|
||
`Base.metadata`, e non abilita `render_as_batch`: le modifiche a colonne
|
||
esistenti su SQLite vanno scritte a mano dentro `op.batch_alter_table`.
|
||
*Codice:* `migrations/env.py` · `app/db/models.py`
|
||
|
||
- **[DAT-54]** Le migrazioni si applicano **solo** all'avvio del container: il
|
||
comando del `Dockerfile` controlla che esista la master key, esegue
|
||
`alembic upgrade head` e solo se riesce avvia `uvicorn`. Una migrazione che
|
||
fallisce impedisce quindi al server di partire invece di lasciarlo girare su
|
||
uno schema a metà. `alembic upgrade head` non va mai lanciato a mano.
|
||
*Codice:* `Dockerfile`
|
||
|
||
- **[DAT-55]** Una colonna `NOT NULL` aggiunta a una tabella che può avere già
|
||
righe deve dare un valore a quelle righe nella migrazione stessa, in uno di
|
||
due modi: un `server_default` temporaneo, rimosso subito dopo in modo che le
|
||
righe nuove prendano il default dell'ORM (`round_config`, `users.token_version`);
|
||
oppure un backfill esplicito calcolato dai dati (`last_broadcast_at` copiato da
|
||
`broadcast_at`; `rounds.duration_seconds`/`cooldown_seconds` copiati da
|
||
`round_config`, così il round in corso non cambia durata con la migrazione).
|
||
*Codice:* `migrations/versions/53cc70d16e63_add_operational_params_to_round_config.py` ·
|
||
`migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py` ·
|
||
`migrations/versions/283844a44b4a_snapshot_round_timing_onto_the_round_.py`
|
||
|
||
- **[DAT-56]** Gli indici su espressioni (`ix_rounds_single_active`,
|
||
`ix_users_username_lower`) vanno scritti a mano nella migrazione:
|
||
l'autogenerate su SQLite non li sa confrontare e li ignora con un avviso.
|
||
Inoltre `op.batch_alter_table` su SQLite ricrea la tabella e **perde senza
|
||
errori** gli indici su espressioni, perché SQLAlchemy non li legge dallo
|
||
schema. Una migrazione che usi `batch_alter_table` su `rounds` o `users`
|
||
deve quindi, nella stessa `upgrade()`, eliminare e ricreare esplicitamente
|
||
l'indice relativo con SQL grezzo, dopo il blocco `batch_alter_table`. Le
|
||
migrazioni di B-73/B-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`, `bump_reserve_sats`, `payout_blocked_at`),
|
||
`round_participants` (`bet_vout`, `draw_position` e il vincolo unico),
|
||
`round_config` (`deposit_confirmations`, `draw_confirmations`,
|
||
`max_round_pool_sats`, `paused_reason`, con `paused_reason = 'operator'` per
|
||
un'istanza già in pausa), `pending_transactions.failed_at` (backfill da
|
||
`updated_at` per le righe `failed`), e la tabella `pending_transaction_txids`
|
||
(backfill: una riga per `current_txid` e una per `replaced_by_txid` non nullo
|
||
di ogni riga esistente; per la seconda il `raw_hex` non è più disponibile e si
|
||
scrive la stringa vuota).
|
||
|
||
### Differenze note tra modello e schema migrato
|
||
|
||
- `users.token_version`: il modello dichiara `server_default="0"`, ma la
|
||
migrazione `943dbd74d983` rimuove il default del server dopo il backfill. Un
|
||
database migrato non ha quindi un default a livello SQL. Non ha effetti
|
||
pratici, perché ogni inserimento passa dall'ORM che fornisce `0`, e
|
||
l'autogenerate non confronta i default del server, quindi non lo segnala.
|
||
- `rounds.duration_seconds`/`cooldown_seconds`: `server_default` presente sia
|
||
nel modello sia nel database migrato.
|
||
|
||
## Scelte specifiche di SQLite
|
||
|
||
- **Un solo file, un solo processo.** SQLite con WAL regge i sei task in
|
||
background e le richieste HTTP di un singolo processo uvicorn ([DAT-52]). I
|
||
lock per utente e il canale SSE sono comunque in memoria, quindi più
|
||
processi non sono supportati in nessun caso (vedi
|
||
[limiti-noti.md](limiti-noti.md)).
|
||
- **Serializzazione degli scrittori.** SQLite ha un solo scrittore alla volta
|
||
per file; un secondo scrittore aspetta il lock fino al `busy_timeout`
|
||
([DAT-52]) e poi riceve `OperationalError: database is locked`. Non esistono
|
||
conflitti fra snapshot: un `OperationalError` è sempre un'attesa scaduta.
|
||
Le transazioni che spostano denaro prendono il lock con `BEGIN IMMEDIATE`
|
||
prima di leggere ([DAT-74]), quindi il compare-and-set di [DAT-16] e le
|
||
letture che lo precedono stanno nella stessa transazione. Il driver
|
||
(pysqlite) non apre più le transazioni da solo (`isolation_level = None`):
|
||
è l'evento `begin` del motore a emettere `BEGIN` o `BEGIN IMMEDIATE`, così
|
||
ogni lettura sta dentro una transazione vera (B-88).
|
||
- **Indice parziale su espressione.** `ix_rounds_single_active` usa sia un'espressione
|
||
(`(1)`) sia una clausola `WHERE`; SQLite e PostgreSQL supportano entrambe, e il
|
||
modello dichiara la clausola per tutti e due (`sqlite_where`,
|
||
`postgresql_where`). La migrazione la crea invece con SQL grezzo
|
||
(`CREATE UNIQUE INDEX ... ON rounds ((1)) WHERE ...`).
|
||
- **Timestamp senza fuso.** SQLite non conserva il fuso orario ([DAT-02]).
|
||
- **Chiavi esterne non applicate** ([DAT-04]).
|
||
- **`Text` invece di `VARCHAR` senza lunghezza** per i contenuti di lunghezza
|
||
arbitraria (`raw_tx_hex`, `payload_json`, `description`), così lo schema resta
|
||
portabile ad altri database (B-47).
|
||
- **Portabilità.** I pragma sono limitati al dialetto `sqlite` e l'indice del
|
||
round attivo è dichiarato anche per PostgreSQL. Un `DATABASE_URL` PostgreSQL
|
||
non è però mai stato provato, e il database supportato resta SQLite.
|