- bet/withdrawal routes read listener.client after taking the user lock - a reduced full-balance withdrawal is checked against the minimum again - a bet covered only by pending balance answers balance_pending_confirmation - a recovered bet/withdrawal/payout writes bet_placed/withdrawal_sent/payout_sent - an abandoned payout clears winner/fee/reserve amounts with payout_txid - the reconciler pings SSE only for rows it changed - closing_stalled every 30 minutes past the deadline; closing_waiting_since on /rounds/current - one refresh per user at a time; a failed refresh no longer ends the Electrum session Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
92 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·pending_transaction_txids·withdrawals·bug_reports·audit_log·alembic_version - Indici e vincoli
- Invarianti tra tabelle
- Motore e sessioni
- Migrazioni
- Scelte specifiche di SQLite
Diagramma delle relazioni
Dieci tabelle applicative. round_config non ha relazioni: è una
riga singola di parametri. Gli indirizzi della piattaforma che non
appartengono a un utente sono derivati dalla master key, e i loro UTXO si
leggono dalla rete, non da una tabella:
| Indirizzo | Percorso | Dove è registrato |
|---|---|---|
| Utente | m/84'/746'/0'/0/<derivation_index> |
users.address |
| Riserva del pool (il "pool" unico di oggi) | m/84'/746'/0'/1/0 |
nessuna tabella; app/wallet/hd.py:derive_pool_key |
| Montepremi di un round | m/84'/746'/0'/2/<round_id> |
rounds.pool_address ([DAT-60]) |
Il diagramma descrive lo schema atteso; ciò che non esiste ancora è marcato da implementare nelle tabelle di dettaglio.
erDiagram
users ||--o{ utxo_events : "possiede"
users ||--o{ round_participants : "scommette"
rounds ||--o{ round_participants : "raccoglie"
users |o--o{ rounds : "vince (winner_user_id)"
users ||--o{ withdrawals : "chiede"
rounds |o--o{ pending_transactions : "bet / payout"
withdrawals |o--o{ pending_transactions : "withdrawal"
users |o--o{ pending_transactions : "bet / withdrawal"
pending_transactions ||--|{ pending_transaction_txids : "ogni versione"
users |o--o{ bug_reports : "segnala"
users |o--o{ audit_log : "riguarda"
rounds |o--o{ audit_log : "riguarda"
users {
int id PK
string username UK "unico anche su lower(username)"
string password_hash "Argon2"
int derivation_index UK
string address UK "P2WPKH plm1..."
bigint cached_balance_sats "cache di utxo_events"
int token_version
datetime created_at
}
utxo_events {
int id PK
int user_id FK
string txid "UK con vout"
int vout
bigint amount_sats
int confirmed_height
datetime confirmed_at
string spent_txid "NULL, txid o external-spend"
}
rounds {
int id PK
string status "open closing drawing paying_out closed"
datetime opened_at
datetime closed_at
int duration_seconds "snapshot"
int cooldown_seconds "snapshot"
bigint bet_amount_sats "snapshot"
string fee_address "snapshot"
bigint max_round_pool_sats "snapshot"
int draw_confirmations "snapshot"
string pool_address
string server_seed_enc "cifrato fino alla rivelazione"
string server_seed "in chiaro solo dopo la rivelazione"
string server_seed_hash
string participants_hash
int draw_height
datetime drawing_started_at
int draw_block_height
string draw_block_hash
string seed_int
int winner_user_id FK
bigint pool_amount_sats
bigint winner_amount_sats
bigint fee_amount_sats
bigint bump_reserve_sats
string payout_txid
datetime payout_blocked_at
}
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
datetime broadcast_at
datetime confirmed_at
int draw_position "UK con round_id"
string status "building broadcast confirmed reorged_out"
}
round_config {
int id PK
string fee_address
bigint bet_amount_sats
int round_duration_seconds
int round_cooldown_seconds
int draw_animation_seconds
int fee_rate_sat_vb
int rbf_timeout_seconds
int deposit_confirmations
int draw_confirmations
bigint max_round_pool_sats
bool paused
string paused_reason
datetime updated_at
}
pending_transactions {
int id PK
string kind "bet payout withdrawal"
int round_id FK
int withdrawal_id FK
int user_id FK
string current_txid
int fee_rate_sat_vb
text raw_tx_hex
datetime broadcast_at
datetime last_broadcast_at
string status "building pending confirmed failed"
string replaced_by_txid
int attempt_count
string failure_reason
datetime failed_at
datetime updated_at
}
pending_transaction_txids {
int id PK
int pending_transaction_id FK
string txid UK
text raw_hex
datetime created_at
}
withdrawals {
int id PK
int user_id FK
string external_address
bigint amount_requested_sats
bigint amount_sent_sats
string txid
string status "building broadcast confirmed failed"
datetime created_at
datetime confirmed_at
}
bug_reports {
int id PK
text description
string contact
int user_id FK
string status "open read resolved"
datetime created_at
}
audit_log {
int id PK
string event_type
text payload_json
int user_id FK
int round_id FK
datetime created_at
}
Convenzioni comuni
-
[DAT-01] Ogni importo è un numero intero di satoshi (1 PLM = 100 000 000 sat), in una colonna
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, con le sue versioni inpending_transaction_txids, [DAT-24], [DAT-38], [DAT-70]); nessun'altra tabella perde righe, quindi i riferimenti restano validi per costruzione, non per vincolo. Codice:app/db/base.py:_register_sqlite_pragmas·app/bets/service.py:_release_failed_bet·app/tx/reconcile.py:_abandon -
[DAT-05] Ogni chiave primaria è un intero autoincrementale
id, assegnato dal database 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 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). 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 (B-82): lo schema non le ripete. Il numero di conferme configurabile è la colonnaround_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, 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,tests/unit/test_deposit_confirmations.py -
[DAT-11] Gli UTXO dei montepremi e della riserva non sono in
utxo_events: non appartengono a un utente. Il pagamento di un round spende solo gli outpoint delle scommesse di quel round (bet_txid,bet_voutdei partecipanticonfirmed, [DAT-63]) all'indirizzorounds.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 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,tests/unit/test_pool_verification.py::test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_elseUn round aperto prima dell'indirizzo per round (il cui montepremi è l'indirizzo di riserva condiviso) conserva la scelta di prima: le monete confermate dell'indirizzo, lette dal server attivo. -
[DAT-12]
spent_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, e conferma di una versione precedente della stessa transazione ([DAT-14]) txid NULLapp/bets/service.py:_release_failed_bet,app/withdrawals/service.py:_release_failed_withdrawalla rete ha rifiutato esplicitamente il broadcast txid NULLapp/tx/reconcile.py:_release_inputsla transazione è stata abbandonata con una prova ([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, corroborato dal quorum Chi sceglie gli input di una nuova transazione considera solo le righe con
spent_txid IS NULL. Codice: le funzioni in tabella · Test:tests/unit/test_deposits.py,tests/unit/test_bets.py,tests/unit/test_withdrawals.py,tests/unit/test_reconcile.py,tests/unit/test_confirmation_proof.py -
[DAT-13] Il riconciliatore libera un input solo se il suo
spent_txidè uno dei txid della riga che sta abbandonando (current_txido una sua versione inpending_transaction_txids, [DAT-70]): se nel frattempo lo stesso output risulta impegnato da un'altra transazione, quella rivendicazione è quella valida e non va toccata. Codice:app/tx/reconcile.py:_release_inputs· Test:tests/unit/test_reconcile.py,tests/unit/test_tx_versions.py -
[DAT-14] Per ogni output impegnato,
spent_txidè sempre il txid corrente della transazione che lo spende, cioè ilcurrent_txiddella sua rigapending_transactions. Un bump RBF cambia il txid e quindi riscrive, nella stessa transazione del database, ognispent_txiduguale al vecchio txid. Quando conferma una versione diversa da quella corrente (una versione precedente registrata inpending_transaction_txids, [DAT-70]),current_txid,raw_tx_hex, lospent_txiddegli input e il txid della riga di dominio vengono riportati a quella versione, nella stessa transazione del database che segna la conferma. Senza questo allineamento [DAT-13] non riconoscerebbe più gli input di una transazione rimpiazzata. Codice:app/tx/broadcast.py:bump_fee·app/tx/broadcast.py:_retarget_txid_references·app/tx/confirmation.py:poll_once· Test:tests/unit/test_broadcast.py,tests/unit/test_tx_versions.py
rounds
Un round della lotteria, dall'apertura al pagamento confermato. I round chiusi restano nella tabella come storico.
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
id |
Integer PK | no | autoincrement | — | database |
status |
String(16) | no | "open" |
Fase del round: open, closing, drawing, paying_out, closed (RND-02) |
vedi RND-02 |
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) | app/rounds/scheduler.py:RoundScheduler._tick; app/rounds/confirmation.py:_on_payout_confirmed |
duration_seconds |
Integer | no | 600 (anche server_default) |
Durata di questo round, copiata da round_config all'apertura |
open_new_round_if_needed |
cooldown_seconds |
Integer | no | 30 (anche server_default) |
Pausa dopo questo round, copiata da round_config all'apertura |
open_new_round_if_needed |
bet_amount_sats |
BigInteger | no | backfill da round_config nella migrazione |
Costo della scommessa di questo round, copiato all'apertura ([DAT-61]) | open_new_round_if_needed |
fee_address |
String(128) | no | backfill da round_config nella migrazione |
Destinatario della commissione di questo round, copiato all'apertura ([DAT-61]) | open_new_round_if_needed |
max_round_pool_sats |
BigInteger | no | 0 (server_default); backfill 0 |
Tetto alla somma delle scommesse di questo round, copiato all'apertura ([DAT-61], [DAT-27]); 0 = nessun tetto |
open_new_round_if_needed |
draw_confirmations |
Integer | no | 3 (server_default) |
Conferme richieste sul blocco di estrazione di questo round, copiate all'apertura ([DAT-61]) | open_new_round_if_needed |
pool_address |
String(128) | no | "" solo fino alla fine della transazione di apertura; backfill con l'indirizzo di riserva per i round esistenti |
Indirizzo del montepremi di questo round ([DAT-60]) | open_new_round_if_needed |
server_seed_enc |
Text | sì | NULL |
Il segreto del round cifrato con Fernet, con la stessa chiave della master xprv; scritto all'apertura ([DAT-64]) | open_new_round_if_needed |
server_seed |
String(64) | sì | NULL |
Il segreto in chiaro (32 byte in esadecimale), scritto solo alla rivelazione ([DAT-64]) | RoundScheduler._complete_draw; RoundScheduler._close_and_draw (round senza partecipanti) |
server_seed_hash |
String(64) | sì | NULL |
sha256(server_seed) in esadecimale, pubblicato dall'apertura ([DAT-64]) |
open_new_round_if_needed |
participants_hash |
String(64) | sì | NULL |
Impronta dell'elenco ordinato dei partecipanti ([DAT-65]) | RoundScheduler._close_and_draw |
draw_height |
Integer | sì | NULL |
Altezza del blocco di estrazione, fissata all'ingresso in drawing ([DAT-65]) |
_close_and_draw |
drawing_started_at |
DateTime | sì | NULL |
Istante del passaggio a drawing |
RoundScheduler._close_and_draw |
draw_block_height |
Integer | sì | NULL |
Altezza del blocco usato per l'estrazione; è sempre uguale a draw_height |
_complete_draw |
draw_block_hash |
String(64) | sì | NULL |
Hash (esadecimale) di quel blocco | _complete_draw |
seed_int |
String(128) | sì | NULL |
Il seme in decimale (fino a 78 cifre, per questo è una stringa). Il valore intero di sha256(server_seed ‖ block_hash ‖ participants_hash), con la codifica definita in ciclo-di-vita-del-round.md (RND-17) |
_complete_draw |
winner_user_id |
FK users.id |
sì | NULL |
Il vincitore | _complete_draw |
pool_amount_sats |
BigInteger | sì | NULL |
Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | _close_and_draw, _complete_draw; RoundScheduler._trigger_payout (la ridefinisce la verifica, solo al ribasso) |
winner_amount_sats |
BigInteger | sì | NULL |
Quanto riceve il vincitore (70% meno la commissione di rete) | RoundScheduler._trigger_payout |
fee_amount_sats |
BigInteger | sì | NULL |
Quanto va a fee_address: 30% più il resto dell'arrotondamento, meno bump_reserve_sats |
_trigger_payout |
bump_reserve_sats |
BigInteger | sì | NULL |
Output di riserva per l'RBF del pagamento, verso l'indirizzo di riserva ([DAT-73]) | _trigger_payout; app/tx/broadcast.py:bump_fee |
payout_txid |
String(64) | sì | NULL |
Txid corrente della transazione di pagamento | _trigger_payout; _retarget_txid_references; app/tx/reconcile.py:_abandon |
payout_blocked_at |
DateTime | sì | NULL |
Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) | _trigger_payout; app/api/routes/admin.py:resume_lottery |
-
[DAT-15]Assorbito da RND-02: i valori distatuse le transizioni sono comportamento del round, e il loro unico proprietario è il documento del ciclo di vita. Qui restano i fatti di schema:statusè unaString(16)senzaCHECK([DAT-03]); gli statiopen,closing,drawing,paying_outsono quelli attivi coperti dall'indiceix_rounds_single_active([DAT-17]); la sola scrittura distatusfuori dal codice applicativo è la migrazione8a1c4e7b2d90([DAT-59]). Il trattenimento di un pagamento (B-82) non è un nuovo stato ma la colonnapayout_blocked_at([DAT-68]), così l'elenco degli stati attivi, e con esso l'indice su espressione, non cambia. -
[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 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 ancoraopennella stessa transazione che inserisce il partecipante, così il passaggio aclosinge l'inserimento non possono intrecciarsi. La transazione è aperta conBEGIN IMMEDIATE([DAT-74]), quindi le letture che precedono il compare-and-set (saldo, partecipanti già presenti) vedono lo stesso stato su cui si scrive. UnOperationalErrorin questo punto è ilbusy_timeoutscaduto (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 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]Assorbito da RND-05 e RND-04: la copia della durata e della pausa all'apertura, la scadenza e il conteggio della pausa dalclosed_atsono regole del round. Qui resta il fatto di schema:duration_secondsecooldown_secondssono colonneNOT NULLconserver_default(tabella sopra), scritte solo all'inserimento del round. -
[DAT-19]Assorbito da RND-28: le due scritture diclosed_at(chiusura delle scommesse, poi conferma del pagamento) sono comportamento del round. Qui resta il fatto di schema:closed_atè l'unica colonna diroundsscritta due volte. -
[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. Poidraw_block_height,draw_block_hash,seed_intewinner_user_idnon cambiano più.pool_amount_satsè la somma dibet_amount_satsdei soli partecipanticonfirmed, 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_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. Quando il riconciliatore abbandona il pagamento tornano tutti aNULL, nella stessa transazione del database, così un round inpaying_outsenza pagamento in volo non mostra importi di un tentativo che non esiste più. Un bump RBF non cambiawinner_amount_satsné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,tests/unit/test_state_consistency.py -
[DAT-60] Ogni round ha il proprio indirizzo del montepremi,
pool_address, derivato all'apertura dam/84'/746'/0'/2/<id>e scritto nella stessa transazione che inserisce il round (l'idè noto dopo ilflush, prima del commit); non cambia più. Le scommesse del round pagano questo indirizzo. I round aperti prima della migrazione ricevono comepool_addressl'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 vincoloUNIQUE: i round nuovi hanno indirizzi distinti per costruzione (percorso diverso per ogniid), 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.pyLa colonna ha
""come valore di inserimento (l'idnon esiste prima delflush) e""comeserver_default, che serve solo ad aggiungerla a una tabella con righe; il codice lo riscrive prima del commit e ogni consumatore rifiuta un indirizzo vuoto. -
[DAT-61]
bet_amount_sats,fee_address,max_round_pool_satsedraw_confirmationsdiroundssono una copia dei valori diround_configpresa quando il round si apre, nella stessa transazione dell'inserimento, e non cambiano più. Le scommesse del round costanorounds.bet_amount_sats, il suo pagamento paga la commissione arounds.fee_address, la sua estrazione attenderounds.draw_confirmationsconferme, le scommesse oltre il tettorounds.max_round_pool_satssono rifiutate. La regola di dominio è in ciclo-di-vita-del-round.md (B-95). La migrazione riempie le righe esistenti con i valori attuali diround_config([DAT-55]). Codice:app/rounds/service.py:open_new_round_if_needed·app/bets/service.py:place_bet·app/rounds/scheduler.py:RoundScheduler._trigger_payout· Test:tests/unit/test_round_snapshot.py,tests/unit/test_rounds_service.py,tests/unit/test_migrations.py -
[DAT-64] Il segreto del round sono 32 byte casuali generati con
secrets.token_bytes(32). All'inserimento del round si scrivono insiemeserver_seed_hash(sha256di quei 32 byte, esadecimale minuscolo) eserver_seed_enc: il segreto cifrato con Fernet usando la stessa chiave che cifra la master xprv (il file di cifratura, sicurezza.md SEC-53). Il segreto in chiaro non viene mai scritto nel database prima della rivelazione:server_seedrestaNULLe si valorizza (esadecimale minuscolo, 64 caratteri) nella stessa transazione che scrivewinner_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,GET /rounds/{id}/proof). Nessuna colonna cambia più dopo la rivelazione;server_seed_encresta com'è. Se all'avvio la chiave non decifra ilserver_seed_encdi un round non ancora rivelato, il server rifiuta di partire (sicurezza.md SEC-49). I round aperti prima della migrazione hanno le tre colonne aNULL. Codice:app/rounds/service.py:open_new_round_if_needed·app/rounds/scheduler.py:RoundScheduler._complete_draw·app/api/routes/rounds.py:round_proof· Test:tests/unit/test_rounds_service.py,tests/unit/test_draw_proof.py -
[DAT-65] All'ingresso in
drawing, nella stessa transazione che cambia lo stato, si scrivonodraw_height,participants_hashe ildraw_positiondi ogni partecipanteconfirmed([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 (B-73). Nessuno dei tre cambia più. Sono ciò che permette di riprendere l'estrazione dopo un errore o un riavvio ottenendo lo stesso vincitore (B-81): la ripresa legge questi valori dal database e non ricalcola nulla. Codice:app/rounds/scheduler.py:RoundScheduler._close_and_draw· Test:tests/unit/test_scheduler.py -
[DAT-67] Prima di costruire il pagamento, gli outpoint delle scommesse vengono verificati sulla rete; i partecipanti la cui scommessa non c'è più passano a
reorged_out([DAT-24]) epool_amount_satsviene riscritto come somma dei partecipanti rimasticonfirmed, 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 (B-82). Codice:app/rounds/scheduler.py:RoundScheduler._verify_round_pool· Test:tests/unit/test_pool_verification.py -
[DAT-68]
payout_blocked_atsi scrive quando la verifica di [DAT-67] trova assente la scommessa del vincitore: il round restapaying_out, non si costruisce alcun pagamento finché la colonna non èNULL, e nella stessa transazione si attiva la pausa conpaused_reason = 'payout_blocked'([DAT-69]). TornaNULLsolo conPOST /admin/resume(api.md), dopo di che la verifica si ripete al tentativo successivo. Codice:app/rounds/scheduler.py:RoundScheduler._trigger_payout·app/api/routes/admin.py:resume_lottery· Test:tests/unit/test_pool_verification.py::test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery,tests/unit/test_pool_verification.py::test_after_the_resume_the_verification_repeats,tests/unit/test_admin.py::test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain -
[DAT-73] Ogni pagamento ha un output di riserva verso l'indirizzo di riserva (
m/84'/746'/0'/1/0), di valoremax(DUST_LIMIT_SATS, vsize stimata × 10 sat/vB), preso dalla quota delle commissioni;bump_reserve_satsne registra il valore iniziale. Un bump RBF del pagamento riduce questo output (nonwinner_amount_satsnéfee_amount_sats) e non riscrivebump_reserve_sats. La regola di dominio è in ciclo-di-vita-del-round.md (B-79). Codice:app/rounds/scheduler.py:RoundScheduler._trigger_payout·app/wallet/psbt_builder.py:build_payout_transaction·app/tx/broadcast.py:bump_fee· Test:tests/unit/test_scheduler.py::test_trigger_payout_always_carries_a_bump_reserve_to_the_reserve_address,tests/unit/test_broadcast.py::test_a_payout_is_bumped_out_of_its_reserve_output_and_nothing_else,tests/unit/test_migrations.py::test_the_bump_reserve_column_exists_at_head_and_is_nullable
round_participants
Una scommessa: un utente in un round.
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
id |
Integer PK | no | autoincrement | — | database |
round_id |
FK rounds.id, indicizzata |
no | — | Il round | app/bets/service.py:place_bet |
user_id |
FK users.id, indicizzata |
no | — | Chi scommette | place_bet |
bet_amount_sats |
BigInteger | no | — | Quanto arriva davvero al pool: costo della scommessa meno la commissione di rete | place_bet |
bet_txid |
String(64) | no | — | Txid corrente della scommessa; dopo la conferma, il txid della versione confermata ([DAT-14]) | place_bet; app/tx/broadcast.py:_retarget_txid_references |
bet_vout |
Integer | no | 0 (server_default, backfill 0) |
Posizione dell'output verso il montepremi nella transazione della scommessa ([DAT-63]) | place_bet |
broadcast_at |
DateTime | no | utcnow |
Istante della fase 1 di place_bet, vedi [DAT-25] |
place_bet |
confirmed_at |
DateTime | sì | NULL |
Istante in cui la scommessa è risultata confermata | app/bets/confirmation.py:_on_bet_confirmed |
draw_position |
Integer | sì | NULL |
Posizione (da 0) nell'elenco ordinato dell'estrazione; valorizzata solo per i partecipanti confirmed all'ingresso in drawing ([DAT-66]) |
app/rounds/scheduler.py:RoundScheduler._close_and_draw |
status |
String(16) | no | "broadcast" (mai usato: si scrive sempre esplicitamente) |
Vedi [DAT-24] | vedi diagramma |
-
[DAT-23] Al massimo una scommessa per utente per round: vincolo unico su (
round_id,user_id).place_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,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àconfirmedil cui outpoint non è più sulla catena alla verifica che precede il pagamento ([DAT-67]): la riga non si cancella, perché ha già undraw_positione fa parte dell'elenco pubblicato conparticipants_hash, ma non conta nel montepremi. Diagramma del comportamento atteso (oggi mancareorged_out):stateDiagram-v2 [*] --> building: place_bet fase 1 building --> broadcast: place_bet fase 2 / _promote building --> confirmed: _on_bet_confirmed broadcast --> confirmed: _on_bet_confirmed building --> [*]: cancellata (_release_failed_bet / _abandon) broadcast --> [*]: cancellata (_abandon) confirmed --> reorged_out: verifica prima del pagamento confirmed --> [*] reorged_out --> [*]Da A Funzione — 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 esplicitamente)building,broadcastcancellata app/tx/reconcile.py:_abandon(abbandono con prova)confirmedreorged_outapp/rounds/scheduler.py:RoundScheduler._verify_round_pool(da_trigger_payout), per ogni partecipante tranne il vincitore (RND-39)Se il compare-and-set di [DAT-16] fallisce, la riga
buildingnon arriva nemmeno al commit. Un broadcast dall'esito ambiguo (timeout, connessione caduta) lascia la riga inbuildingper il riconciliatore (scommesse-e-prelievi.md B-76). Codice:app/bets/service.py:place_bet·app/bets/service.py:_release_failed_bet·app/tx/reconcile.py:_promote·app/tx/reconcile.py:_abandon·app/bets/confirmation.py:_on_bet_confirmed· Test:tests/unit/test_bets.py,tests/unit/test_confirmation.py,tests/unit/test_reconcile.py,tests/unit/test_pool_verification.py -
[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). Non determina più l'ordine di estrazione, che è quello di [DAT-66]: resta un'informazione per/admine per il registro. Codice:app/bets/service.py:place_bet·app/rounds/scheduler.py:RoundScheduler._close_and_draw· Test:tests/unit/test_scheduler.py -
[DAT-63]
bet_voutè l'indice dell'output della scommessa che pagarounds.pool_address; con il costruttore attuale è sempre0(recipient_satsè il primo output). Insieme abet_txididentifica l'outpoint che l'elenco dell'estrazione pubblica e che il pagamento spende ([DAT-11]). Codice:app/bets/service.py:place_bet·app/wallet/psbt_builder.py:RECIPIENT_VOUT· Test:tests/unit/test_bets.py -
[DAT-66]
draw_positionsi scrive una sola volta, all'ingresso indrawing([DAT-65]), per ogni partecipanteconfirmeddel round: i partecipanti sono ordinati perbet_txidcrescente (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 hannoNULL, ammesso più volte dal vincolo.draw_positionnon cambia nemmeno quando la riga passa areorged_out. Codice:app/rounds/scheduler.py:RoundScheduler._close_and_draw·app/db/models.py:RoundParticipant· Test:tests/unit/test_scheduler.py,tests/unit/test_migrations.py -
[DAT-26]
bet_amount_satsè l'importo del primo output della scommessa (recipient_sats), cioè il costo della scommessa del round (rounds.bet_amount_sats, [DAT-61]) meno la commissione di rete. Non cambia più: un bump RBF riduce il resto dell'utente, non l'output verso il pool. Per questo il montepremi si calcola sommando questa colonna, non moltiplicando il numero di partecipanti per il costo (B-11). Codice:app/bets/service.py:place_bet·app/wallet/psbt_builder.py:build_signed_transaction·app/api/routes/rounds.py:current_round· Test:tests/unit/test_bets.py,tests/unit/test_round_snapshot.py -
[DAT-27] Il numero di righe di un round, contando tutti gli stati, non supera
MAX_PARTICIPANTS_PER_ROUND(400): la 401ª scommessa è rifiutata conround_fullprima che si muova denaro (B-52). Conrounds.max_round_pool_sats(copiato daround_configall'apertura, [DAT-61], [DAT-62]) diverso da 0, una scommessa è rifiutata conround_fullanche quando la somma deibet_amount_satsdel round, contando tutti gli stati, ha già raggiunto quel tetto; la regola di dominio è in scommesse-e-prelievi.md (B-95). Codice:app/bets/service.py:place_bet·app/wallet/psbt_builder.py· Test:tests/unit/test_bets.py,tests/unit/test_round_snapshot.py
round_config
I parametri operativi, in una sola riga, modificabili a caldo da /admin
senza riavviare (vedi limiti-noti.md).
| Colonna | Tipo | Default | Limiti accettati da PUT /admin/config |
Significato | Quando ha effetto |
|---|---|---|---|---|---|
id |
Integer PK | autoincrement | — | — | — |
fee_address |
String(128), non null | nessun default nel modello; la riga nasce con "" |
indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Dal round successivo, copiato all'apertura ([DAT-61]); il round in corso paga la commissione all'indirizzo con cui si è aperto |
bet_amount_sats |
BigInteger | 1_000_000_000 (10 PLM) |
> 0, ≤ 100 000 PLM |
Costo della scommessa; anche il prelievo minimo | Per le scommesse, dal round successivo ([DAT-61]); per il prelievo minimo, subito (ADR-20). 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) |
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) |
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 (5 000) |
Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio fee_rate_sat_vb |
rbf_timeout_seconds |
Integer | 900 |
60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) |
deposit_confirmations |
Integer | 6 |
1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) | Subito, per gli accrediti successivi; gli output già accreditati restano |
draw_confirmations |
Integer | 3 |
1 – 20 | Conferme richieste sul blocco di estrazione ([DAT-62]) | Dal round successivo, copiato all'apertura ([DAT-61]) |
max_round_pool_sats |
BigInteger | 0 (tetto disattivato) |
0, oppure ≥ bet_amount_sats e ≤ 4 000 000 000 000 000 (400 × 100 000 PLM) |
Tetto alla somma delle scommesse di un round ([DAT-27], [DAT-62]) | Dal round successivo, copiato all'apertura ([DAT-61]): un round tiene il tetto con cui si è aperto |
paused |
Boolean | False |
non modificabile da PUT /admin/config |
Pausa della lotteria | Subito per l'apertura del round successivo; il round in corso non è toccato |
paused_reason |
String(16) | NULL |
non modificabile da PUT /admin/config |
Perché la lotteria è in pausa ([DAT-69]) | Insieme a paused |
updated_at |
DateTime | utcnow, aggiornato a ogni modifica (onupdate) |
— | Ultima modifica della riga | ORM |
-
[DAT-28] La tabella contiene al più una riga, creata alla prima lettura con i default di colonna di
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,deposit_confirmations,draw_confirmations,max_round_pool_sats, 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]
pausedepaused_reasonsi modificano solo conPOST /admin/pauseePOST /admin/resume, che scrivonolottery_pausedelottery_resumednel registro (B-10), e con le pause automatiche di [DAT-69], che scrivono la propria voce di audit. Codice:app/api/routes/admin.py:pause_lottery·app/api/routes/admin.py:resume_lottery· Test:tests/unit/test_admin.py -
[DAT-62]
deposit_confirmations(predefinito 6) è il numero di conferme dopo cui un output che paga un utente si accredita ([DAT-10]);draw_confirmations(predefinito 3) è il numero di blocchi che la catena deve avere a partire dadraw_heightprima 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 (B-82), ciclo-di-vita-del-round.md (B-73) e scommesse-e-prelievi.md (B-95). La migrazione le aggiunge con unserver_defaulttemporaneo pari al predefinito ([DAT-55]). Codice:app/db/models.py:RoundConfig·app/rounds/config.py:get_round_config· Test:tests/unit/test_admin.py,tests/unit/test_migrations.py -
[DAT-69]
paused_reasonèNULLesattamente quandopausedè falso; conpausedvero vale uno di questi valori, e nessun altro:Valore Chi lo scrive Quando operatorapp/api/routes/admin.py:pause_lottery; l'avvio conSTART_PAUSED=true(operativita.md B-90)pausa decisa dall'operatore reorgil listener Electrum riorganizzazione di profondità ≥ 2 (connessione-electrum.md B-85) payout_blockedapp/rounds/scheduler.py:RoundScheduler._trigger_payoutla scommessa del vincitore non è più sulla catena ([DAT-68], B-82) resurrected_txapp/tx/reconcile.py:reconcile_onceuna riga failedrisulta 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 pausaoperatornon sovrascrive un motivo automatico già presente, così la ragione che richiede un controllo non si perde.POST /admin/resumeè l'unico modo di tornare apaused = false,paused_reason = NULL, per qualunque motivo. La pausa, come oggi, impedisce solo l'apertura del round successivo. Codice:app/api/routes/admin.py:pause_lottery·app/api/routes/admin.py:resume_lottery·app/electrum/listener.py:ElectrumListener._record_reorg·app/rounds/scheduler.py:RoundScheduler._trigger_payout·app/tx/reconcile.py:reconcile_once· Test:tests/unit/test_pause_reason.py,tests/unit/test_electrum_listener.py -
[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). La cronologia completa è in pending_transaction_txids ([DAT-70]) e questa colonna resta solo informativa |
bump_fee |
attempt_count |
Integer | no | 1 |
1 + numero di bump riusciti | bump_fee |
failure_reason |
String(128) | sì | NULL |
Perché la transazione è fallita; solo con status = failed |
_abandon; _release_failed_withdrawal |
failed_at |
DateTime | sì | NULL |
Istante del passaggio a failed; da lì si contano i 14 giorni di sorveglianza ([DAT-72]) |
_abandon; _release_failed_withdrawal |
updated_at |
DateTime | no | utcnow, onupdate |
Ultima modifica | ORM |
Le funzioni citate sono app/bets/service.py:place_bet,
app/withdrawals/service.py:request_withdrawal,
app/rounds/scheduler.py:RoundScheduler._trigger_payout,
app/tx/broadcast.py:bump_fee, app/tx/reconcile.py:_abandon e
app/withdrawals/service.py:_release_failed_withdrawal.
-
[DAT-32] Le colonne di collegamento dipendono da
kind: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 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
kindeuser_ide, per unpayout, darounds.pool_addressdelround_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]
statusassume solo i valoribuilding,pending,confirmed,failed.confirmedè terminale;failedè terminale per il bumper e per il poller ordinario, ma per 14 giorni dafailed_atla riga resta sorvegliata e può ancora passare aconfirmedse una delle sue versioni conferma ([DAT-72]). Diagramma del comportamento atteso (oggifailedè definitivo e il broadcast ambiguo non esiste come caso a sé):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_atDa A Funzione — buildingapp/bets/service.py:place_bet,app/withdrawals/service.py:request_withdrawal,app/rounds/scheduler.py:RoundScheduler._trigger_payoutbuildingpendingle stesse tre, dopo che il server attivo ha accettato il broadcast (anche con un rifiuto "già noto / già nel mempool"); app/tx/reconcile.py:_promotependingconfirmedapp/tx/confirmation.py:poll_once(poi il gestore delkind)building,pendingfailedapp/tx/reconcile.py:_abandon, solo con una prova (transazioni-e-riconciliazione.md B-78)buildingfailedapp/withdrawals/service.py:_release_failed_withdrawal, solo per un rifiuto esplicitobuildingcancellata app/bets/service.py:_release_failed_bet, solo per un rifiuto esplicitofailedconfirmedapp/tx/reconcile.py:reconcile_once([DAT-72])Un broadcast dall'esito ambiguo lascia la riga in
buildingper il riconciliatore, per tutti e tre ikind(B-76). Il poller delle conferme guarda le righepending, più lefailedentro 14 giorni. Codice: le funzioni in tabella · Test:tests/unit/test_confirmation.py,tests/unit/test_reconcile.py,tests/unit/test_reconcile_proof.py,tests/unit/test_scheduler.py,tests/unit/test_bets.py,tests/unit/test_withdrawals.py -
[DAT-34] Dopo la creazione,
current_txid,raw_tx_hex,fee_rate_sat_vb,replaced_by_txid,attempt_countelast_broadcast_atcambiano solo inbump_fee, ecurrent_txid/raw_tx_hexanche nel riallineamento a una versione precedente confermata ([DAT-14]).bump_feeprocede in quest'ordine:- scrive la nuova versione in
pending_transaction_txidscome candidata, con un commit, prima di trasmetterla ([DAT-71]); - la trasmette;
- 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_vbregistra la commissione effettivamente pagata dalla nuova versione, non quella obiettivo. Codice:app/tx/broadcast.py:bump_fee· Test:tests/unit/test_broadcast.py,tests/unit/test_tx_versions.py - scrive la nuova versione in
-
[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.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 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_reasonefailed_atsi scrivono solo quando la riga passa afailed,failure_reasontroncato 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]) conservafailure_reasonefailed_at. Codice:app/tx/reconcile.py:_abandon·app/withdrawals/service.py:_release_failed_withdrawal· Test:tests/unit/test_reconcile.py,tests/unit/test_reconcile_proof.py -
[DAT-72] Una riga
failedconfailed_atnegli ultimi 14 giorni resta tra quelle che il riconciliatore controlla a ogni giro (120 s, transazioni-e-riconciliazione.md TX-23), su tutti i txid dipending_transaction_txids. Se una versione risulta confermata, la riga passa aconfirmed, si scrivepending_tx_resurrectede la lotteria va in pausa conpaused_reason = 'resurrected_tx'([DAT-69]), tutto nella stessa transazione. Le righe di dominio già riportate indietro dall'abbandono (partecipante cancellato, prelievofailed, input liberati) non vengono ricostruite automaticamente: le riallinea l'operatore (operativita.md B-78). Codice:app/tx/reconcile.py:reconcile_once·app/tx/reconcile.py:_abandon· Test:tests/unit/test_reconcile_proof.py -
[DAT-37] Per le righe
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
pending_transaction_txids
Ogni versione che una riga
pending_transactions abbia mai avuto: la prima, ogni sostituzione RBF
trasmessa e ogni candidata scritta prima di un bump. Serve perché una
qualunque di queste versioni può essere quella che la rete conferma.
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
id |
Integer PK | no | autoincrement | — | database |
pending_transaction_id |
FK pending_transactions.id, indicizzata |
no | — | La transazione di cui questa è una versione | place_bet; request_withdrawal; _trigger_payout; bump_fee |
txid |
String(64), unico | no | — | Txid della versione | come sopra |
raw_hex |
Text | no | — | Transazione firmata di questa versione; stringa vuota solo per un replaced_by_txid ricostruito dal backfill della migrazione, di cui la transazione grezza non è più disponibile |
come sopra |
created_at |
DateTime | no | utcnow |
Istante in cui la versione è stata scritta, prima della sua trasmissione | ORM |
-
[DAT-70] Ogni txid che una riga
pending_transactionsabbia mai avuto come versione ha una riga qui, scritta nello stesso commit che precede la sua trasmissione: la prima versione insieme alla rigapending_transactionsinbuilding([DAT-44]), ogni sostituzione nella fase 1 dibump_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 solocurrent_txid: la conferma di uno qualunque conferma la riga ([DAT-14]). La rigabetcancellata da_release_failed_bet([DAT-38]) porta con sé le proprie versioni, nella stessa transazione. Codice:app/db/models.py:PendingTransactionTxid·app/tx/versions.py·app/tx/broadcast.py:bump_fee·app/tx/confirmation.py:poll_once·app/tx/reconcile.py:reconcile_once· Test:tests/unit/test_tx_versions.py,tests/unit/test_migrations.py -
[DAT-71] Una versione candidata è una riga di questa tabella il cui txid non è (ancora)
current_txid:bump_feel'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 ilraw_tx_hexcorrente, non quello di una candidata. Codice:app/tx/broadcast.py:bump_fee·app/tx/reconcile.py:reconcile_once· Test:tests/unit/test_tx_versions.py
withdrawals
Una richiesta di prelievo verso un indirizzo esterno. A differenza di una scommessa, resta nella tabella anche quando fallisce: è un'istruzione data dall'utente.
| Colonna | Tipo | Null | Default | Significato | Scritta da |
|---|---|---|---|---|---|
id |
Integer PK | no | autoincrement | — | database |
user_id |
FK users.id, indicizzata |
no | — | Chi preleva | app/withdrawals/service.py:request_withdrawal |
external_address |
String(128) | no | — | Destinatario: un indirizzo PLM valido, diverso da quello dell'utente e da ogni indirizzo di montepremi, di riserva e delle commissioni | request_withdrawal |
amount_requested_sats |
BigInteger | no | — | Importo chiesto dall'utente | request_withdrawal |
amount_sent_sats |
BigInteger | sì (sempre valorizzata dal codice) | NULL |
Quanto riceve il destinatario, vedi [DAT-40] | request_withdrawal |
txid |
String(64) | sì | NULL |
Txid corrente; NULL dopo un fallimento |
request_withdrawal; _retarget_txid_references; _release_failed_withdrawal; _abandon |
status |
String(16) | no | "pending" (mai usato: si crea sempre building) |
Vedi [DAT-39] | vedi diagramma |
created_at |
DateTime | no | utcnow |
Istante della richiesta | ORM |
confirmed_at |
DateTime | sì | NULL |
Istante in cui il prelievo è risultato confermato | app/withdrawals/confirmation.py:_on_withdrawal_confirmed |
-
[DAT-39]
statusassume solo i valoribuilding,broadcast,confirmed,failed; gli ultimi due sono terminali. Il passaggio afailedazzeratxid. Diagramma del comportamento atteso (oggi un broadcast ambiguo porta afailedcome un rifiuto):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 — buildingapp/withdrawals/service.py:request_withdrawalbuildingbroadcastrequest_withdrawal(broadcast accettato);app/tx/reconcile.py:_promotebuilding,broadcastconfirmedapp/withdrawals/confirmation.py:_on_withdrawal_confirmedbuildingfailedapp/withdrawals/service.py:_release_failed_withdrawal(rifiuto esplicito)building,broadcastfailedapp/tx/reconcile.py:_abandon(abbandono con prova)Se la riga
pending_transactionsdi un prelievofailedrisorge ([DAT-72]), il prelievo restafailedfinché l'operatore non lo riallinea. Codice: le funzioni in tabella · Test:tests/unit/test_withdrawals.py,tests/unit/test_reconcile.py,tests/unit/test_reconcile_proof.py -
[DAT-40]
amount_requested_satsè ciò che l'utente ha chiesto;amount_sent_satsè l'output effettivo verso il destinatario: l'importo chiesto meno la commissione di rete e, per un prelievo dell'intero saldo, meno quanto serve a lasciare un resto 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] |
unico (round_id, draw_position) |
round_participants |
UNIQUE (i NULL non collidono) |
una posizione di estrazione per partecipante, [DAT-66]; creato come indice (ux_round_participants_round_draw_position) |
unico txid |
pending_transaction_txids |
UNIQUE |
un txid appartiene a una sola transazione, [DAT-70] |
ix_pending_transaction_txids_pending_transaction_id |
pending_transaction_txids |
indice | versioni di una transazione |
ix_round_participants_round_id |
round_participants |
indice | partecipanti di un round |
ix_round_participants_user_id |
round_participants |
indice | round di un utente (last-round-result) |
ix_withdrawals_user_id |
withdrawals |
indice | prelievi di un utente |
Non ci sono indici su pending_transactions, audit_log o bug_reports oltre
alla chiave primaria. In particolare pending_transactions.status e
audit_log.event_type/round_id, usati dai filtri dei task in background, sono
letti con una scansione completa.
Invarianti tra tabelle
-
[DAT-44] Prima si scrive, poi si trasmette. Ogni versione di una transazione in uscita esiste nel database prima di essere trasmessa. Per la prima versione: la riga
pending_transactionsinbuilding, la sua riga inpending_transaction_txids, 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). Per una sostituzione RBF: la sua riga candidata inpending_transaction_txids, in un commit che precede il broadcast ([DAT-34], [DAT-71]). Nessuna sessione del database resta aperta durante una chiamata di rete. Codice:app/bets/service.py:place_bet·app/withdrawals/service.py:request_withdrawal·app/rounds/scheduler.py:RoundScheduler._trigger_payout·app/tx/broadcast.py:bump_fee· Test:tests/unit/test_bets.py,tests/unit/test_scheduler.py,tests/unit/test_tx_versions.py -
[DAT-45] Ogni versione è riconoscibile, una sola è corrente. Per ogni transazione non terminale,
pending_transactions.current_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]); ogni altro txid che la transazione abbia avuto è inpending_transaction_txids([DAT-70]), e la conferma di uno qualunque di essi conferma la transazione.current_txidcambia solo con un aggiornamento condizionato al valore precedente ([DAT-34]), che aggiorna tutti i riferimenti 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·app/tx/broadcast.py:bump_fee·app/tx/confirmation.py:poll_once· Test:tests/unit/test_broadcast.py,tests/unit/test_tx_versions.py -
[DAT-46] Il montepremi di ogni round si conserva, separato dagli altri. Per un round pagato, con i soli partecipanti rimasti
confirmeddopo la verifica di [DAT-67]:- gli input del pagamento sono esattamente gli outpoint (
bet_txid,bet_vout) di quei partecipanti, all'indirizzorounds.pool_addressdel round ([DAT-11]); nessun input viene da un altro round o da un altro indirizzo; pool_amount_sats= somma dei lorobet_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 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,tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there - gli input del pagamento sono esattamente gli outpoint (
-
[DAT-47]Assorbito da RND-12: 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: 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 è ilraw_tx_hexda 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_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=FULLePRAGMA 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 suoderivation_index, B-88). 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·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 rigapending_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 diplace_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 conbegin_immediate(session), che chiude l'eventuale transazione di sola lettura già aperta (di solito la ricerca dell'utente autenticato) e apre quella nuova conBEGIN IMMEDIATE; le altre transazioni, in sola lettura, restanoBEGINsemplice e in WAL non aspettano nessuno. UnOperationalErrordatabase is lockedè ilbusy_timeoutdi [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 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 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 che usibatch_alter_tablesuroundsousersdeve quindi, nella stessaupgrade(), eliminare e ricreare esplicitamente l'indice relativo con SQL grezzo, dopo il bloccobatch_alter_table. Le migrazioni di B-73/B-81/B-95 (e quella di B-80, già scritta) aggiungono colonne arounds: se aggiungono solo colonne conop.add_column(che su SQLite non ricrea la tabella) l'indice resta; se usanobatch_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 headsu un database SQLite vuoto e verifica che, alla fine della catena, esistanoix_rounds_single_activeeix_users_username_lowercon la loro definizione (lette dasqlite_master), e che un secondo round attivo e un secondo username uguale a meno delle maiuscole vengano rifiutati conIntegrityError. 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). 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
I test unitari creano lo schema direttamente dal modello
(Base.metadata.create_all), quindi verificano il modello, non la catena di
revisioni; [DAT-75] aggiunge il test che esegue la catena su un database vuoto.
Storia dello schema
In ordine di applicazione (ogni riga rivede la precedente):
| Revisione | Contenuto |
|---|---|
274efdcbfbcc |
Schema iniziale: round_config (solo fee_address, bet_amount_sats, updated_at), users, rounds, utxo_events, withdrawals, audit_log, pending_transactions, round_participants |
53cc70d16e63 |
round_config: round_duration_seconds, round_cooldown_seconds, min_amount_sats, fee_rate_sat_vb, rbf_timeout_seconds (valori prima presi da app/config.py) |
1db52f3a7c67 |
round_config.draw_animation_seconds |
5f2079b95b33 |
round_config.paused |
6cb50b29f64c |
Rimosso round_config.min_amount_sats: il prelievo minimo è il costo della scommessa |
8a1c4e7b2d90 |
pending_transactions.failure_reason; indice ix_rounds_single_active (B-04, B-09) |
861e76aaf34c |
pending_transactions.last_broadcast_at, con backfill da broadcast_at (B-27) |
943dbd74d983 |
users.token_version (B-34) |
9ef6a51509f7 |
rounds.drawing_started_at (B-36) |
87a0c640355c |
audit_log.payload_json e pending_transactions.raw_tx_hex da VARCHAR a Text (B-47) |
ee8508d98d34 |
Tabella bug_reports, con un booleano resolved |
be71fdac734e |
bug_reports.resolved sostituito da status a tre valori (resolved → 'resolved', il resto → 'open') |
c1d4a97b5e10 |
Indice ix_users_username_lower (B-57), [DAT-58] |
283844a44b4a |
rounds.duration_seconds/cooldown_seconds (B-61), backfill dalla configurazione attuale |
477bddae1a84 |
rounds.pool_address (B-80, [DAT-60]), backfill con l'indirizzo di riserva per i round esistenti — head |
Revisioni attese, non ancora scritte (ciascuna nel commit della sua voce,
con i backfill di [DAT-55]): rounds (bet_amount_sats,
fee_address, draw_confirmations, server_seed, server_seed_hash,
participants_hash, draw_height, payout_blocked_at),
round_participants (bet_vout, draw_position e il vincolo unico),
round_config (deposit_confirmations, draw_confirmations,
max_round_pool_sats, paused_reason, con paused_reason = 'operator' per
un'istanza già in pausa), pending_transactions.failed_at (backfill da
updated_at per le righe failed), e la tabella pending_transaction_txids
(backfill: una riga per current_txid e una per replaced_by_txid non nullo
di ogni riga esistente; per la seconda il raw_hex non è più disponibile e si
scrive la stringa vuota).
Differenze note tra modello e schema migrato
users.token_version: il modello 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 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 riceveOperationalError: database is locked. Non esistono conflitti fra snapshot: unOperationalErrorè sempre un'attesa scaduta. Le transazioni che spostano denaro prendono il lock conBEGIN IMMEDIATEprima 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'eventobegindel motore a emettereBEGINoBEGIN IMMEDIATE, così ogni lettura sta dentro una transazione vera (B-88). - 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.