The two Mermaid diagrams under flowchart/ described the intended flow but had drifted from the code and carried no link back to it. docs/ now holds the specification instead: 15 Italian documents (flows, architecture, data model, API, security, audit log, operations, decisions and limits, bug history) with 472 numbered requirements, each pointing at the function that implements it and the test that covers it. docs/processo-di-modifica.md defines the ID convention and the spec -> code -> test change workflow, and CLAUDE.md now names docs/ as authoritative. Writing the spec from the code also corrected stale claims in CLAUDE.md (B-41's get_history check, BUGS.md still tracking B-71/B-72, Cache-Control scope, the error contract's bare-string exceptions) and in the operator guides (payouts do retry, only `drawing` fails to resume, rounds do not open without fee_address). The B-70 doc test now pins the prose in docs/scommesse-e-prelievi.md instead of the deleted diagram. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
61 KiB
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; lo
schema effettivo di un'istanza è quello prodotto dalla catena di migrazioni in
migrations/versions/. Le due cose coincidono (vedi
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, scommesse-e-prelievi.md, ciclo-di-vita-del-round.md, transazioni-e-riconciliazione.md;
- il catalogo dei tipi di evento di
audit_log: audit-log.md; - gli endpoint che leggono e scrivono queste tabelle: api.md;
- chiavi, password e token: sicurezza.md;
- backup e interventi manuali sul database: operativita.md;
- la storia dei bug citati come
B-nn: storico-bug.md.
Indice
- Diagramma delle relazioni
- Convenzioni comuni
- Tabelle:
users·utxo_events·rounds·round_participants·round_config·pending_transactions·withdrawals·bug_reports·audit_log·alembic_version - Indici e vincoli
- Invarianti tra tabelle
- Motore e sessioni
- Migrazioni
- Scelte specifiche di SQLite
Diagramma delle relazioni
Nove tabelle applicative. round_config non ha relazioni: è una riga singola
di parametri. L'indirizzo del montepremi (il "pool") non è in nessuna tabella:
è derivato dalla master key (m/84'/746'/0'/1/0,
app/wallet/hd.py:derive_pool_key) e i suoi UTXO si leggono dalla rete.
erDiagram
users ||--o{ utxo_events : "possiede"
users ||--o{ round_participants : "scommette"
rounds ||--o{ round_participants : "raccoglie"
users |o--o{ rounds : "vince (winner_user_id)"
users ||--o{ withdrawals : "chiede"
rounds |o--o{ pending_transactions : "bet / payout"
withdrawals |o--o{ pending_transactions : "withdrawal"
users |o--o{ pending_transactions : "bet / withdrawal"
users |o--o{ bug_reports : "segnala"
users |o--o{ audit_log : "riguarda"
rounds |o--o{ audit_log : "riguarda"
users {
int id PK
string username UK "unico anche su lower(username)"
string password_hash "Argon2"
int derivation_index UK
string address UK "P2WPKH plm1..."
bigint cached_balance_sats "cache di utxo_events"
int token_version
datetime created_at
}
utxo_events {
int id PK
int user_id FK
string txid "UK con vout"
int vout
bigint amount_sats
int confirmed_height
datetime confirmed_at
string spent_txid "NULL, txid o external-spend"
}
rounds {
int id PK
string status "open closing drawing paying_out closed"
datetime opened_at
datetime closed_at
int duration_seconds "snapshot"
int cooldown_seconds "snapshot"
datetime drawing_started_at
int draw_block_height
string draw_block_hash
string seed_int
int winner_user_id FK
bigint pool_amount_sats
bigint winner_amount_sats
bigint fee_amount_sats
string payout_txid
}
round_participants {
int id PK
int round_id FK "UK con user_id"
int user_id FK
bigint bet_amount_sats
string bet_txid
datetime broadcast_at
datetime confirmed_at
string status "building broadcast confirmed"
}
round_config {
int id PK
string fee_address
bigint bet_amount_sats
int round_duration_seconds
int round_cooldown_seconds
int draw_animation_seconds
int fee_rate_sat_vb
int rbf_timeout_seconds
bool paused
datetime updated_at
}
pending_transactions {
int id PK
string kind "bet payout withdrawal"
int round_id FK
int withdrawal_id FK
int user_id FK
string current_txid
int fee_rate_sat_vb
text raw_tx_hex
datetime broadcast_at
datetime last_broadcast_at
string status "building pending confirmed failed"
string replaced_by_txid
int attempt_count
string failure_reason
datetime updated_at
}
withdrawals {
int id PK
int user_id FK
string external_address
bigint amount_requested_sats
bigint amount_sent_sats
string txid
string status "building broadcast confirmed failed"
datetime created_at
datetime confirmed_at
}
bug_reports {
int id PK
text description
string contact
int user_id FK
string status "open read resolved"
datetime created_at
}
audit_log {
int id PK
string event_type
text payload_json
int user_id FK
int round_id FK
datetime created_at
}
Convenzioni comuni
-
[DAT-01] Ogni importo è un numero intero di satoshi (1 PLM = 100 000 000 sat), in una colonna
BigIntegeril cui nome finisce in_sats. Nessun importo passa mai per unfloat: 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 condatetime.now(timezone.utc); le colonne sonoDateTimesenza 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 conisoformat_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)), senzaCHECKné 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'indiceix_rounds_single_active([DAT-17]). Codice:app/db/models.py -
[DAT-04] Le chiavi esterne sono dichiarate ma, su SQLite, non applicate:
PRAGMA foreign_keysnon viene attivato ([DAT-52]). Nessuna chiave esterna haON DELETE. Le sole cancellazioni fisiche nel codice sono quelle di una scommessa fallita (la rigaround_participantse la suapending_transactions, [DAT-24], [DAT-38]); nessun'altra tabella perde righe, quindi i riferimenti restano validi per costruzione, non per vincolo. Codice:app/db/base.py:_register_sqlite_pragmas·app/bets/service.py:_release_failed_bet·app/tx/reconcile.py:_abandon -
[DAT-05] Ogni chiave primaria è un intero autoincrementale
id, assegnato dal database alflush/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_lowersulower(username), e registrazione e login confrontano confunc.lower(...). Il valore è comunque memorizzato così come l'utente l'ha scritto, ed è quello mostrato in/admine 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 unicoix_users_usernamesulla 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 comemax(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 rispondeusername_taken.derivation_indexeaddressnon 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 diamount_satsdelle righeutxo_eventsdell'utente conspent_txid IS NULL. La fonte di verità èutxo_events; la colonna è scritta solo darecompute_balance, che va chiamata nella stessa transazione di qualunque modifica alle righeutxo_eventsdell'utente (un inserimento o un cambio dispent_txidda o versoNULL). I chiamanti sono:credit_confirmed_utxos,reinstate_reappeared_utxos,mark_utxos_spent_externally,place_bet,_release_failed_bet,request_withdrawal,_release_failed_withdrawale il_abandondel riconciliatore. Il retarget dibump_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). 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_versionparte 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 claimtvnon coincide con il valore corrente è rifiutato (B-34). Un token senza claimtvvale cometv = 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).
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
id |
Integer PK | no | autoincrement | — | database |
user_id |
FK users.id, indicizzata |
no | — | Proprietario dell'indirizzo pagato | app/deposits/service.py:credit_confirmed_utxos |
txid |
String(64) | no | — | Transazione che ha creato l'output | credit_confirmed_utxos |
vout |
Integer | no | — | Posizione dell'output | credit_confirmed_utxos |
amount_sats |
BigInteger | no | — | Valore dell'output (value di listunspent) |
credit_confirmed_utxos |
confirmed_height |
Integer | no | — | Altezza del blocco riportata dal server (sempre > 0) | credit_confirmed_utxos |
confirmed_at |
DateTime | no | utcnow |
Istante dell'accredito nel database, non l'orario del blocco | ORM |
spent_txid |
String(64) | sì | NULL |
Chi ha speso l'output, vedi [DAT-12] | vedi tabella sotto |
-
[DAT-10] Una riga si inserisce solo per un output con
height > 0(almeno 1 conferma) che non è già presente, e solo dopo che gli altri server Electrum hanno corroborato lo stesso outpoint con lo stesso importo (B-59; la corroborazione è in connessione-electrum.md). 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, solospent_txidcambia. Codice:app/deposits/service.py:find_new_credit_candidates·app/deposits/service.py:credit_confirmed_utxos·app/electrum/listener.py:ElectrumListener.refresh_user· Test:tests/unit/test_deposits.py,tests/unit/test_electrum_listener.py -
[DAT-11] Gli UTXO del pool non sono in
utxo_events: il pool non è un utente. Il pagamento sceglie i suoi input da unalistunspentdell'indirizzo del pool, escludendo gli outpoint già impegnati da un pagamento non ancora risolto (letti dalraw_tx_hexdelle righepending_transactionsdi tipopayoutinbuilding/pending). Per lo stesso motivo l'abbandono di un pagamento non ha input da liberare. Codice:app/rounds/scheduler.py:_reserved_payout_outpoints·app/rounds/scheduler.py:RoundScheduler._trigger_payout·app/tx/reconcile.py:_abandon· Test:tests/unit/test_scheduler.py -
[DAT-12]
spent_txidha 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 dallalistunspente 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 NULLtxid app/bets/service.py:place_bet,app/withdrawals/service.py:request_withdrawalfase 1, prima del broadcast txid nuovo txid app/tx/broadcast.py:_retarget_txid_referencesbump RBF txid NULLapp/bets/service.py:_release_failed_bet,app/withdrawals/service.py:_release_failed_withdrawalla rete ha rifiutato il broadcast txid NULLapp/tx/reconcile.py:_release_inputsla transazione è stata abbandonata ([DAT-13]) NULLexternal-spendapp/deposits/service.py:mark_utxos_spent_externallyspesa esterna corroborata external-spendNULLapp/deposits/service.py:reinstate_reappeared_utxosl'outpoint ricompare come non speso Chi sceglie gli input di una nuova transazione considera solo le righe con
spent_txid IS NULL. Codice: le funzioni in tabella · Test:tests/unit/test_deposits.py,tests/unit/test_bets.py,tests/unit/test_withdrawals.py,tests/unit/test_reconcile.py -
[DAT-13] Il riconciliatore libera un input solo se il suo
spent_txidè uguale alcurrent_txiddella riga che sta abbandonando: se nel frattempo lo stesso output risulta impegnato da un'altra transazione, quella rivendicazione è quella valida e non va toccata. Codice:app/tx/reconcile.py:_release_inputs· Test:tests/unit/test_reconcile.py -
[DAT-14] Per ogni output impegnato,
spent_txidè sempre il txid corrente della transazione che lo spende, cioè ilcurrent_txiddella sua rigapending_transactions. Un bump RBF cambia il txid e quindi riscrive, nella stessa transazione del database, ognispent_txiduguale al vecchio txid. Senza questo allineamento [DAT-13] non riconoscerebbe più gli input di una transazione rimpiazzata. Codice:app/tx/broadcast.py:bump_fee·app/tx/broadcast.py:_retarget_txid_references· Test:tests/unit/test_broadcast.py
rounds
Un round della lotteria, dall'apertura al pagamento confermato. I round chiusi restano nella tabella come storico.
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
id |
Integer PK | no | autoincrement | — | database |
status |
String(16) | no | "open" |
Fase del round, vedi [DAT-15] | vedi diagramma |
opened_at |
DateTime | no | utcnow |
Apertura; base della scadenza | app/rounds/service.py:open_new_round_if_needed (default) |
closed_at |
DateTime | sì | NULL |
Vedi [DAT-19] | app/rounds/scheduler.py:RoundScheduler._tick; app/rounds/confirmation.py:_on_payout_confirmed |
duration_seconds |
Integer | no | 600 (anche server_default) |
Durata di questo round, copiata da round_config all'apertura |
open_new_round_if_needed |
cooldown_seconds |
Integer | no | 30 (anche server_default) |
Pausa dopo questo round, copiata da round_config all'apertura |
open_new_round_if_needed |
drawing_started_at |
DateTime | sì | NULL |
Istante del passaggio a drawing |
RoundScheduler._close_and_draw |
draw_block_height |
Integer | sì | NULL |
Altezza del blocco usato per l'estrazione | _close_and_draw |
draw_block_hash |
String(64) | sì | NULL |
Hash (esadecimale) di quel blocco | _close_and_draw |
seed_int |
String(128) | sì | NULL |
Il seme: int(draw_block_hash, 16) scritto in decimale (fino a 78 cifre, per questo è una stringa) |
_close_and_draw |
winner_user_id |
FK users.id |
sì | NULL |
Il vincitore | _close_and_draw |
pool_amount_sats |
BigInteger | sì | NULL |
Montepremi: somma delle scommesse confermate | _close_and_draw |
winner_amount_sats |
BigInteger | sì | NULL |
Quanto riceve il vincitore (70% meno la commissione di rete) | RoundScheduler._trigger_payout |
fee_amount_sats |
BigInteger | sì | NULL |
Quanto va a fee_address (30% più il resto dell'arrotondamento) |
_trigger_payout |
payout_txid |
String(64) | sì | NULL |
Txid corrente della transazione di pagamento | _trigger_payout; _retarget_txid_references; app/tx/reconcile.py:_abandon |
-
[DAT-15]
statusassume solo i valoriopen,closing,drawing,paying_out,closed. I primi quattro sono gli stati attivi. Le transizioni sono esclusivamente queste:stateDiagram-v2 [*] --> open: open_new_round_if_needed open --> closing: _tick, scadenza superata closing --> closed: _close_and_draw, nessun partecipante confermato closing --> drawing: _close_and_draw, nessuna scommessa in volo drawing --> paying_out: _close_and_draw, blocco corroborato paying_out --> closed: _on_payout_confirmed closed --> [*]Da A Funzione Condizione — openapp/rounds/service.py:open_new_round_if_needednessun round attivo, non in pausa, fee_addressimpostato, cooldown del round precedente trascorsoopenclosingapp/rounds/scheduler.py:RoundScheduler._ticknow >= opened_at + duration_secondsclosingclosedRoundScheduler._close_and_drawnessuna scommessa building/broadcaste nessunaconfirmedclosingdrawingRoundScheduler._close_and_drawnessuna scommessa building/broadcast, almeno unaconfirmeddrawingpaying_outRoundScheduler._close_and_draw_wait_for_next_blockha restituito un blocco corroboratopaying_outclosedapp/rounds/confirmation.py:_on_payout_confirmedla transazione di pagamento ha 1 conferma Un pagamento abbandonato non cambia lo stato: il round resta
paying_outconpayout_txid = NULLe il pagamento viene ricostruito. L'unica altra scrittura distatusfuori da questa tabella è la migrazione8a1c4e7b2d90, che una tantum portò aclosedi round attivi duplicati ([DAT-59]). Il significato di ogni fase è in ciclo-di-vita-del-round.md. Codice: le funzioni in tabella · Test:tests/unit/test_scheduler.py,tests/unit/test_rounds_service.py,tests/unit/test_confirmation.py -
[DAT-16]
place_betprotegge l'inserimento del partecipante con un compare-and-set sulla riga del round, nella stessa transazione:UPDATE rounds SET status = 'open' WHERE id = :id AND status = 'open'. Se l'aggiornamento non tocca esattamente una riga (o SQLite segnala un conflitto di scrittura) la transazione viene annullata e la scommessa rifiutata prima di qualunque broadcast (B-53). L'aggiornamento non cambia alcun valore: serve a prendere il lock di scrittura sulla riga, così il passaggio aclosinge l'inserimento non possono intrecciarsi. Codice:app/bets/service.py:place_bet· Test:tests/unit/test_bets.py -
[DAT-17] Al massimo un round attivo alla volta, come vincolo del database:
ix_rounds_single_activeè un indice unico sull'espressione costante(1), parziale sugli stati attivi (WHERE status IN ('open', 'closing', 'drawing', 'paying_out')). La tabella può quindi contenere quanti roundclosedsi vuole, ma un secondo inserimento attivo fallisce conIntegrityError(B-09).open_new_round_if_neededgestisce il conflitto annullando il proprio inserimento e adottando il round del concorrente, con al massimo 3 tentativi. L'indice è dichiarato sia consqlite_wheresia conpostgresql_where, così resta parziale anche su PostgreSQL.get_active_roundordina comunque periddecrescente e registra un errore se ne trova più di uno. Codice:app/db/models.py:Round·app/rounds/service.py:open_new_round_if_needed·app/rounds/service.py:get_active_round· Test:tests/unit/test_rounds_service.py -
[DAT-18]
duration_secondsecooldown_secondssono una copia dei valori diround_configpresa quando il round si apre, e non cambiano più (B-61). La scadenza del round èopened_at + duration_seconds; la pausa prima del round successivo è ilcooldown_secondsdel round chiuso più recente, contata dal suoclosed_at. Modificareround_configinfluisce quindi solo sui round aperti dopo. Codice:app/rounds/service.py:open_new_round_if_needed·app/rounds/service.py:round_deadline· Test:tests/unit/test_rounds_service.py,tests/unit/test_scheduler.py -
[DAT-19]
closed_atsi scrive due volte. La prima al passaggioopen → closing, con l'ora della chiusura delle scommesse. La seconda alla conferma del pagamento (paying_out → closed), dove viene sovrascritto con l'ora della conferma, così la pausa tra i round parte da quando il vincitore è stato davvero pagato. Un round chiuso senza partecipanti tiene il primo valore. Codice:app/rounds/scheduler.py:RoundScheduler._tick·app/rounds/confirmation.py:_on_payout_confirmed· Test:tests/unit/test_rounds_service.py(conteggio della pausa daclosed_at; la seconda scrittura non ha un test dedicato) -
[DAT-20]
drawing_started_atsi scrive una sola volta, al passaggio adrawing. Da lì si misura quanto l'estrazione sta aspettando un blocco (draw_waiting_sinceinGET /rounds/current, le vocidraw_stalleddel 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_idepool_amount_satssi scrivono insieme, nella stessa transazione che porta il round apaying_out, e poi non cambiano più.pool_amount_satsè la somma dibet_amount_satsdei soli partecipanticonfirmed, gli stessi fra cui si estrae il vincitore. Codice:app/rounds/scheduler.py:RoundScheduler._close_and_draw· Test:tests/unit/test_scheduler.py -
[DAT-22]
winner_amount_sats,fee_amount_satsepayout_txidsi scrivono nella fase 3 di_trigger_payout, insieme alla rigapending_transactionsdel pagamento e prima del broadcast (B-25).payout_txidsegue poi il txid corrente a ogni bump RBF, e tornaNULLquando il riconciliatore abbandona il pagamento.winner_amount_satsefee_amount_satsinvece non vengono azzerati da un abbandono: li riscrive il tentativo successivo. Un bump RBF non li cambia, perché riduce solo il resto del pool. Codice:app/rounds/scheduler.py:RoundScheduler._trigger_payout·app/tx/broadcast.py:_retarget_txid_references·app/tx/reconcile.py:_abandon· Test:tests/unit/test_scheduler.py,tests/unit/test_broadcast.py,tests/unit/test_reconcile.py
round_participants
Una scommessa: un utente in un round.
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
id |
Integer PK | no | autoincrement | — | database |
round_id |
FK rounds.id, indicizzata |
no | — | Il round | app/bets/service.py:place_bet |
user_id |
FK users.id, indicizzata |
no | — | Chi scommette | place_bet |
bet_amount_sats |
BigInteger | no | — | Quanto arriva davvero al pool: costo della scommessa meno la commissione di rete | place_bet |
bet_txid |
String(64) | no | — | Txid corrente della scommessa | place_bet; app/tx/broadcast.py:_retarget_txid_references |
broadcast_at |
DateTime | no | utcnow |
Ordine di estrazione, vedi [DAT-25] | place_bet |
confirmed_at |
DateTime | sì | NULL |
Istante in cui la scommessa è risultata confermata | app/bets/confirmation.py:_on_bet_confirmed |
status |
String(16) | no | "broadcast" (mai usato: si scrive sempre esplicitamente) |
Vedi [DAT-24] | vedi diagramma |
-
[DAT-23] Al massimo una scommessa per utente per round: vincolo unico su (
round_id,user_id).place_betlo 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]
statusassume solo i valoribuilding,broadcast,confirmed. Non esiste uno stato di fallimento: una scommessa che non va a buon fine viene cancellata, e l'utente non risulta nel round.stateDiagram-v2 [*] --> building: place_bet fase 1 building --> broadcast: place_bet fase 2 / _promote building --> confirmed: _on_bet_confirmed broadcast --> confirmed: _on_bet_confirmed building --> [*]: cancellata (_release_failed_bet / _abandon) broadcast --> [*]: cancellata (_abandon) confirmed --> [*]Da A Funzione — buildingapp/bets/service.py:place_bet(fase 1, prima del broadcast)buildingbroadcastplace_bet(fase 2, broadcast riuscito);app/tx/reconcile.py:_promotebuilding,broadcastconfirmedapp/bets/confirmation.py:_on_bet_confirmedbuildingcancellata app/bets/service.py:_release_failed_bet(broadcast rifiutato)building,broadcastcancellata app/tx/reconcile.py:_abandonSe il compare-and-set di [DAT-16] fallisce, la riga
buildingnon arriva nemmeno al commit. Codice: le funzioni in tabella · Test:tests/unit/test_bets.py,tests/unit/test_confirmation.py,tests/unit/test_reconcile.py -
[DAT-25]
broadcast_atè l'istante della fase 1 diplace_bet, appena prima del broadcast, e non viene mai riscritto (nemmeno da un bump RBF). L'estrazione ordina i partecipanti confermati perbroadcast_atcrescente. Codice:app/bets/service.py:place_bet·app/rounds/scheduler.py:RoundScheduler._close_and_draw· Test:tests/unit/test_scheduler.py -
[DAT-26]
bet_amount_satsè l'importo del primo output della scommessa (recipient_sats), cioèround_config.bet_amount_satsin vigore al momento della scommessa meno la commissione di rete. Non cambia più: un bump RBF riduce il resto dell'utente, non l'output verso il pool. Per questo il montepremi si calcola sommando questa colonna, non moltiplicando il numero di partecipanti per il costo attuale (B-11). Codice:app/bets/service.py:place_bet·app/wallet/psbt_builder.py:build_signed_transaction·app/api/routes/rounds.py:current_round· Test:tests/unit/test_bets.py -
[DAT-27] Il numero di righe di un round, contando tutti gli stati, non supera
MAX_PARTICIPANTS_PER_ROUND(400): la 401ª scommessa è rifiutata conround_fullprima che si muova denaro (B-52). Codice:app/bets/service.py:place_bet·app/wallet/psbt_builder.py· Test:tests/unit/test_bets.py
round_config
I parametri operativi, in una sola riga, modificabili a caldo da /admin
senza riavviare (vedi decisioni-e-limiti.md).
| Colonna | Tipo | Default | Limiti accettati da PUT /admin/config |
Significato | Quando ha effetto |
|---|---|---|---|---|---|
id |
Integer PK | autoincrement | — | — | — |
fee_address |
String(128), non null | nessun default nel modello; la riga nasce con "" |
indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Subito: si legge a ogni costruzione del pagamento, quindi vale anche per il round in corso se il suo pagamento non è ancora stato costruito |
bet_amount_sats |
BigInteger | 1_000_000_000 (10 PLM) |
> 0, ≤ 100 000 PLM |
Costo della scommessa; anche il prelievo minimo | Subito, per le scommesse e i prelievi successivi; le scommesse già piazzate tengono il proprio importo ([DAT-26]) |
round_duration_seconds |
Integer | 600 |
30 s – 7 giorni | Durata del round | Dal round successivo ([DAT-18]) |
round_cooldown_seconds |
Integer | 30 |
0 s – 24 h | Pausa dopo la chiusura | Dal round successivo: la pausa dopo il round in corso è quella copiata alla sua apertura ([DAT-18]) |
draw_animation_seconds |
Integer | 20 |
0 – 600 s | Durata minima dell'animazione dell'estrazione nel frontend; non condiziona l'estrazione vera | Subito (letto da GET /rounds/current) |
fee_rate_sat_vb |
Integer | 1 |
1 – MAX_FEE_RATE_SAT_VB (10 000) |
Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio fee_rate_sat_vb |
rbf_timeout_seconds |
Integer | 900 |
60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) |
paused |
Boolean | False |
non modificabile da PUT /admin/config |
Pausa di manutenzione | Subito per l'apertura del round successivo; il round in corso non è toccato |
updated_at |
DateTime | utcnow, aggiornato a ogni modifica (onupdate) |
— | Ultima modifica della riga | ORM |
-
[DAT-28] La tabella contiene al più una riga, creata alla prima lettura con i default di colonna di
RoundConfigefee_address = "". Tutti leggono la configurazione attraversoget_round_config, che prende la prima riga trovata. Il database non impone l'unicità della riga: la garanzia è che sologet_round_configne 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/configsono esattamentefee_address,bet_amount_sats,round_duration_seconds,round_cooldown_seconds,fee_rate_sat_vb,rbf_timeout_seconds,draw_animation_seconds, entro i limiti della tabella sopra. Ogni modifica effettiva scrive una voceconfig_updatedcon il valore prima e dopo, e un valore uguale a quello attuale non conta come modifica. Codice:app/api/routes/admin.py:update_config·app/api/routes/admin.py:RoundConfigUpdate· Test:tests/unit/test_admin.py -
[DAT-30]
pausedsi modifica solo conPOST /admin/pauseePOST /admin/resume, che scrivonolottery_pausedelottery_resumednel registro (B-10). Codice:app/api/routes/admin.py:pause_lottery·app/api/routes/admin.py:resume_lottery· Test:tests/unit/test_admin.py -
[DAT-31] Con
fee_addressvuoto (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).
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
id |
Integer PK | no | autoincrement | — | database |
kind |
String(16) | no | — | bet, withdrawal o payout |
chi crea la riga |
round_id |
FK rounds.id |
sì | NULL |
Il round (bet, payout) |
place_bet; _trigger_payout |
withdrawal_id |
FK withdrawals.id |
sì | NULL |
Il prelievo (withdrawal) |
request_withdrawal |
user_id |
FK users.id |
sì | NULL |
Il mittente (bet, withdrawal); NULL per payout |
place_bet; request_withdrawal |
current_txid |
String(64) | no | — | Txid della versione più recente della transazione | creazione; bump_fee |
fee_rate_sat_vb |
Integer | no | — | Commissione della versione corrente, in sat/vB | creazione; bump_fee |
raw_tx_hex |
Text | no | — | Transazione firmata della versione corrente | creazione; bump_fee |
broadcast_at |
DateTime | no | utcnow |
Prima trasmissione, vedi [DAT-35] | ORM, alla creazione |
last_broadcast_at |
DateTime | no | utcnow |
Ultima trasmissione, vedi [DAT-35] | ORM; bump_fee |
status |
String(16) | no | "pending" (mai usato: si crea sempre building) |
Vedi [DAT-33] | vedi diagramma |
replaced_by_txid |
String(64) | sì | NULL |
Il txid sostituito dall'ultimo bump (nonostante il nome, punta all'indietro) | bump_fee |
attempt_count |
Integer | no | 1 |
1 + numero di bump riusciti | bump_fee |
failure_reason |
String(128) | sì | NULL |
Perché la transazione è fallita; solo con status = failed |
_abandon; _release_failed_withdrawal |
updated_at |
DateTime | no | utcnow, onupdate |
Ultima modifica | ORM |
Le funzioni citate sono app/bets/service.py:place_bet,
app/withdrawals/service.py:request_withdrawal,
app/rounds/scheduler.py:RoundScheduler._trigger_payout,
app/tx/broadcast.py:bump_fee, app/tx/reconcile.py:_abandon e
app/withdrawals/service.py:_release_failed_withdrawal.
-
[DAT-32] Le colonne di collegamento dipendono da
kind:kindround_iduser_idwithdrawal_idFirmata con Riga di dominio collegata betil round lo scommettitore NULLchiave dell'utente round_participants(round_id,user_id)withdrawalNULLchi preleva il prelievo chiave dell'utente withdrawals(withdrawal_id)payoutil round NULLNULLchiave del pool rounds(round_id)I gestori delle conferme e del riconciliatore ritrovano la riga di dominio da questi identificativi, che non cambiano, e non dal txid, che cambia a ogni bump (B-02). L'indirizzo di cui controllare la cronologia si ricava da
kindeuser_id. Codice:app/tx/pending_address.py:own_address_for·app/tx/broadcast.py:_signing_context·app/bets/confirmation.py:_on_bet_confirmed·app/withdrawals/confirmation.py:_on_withdrawal_confirmed·app/rounds/confirmation.py:_on_payout_confirmed· Test:tests/unit/test_confirmation.py,tests/unit/test_pending_address.py -
[DAT-33]
statusassume solo i valoribuilding,pending,confirmed,failed; gli ultimi due sono terminali.stateDiagram-v2 [*] --> building: creazione, prima del broadcast building --> pending: broadcast riuscito / _promote building --> failed: _release_failed_withdrawal / _abandon building --> [*]: cancellata (_release_failed_bet) pending --> confirmed: poll_once pending --> failed: _abandon confirmed --> [*] failed --> [*]Da A Funzione — buildingapp/bets/service.py:place_bet,app/withdrawals/service.py:request_withdrawal,app/rounds/scheduler.py:RoundScheduler._trigger_payoutbuildingpendingle stesse tre, dopo il broadcast riuscito; app/tx/reconcile.py:_promotependingconfirmedapp/tx/confirmation.py:poll_once(poi il gestore delkind)building,pendingfailedapp/tx/reconcile.py:_abandonbuildingfailedapp/withdrawals/service.py:_release_failed_withdrawalbuildingcancellata app/bets/service.py:_release_failed_betUn pagamento il cui broadcast fallisce resta
buildingfinché il riconciliatore non lo risolve. Il poller delle conferme guarda solo le righepending. Codice: le funzioni in tabella · Test:tests/unit/test_confirmation.py,tests/unit/test_reconcile.py,tests/unit/test_scheduler.py,tests/unit/test_bets.py,tests/unit/test_withdrawals.py -
[DAT-34] Dopo la creazione,
current_txid,raw_tx_hex,fee_rate_sat_vb,replaced_by_txid,attempt_countelast_broadcast_atcambiano solo inbump_fee, tutti nella stessa transazione del database, solo dopo che il nuovo broadcast è riuscito e solo per una riga ancorapending.fee_rate_sat_vbregistra la commissione effettivamente pagata dalla nuova versione, non quella obiettivo.replaced_by_txidconserva solo il txid immediatamente precedente: la catena completa dei rimpiazzi non è memorizzata. Codice:app/tx/broadcast.py:bump_fee· Test:tests/unit/test_broadcast.py -
[DAT-35]
broadcast_atè la prima trasmissione e non viene mai riscritta: da lì il riconciliatore misura dopo quanto abbandonare (120 s perbuilding, 6 h perpending).last_broadcast_atè l'ultima trasmissione: la aggiornabump_feee da lìshould_bumpmisura se serve un altro bump. Tenerle separate impedisce che bump ripetuti rinviino all'infinito l'abbandono (B-27). Codice:app/tx/reconcile.py:_is_due·app/tx/broadcast.py:should_bump·app/tx/broadcast.py:bump_fee· Test:tests/unit/test_broadcast.py,tests/unit/test_reconcile.py -
[DAT-36]
failure_reasonsi scrive solo quando la riga passa afailed, troncato a 128 caratteri:"not found on chain"dal riconciliatore, il messaggio d'errore della rete per un prelievo rifiutato. Codice:app/tx/reconcile.py:_abandon·app/withdrawals/service.py:_release_failed_withdrawal· Test:tests/unit/test_reconcile.py -
[DAT-37] Per le righe
betewithdrawalinbuilding/pending,raw_tx_hexè la fonte del saldo "in attesa" (gli output che tornano all'indirizzo dell'utente). Per le righepayoutinbuilding/pendingdefinisce 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_transactionsnon si cancellano, con una sola eccezione: la rigabetil cui broadcast è stato rifiutato, cancellata insieme al partecipante da_release_failed_bet. Un prelievo rifiutato invece lascia la riga, infailed. Codice:app/bets/service.py:_release_failed_bet·app/withdrawals/service.py:_release_failed_withdrawal· Test:tests/unit/test_bets.py,tests/unit/test_withdrawals.py
withdrawals
Una richiesta di prelievo verso un indirizzo esterno. A differenza di una scommessa, resta nella tabella anche quando fallisce: è un'istruzione data dall'utente.
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
id |
Integer PK | no | autoincrement | — | database |
user_id |
FK users.id, indicizzata |
no | — | Chi preleva | app/withdrawals/service.py:request_withdrawal |
external_address |
String(128) | no | — | Destinatario: un indirizzo PLM valido, diverso da quello dell'utente | request_withdrawal |
amount_requested_sats |
BigInteger | no | — | Importo chiesto dall'utente | request_withdrawal |
amount_sent_sats |
BigInteger | sì (sempre valorizzata dal codice) | NULL |
Quanto riceve il destinatario, vedi [DAT-40] | request_withdrawal |
txid |
String(64) | sì | NULL |
Txid corrente; NULL dopo un fallimento |
request_withdrawal; _retarget_txid_references; _release_failed_withdrawal; _abandon |
status |
String(16) | no | "pending" (mai usato: si crea sempre building) |
Vedi [DAT-39] | vedi diagramma |
created_at |
DateTime | no | utcnow |
Istante della richiesta | ORM |
confirmed_at |
DateTime | sì | NULL |
Istante in cui il prelievo è risultato confermato | app/withdrawals/confirmation.py:_on_withdrawal_confirmed |
-
[DAT-39]
statusassume solo i valoribuilding,broadcast,confirmed,failed; gli ultimi due sono terminali. Il passaggio afailedazzeratxid.stateDiagram-v2 [*] --> building: request_withdrawal building --> broadcast: broadcast riuscito / _promote building --> confirmed: _on_withdrawal_confirmed broadcast --> confirmed: _on_withdrawal_confirmed building --> failed: _release_failed_withdrawal / _abandon broadcast --> failed: _abandon confirmed --> [*] failed --> [*]Da A Funzione — buildingapp/withdrawals/service.py:request_withdrawalbuildingbroadcastrequest_withdrawal(broadcast riuscito);app/tx/reconcile.py:_promotebuilding,broadcastconfirmedapp/withdrawals/confirmation.py:_on_withdrawal_confirmedbuildingfailedapp/withdrawals/service.py:_release_failed_withdrawalbuilding,broadcastfailedapp/tx/reconcile.py:_abandonCodice: le funzioni in tabella · Test:
tests/unit/test_withdrawals.py,tests/unit/test_reconcile.py -
[DAT-40]
amount_requested_satsè ciò che l'utente ha chiesto;amount_sent_satsè l'output effettivo verso il destinatario: l'importo chiesto meno la commissione di rete e, per un prelievo dell'intero saldo, meno quanto serve a lasciare un resto diDUST_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]
statusassume solo i valoriopen,read,resolved. Nasceopen. 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 scrivebug_report_status_changedcon il valore prima e dopo, e riassegnare lo stesso valore non scrive nulla (B-60).readesiste 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.
| 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 diaudit_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_atdell'ultima vocepayout_faileddi 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_payoutscrive una vocepayout_failed. Codice:app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due·app/rounds/scheduler.py:RoundScheduler._log_payout_failure· Test:tests/unit/test_scheduler.py
alembic_version
Tabella di servizio di Alembic: una riga con la revisione applicata
(version_num). Non è nel modello e il codice applicativo non la legge.
Indici e vincoli
| Nome | Tabella | Definizione | Perché |
|---|---|---|---|
PK id |
tutte | intero autoincrementale | [DAT-05] |
ix_users_username |
users |
UNIQUE (username) |
unicità esatta, dallo schema iniziale; ridondante rispetto alla riga sotto |
ix_users_username_lower |
users |
UNIQUE (lower(username)) |
username senza distinzione di maiuscole, [DAT-06] (B-57) |
unico derivation_index |
users |
UNIQUE |
un indice HD, quindi un indirizzo, per utente, [DAT-07] |
unico address |
users |
UNIQUE |
un indirizzo non appartiene a due utenti |
unico (txid, vout) |
utxo_events |
UNIQUE |
un outpoint si accredita una volta sola, [DAT-10] |
ix_utxo_events_user_id |
utxo_events |
indice su user_id |
calcolo del saldo e selezione degli UTXO per utente |
ix_rounds_single_active |
rounds |
UNIQUE ((1)) WHERE status IN ('open','closing','drawing','paying_out') |
al massimo un round attivo, [DAT-17] (B-09) |
unico (round_id, user_id) |
round_participants |
UNIQUE |
una scommessa per utente per round, [DAT-23] |
ix_round_participants_round_id |
round_participants |
indice | partecipanti di un round |
ix_round_participants_user_id |
round_participants |
indice | round di un utente (last-round-result) |
ix_withdrawals_user_id |
withdrawals |
indice | prelievi di un utente |
Non ci sono indici su pending_transactions, audit_log o bug_reports oltre
alla chiave primaria. In particolare pending_transactions.status e
audit_log.event_type/round_id, usati dai filtri dei task in background, sono
letti con una scansione completa.
Invarianti tra tabelle
-
[DAT-44] Prima si scrive, poi si trasmette. Ogni transazione in uscita esiste nel database prima di essere trasmessa: la riga
pending_transactionsinbuilding, la riga di dominio (partecipante inbuilding, prelievo inbuilding, oppurepayout_txidsul round) e, per scommesse e prelievi, gli input già marcati conspent_txid, tutto in un commit che precede il broadcast (B-08, B-25). Nessuna sessione del database resta aperta durante una chiamata di rete. Codice:app/bets/service.py:place_bet·app/withdrawals/service.py:request_withdrawal·app/rounds/scheduler.py:RoundScheduler._trigger_payout· Test:tests/unit/test_bets.py,tests/unit/test_scheduler.py -
[DAT-45] Un solo txid valido. Per ogni transazione non terminale,
pending_transactions.current_txidcoincide con il txid registrato sulla riga di dominio (round_participants.bet_txid,withdrawals.txid,rounds.payout_txid) e con lospent_txiddi ogni input impegnato ([DAT-14]).bump_feeli aggiorna tutti nella stessa transazione del database. Per un pagamento,payout_txidsi aggiorna solo se era ancora uguale al vecchio txid. Codice:app/tx/broadcast.py:_retarget_txid_references· Test:tests/unit/test_broadcast.py -
[DAT-46] Il montepremi si conserva. Per un round pagato:
pool_amount_sats= somma deibet_amount_satsdei partecipanticonfirmed;fee_amount_sats=pool_amount_sats− 70% (arrotondato per difetto) dipool_amount_sats;winner_amount_sats= quel 70% meno la commissione di rete del pagamento. La commissione di rete esce quindi dalla quota del vincitore, mai da quella della piattaforma. La ripartizione è nel codice, non inround_config. Codice:app/rounds/service.py:winner_share·app/rounds/scheduler.py:RoundScheduler._trigger_payout·app/wallet/psbt_builder.py:build_payout_transaction· Test:tests/unit/test_payout_builder.py,tests/unit/test_scheduler.py -
[DAT-47] Un round non si chiude con scommesse in volo. Finché un round ha partecipanti in
buildingobroadcastresta inclosing. Il conteggio si ripete nella stessa sessione che legge i partecipanti da estrarre (B-53), e l'attesa finisce perché ogni riga in volo viene prima o poi confermata o cancellata dal riconciliatore. Codice:app/rounds/scheduler.py:RoundScheduler._tick·app/rounds/scheduler.py:RoundScheduler._close_and_draw· Test:tests/unit/test_scheduler.py -
[DAT-48] Un solo pagamento per round. Non si costruisce un pagamento per un round che ha già una riga
payoutinbuildingopending, e gli outpoint del pool impegnati da qualunque pagamento non risolto sono esclusi dalla selezione ([DAT-11]). Codice:app/rounds/scheduler.py:RoundScheduler._trigger_payout·app/rounds/scheduler.py:_reserved_payout_outpoints· Test:tests/unit/test_scheduler.py -
[DAT-49] Le transazioni ritrovano la loro riga senza il txid. Le righe collegate a una
pending_transactionssi ritrovano tramiteround_id,user_idewithdrawal_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.pydasettings.database_url(DATABASE_URL; predefinitosqlite+aiosqlite:///./plm_lottery.db, e nel containersqlite+aiosqlite:////app/db_data/plm_lottery.db, montato da./data/db). Le sessioni si creano conAsyncSessionLocal(async_sessionmakerconexpire_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 ricevonoAsyncSessionLocalcome factory e aprono sessioni brevi, chiuse prima di ogni chiamata di rete. Il commit è sempre esplicito:get_sessionnon 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=NORMALePRAGMA busy_timeout=5000(B-39): lettori e scrittori non si bloccano a vicenda, e un secondo scrittore aspetta fino a 5 s invece di fallire subito. Con un altro dialetto non viene eseguito nulla.PRAGMA foreign_keysnon è tra questi, da cui [DAT-04]. Codice:app/db/base.py:_register_sqlite_pragmas· Test:tests/unit/test_db_base.py
Migrazioni
Politica
-
[DAT-53] Lo schema si cambia solo con una revisione Alembic in
migrations/versions/. Si modificaapp/db/models.py, poi si genera la revisione conalembic revision --autogenerate -m "..."e la si rilegge e corregge a mano prima del commit. La catena è lineare, senza rami.migrations/env.pyprende l'URL dasettings.database_urle i metadati daBase.metadata, e non abilitarender_as_batch: le modifiche a colonne esistenti su SQLite vanno scritte a mano dentroop.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
Dockerfilecontrolla che esista la master key, eseguealembic upgrade heade solo se riesce avviauvicorn. Una migrazione che fallisce impedisce quindi al server di partire invece di lasciarlo girare su uno schema a metà.alembic upgrade headnon va mai lanciato a mano. Codice:Dockerfile -
[DAT-55] Una colonna
NOT NULLaggiunta a una tabella che può avere già righe deve dare un valore a quelle righe nella migrazione stessa, in uno di due modi: unserver_defaulttemporaneo, 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_atcopiato dabroadcast_at;rounds.duration_seconds/cooldown_secondscopiati daround_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. Inoltreop.batch_alter_tablesu SQLite ricrea la tabella e perde senza errori gli indici su espressioni, perché SQLAlchemy non li legge dallo schema. Una migrazione futura che usibatch_alter_tablesuroundsousersdeve quindi ricreare esplicitamente l'indice relativo. Oggi nessuna migrazione lo fa dopo la creazione di quegli indici. Codice:migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py·migrations/versions/c1d4a97b5e10_case_insensitive_usernames.py -
[DAT-57] Una migrazione che incontra dati incompatibili con un nuovo vincolo si comporta in uno di due modi. Se esiste una correzione senza ambiguità, la applica e procede. Se correggere significherebbe decidere sulla proprietà dei fondi, si rifiuta, e il server non parte ([DAT-54]). Le due migrazioni di questo tipo sono [DAT-58] e [DAT-59].
-
[DAT-58]
c1d4a97b5e10(username senza distinzione di maiuscole, B-57) si rifiuta di procedere se due account esistenti hanno username che differiscono solo per maiuscole/minuscole: solleva un errore che elenca i gruppi in collisione e non crea l'indice. Non sceglie quale account rinominare, perché entrambi possono contenere fondi: l'operatore rinomina a mano un account (e avvisa l'utente), poi riavvia (vedi operativita.md). Codice:migrations/versions/c1d4a97b5e10_case_insensitive_usernames.py -
[DAT-59]
8a1c4e7b2d90(indice del round attivo unico, B-09), se trova più round attivi, porta aclosedtutti tranne il più recente prima di creare l'indice. È la stessa scelta cheget_active_roundfaceva già in silenzio. Codice:migrations/versions/8a1c4e7b2d90_add_failure_reason_and_single_active_round_index.py
Nessun test esegue le migrazioni. I test unitari creano lo schema direttamente
dal modello (Base.metadata.create_all), quindi verificano il modello, non la
catena di revisioni.
Storia dello schema
In ordine di applicazione (ogni riga rivede la precedente):
| Revisione | Contenuto |
|---|---|
274efdcbfbcc |
Schema iniziale: round_config (solo fee_address, bet_amount_sats, updated_at), users, rounds, utxo_events, withdrawals, audit_log, pending_transactions, round_participants |
53cc70d16e63 |
round_config: round_duration_seconds, round_cooldown_seconds, min_amount_sats, fee_rate_sat_vb, rbf_timeout_seconds (valori prima presi da app/config.py) |
1db52f3a7c67 |
round_config.draw_animation_seconds |
5f2079b95b33 |
round_config.paused |
6cb50b29f64c |
Rimosso round_config.min_amount_sats: il prelievo minimo è il costo della scommessa |
8a1c4e7b2d90 |
pending_transactions.failure_reason; indice ix_rounds_single_active (B-04, B-09) |
861e76aaf34c |
pending_transactions.last_broadcast_at, con backfill da broadcast_at (B-27) |
943dbd74d983 |
users.token_version (B-34) |
9ef6a51509f7 |
rounds.drawing_started_at (B-36) |
87a0c640355c |
audit_log.payload_json e pending_transactions.raw_tx_hex da VARCHAR a Text (B-47) |
ee8508d98d34 |
Tabella bug_reports, con un booleano resolved |
be71fdac734e |
bug_reports.resolved sostituito da status a tre valori (resolved → 'resolved', il resto → 'open') |
c1d4a97b5e10 |
Indice ix_users_username_lower (B-57), [DAT-58] |
283844a44b4a |
rounds.duration_seconds/cooldown_seconds (B-61), backfill dalla configurazione attuale — head |
Differenze note tra modello e schema migrato
users.token_version: il modello dichiaraserver_default="0", ma la migrazione943dbd74d983rimuove 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 fornisce0, e l'autogenerate non confronta i default del server, quindi non lo segnala.rounds.duration_seconds/cooldown_seconds:server_defaultpresente sia nel modello sia nel database migrato.
Scelte specifiche di SQLite
- Un solo file, un solo processo. SQLite con WAL regge i sei task in background e le richieste HTTP di un singolo processo uvicorn ([DAT-52]). I lock per utente e il canale SSE sono comunque in memoria, quindi più processi non sono supportati in nessun caso (vedi decisioni-e-limiti.md).
- Serializzazione degli scrittori. Il compare-and-set di [DAT-16] si basa sul
lock di scrittura di SQLite: un conflitto tra snapshot arriva come
OperationalErrore viene trattato come "round non più aperto". - Indice parziale su espressione.
ix_rounds_single_activeusa sia un'espressione ((1)) sia una clausolaWHERE; 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]).
Textinvece diVARCHARsenza 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
sqlitee l'indice del round attivo è dichiarato anche per PostgreSQL. UnDATABASE_URLPostgreSQL non è però mai stato provato, e il database supportato resta SQLite.