Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)

The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.

- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
  master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
  `draw_confirmations` is copied from round_config. The plaintext column is written only by
  the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
  bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
  lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
  `closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
  reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
  `draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
  asks every server, the active one included, for the header at that exact height (valid
  work, >= 2 other answers, strict majority of all); no agreement means the same height is
  asked again and `draw_header_corroboration_failed` is written once per distinct reported
  hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
  to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
  one transaction; a round closing with no participants reveals too. A round opened before
  this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
  the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
  /admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
  draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
  (RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.

Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 16:29:09 +02:00
1 parent 82d078e2d6
commit db24287ea5
42 files changed
+1715 -620

No files matched your search

+31 -36
View File
@@ -431,19 +431,19 @@ restano nella tabella come storico.
| `cooldown_seconds` | Integer | no | `30` (anche `server_default`) | Pausa dopo **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` |
| `bet_amount_sats` | BigInteger | no | backfill da `round_config` nella migrazione | Costo della scommessa di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` |
| `fee_address` | String(128) | no | backfill da `round_config` nella migrazione | Destinatario della commissione di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` |
| `draw_confirmations` | Integer | no | `3` (`server_default`) | Conferme richieste sul blocco di estrazione di **questo** round, copiate all'apertura ([DAT-61]) — *da implementare (B-73, B-95)* | `open_new_round_if_needed` |
| `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]) — *da implementare (B-73)* | `open_new_round_if_needed` |
| `server_seed` | String(64) | sì | `NULL` | Il segreto in chiaro (32 byte in esadecimale), scritto **solo** alla rivelazione ([DAT-64]) — *da implementare (B-73)* | `RoundScheduler._close_and_draw` |
| `server_seed_hash` | String(64) | sì | `NULL` | `sha256(server_seed)` in esadecimale, pubblicato dall'apertura ([DAT-64]) — *da implementare (B-73)* | `open_new_round_if_needed` |
| `participants_hash` | String(64) | sì | `NULL` | Impronta dell'elenco ordinato dei partecipanti ([DAT-65]) — *da implementare (B-73, B-81)* | `RoundScheduler._close_and_draw` |
| `draw_height` | Integer | sì | `NULL` | Altezza del blocco di estrazione, fissata all'ingresso in `drawing` ([DAT-65]) — *da implementare (B-73, B-81)* | `_close_and_draw` |
| `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; nello schema atteso è sempre uguale a `draw_height` | `_close_and_draw` |
| `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_close_and_draw` |
| `seed_int` | String(128) | sì | `NULL` | Il seme in decimale (fino a 78 cifre, per questo è una stringa). Atteso: il valore intero di `sha256(server_seed ‖ block_hash ‖ participants_hash)`, con la codifica definita in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73); oggi `int(draw_block_hash, 16)` | `_close_and_draw` |
| `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_close_and_draw` |
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`; `RoundScheduler._trigger_payout` (atteso, B-82) |
| `draw_block_height` | Integer | sì | `NULL` | Altezza del blocco usato per l'estrazione; è sempre uguale a `draw_height` | `_complete_draw` |
| `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_complete_draw` |
| `seed_int` | String(128) | sì | `NULL` | Il seme in decimale (fino a 78 cifre, per questo è una stringa). Il valore intero di `sha256(server_seed ‖ block_hash ‖ participants_hash)`, con la codifica definita in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (RND-17) | `_complete_draw` |
| `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_complete_draw` |
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`, `_complete_draw`; `RoundScheduler._trigger_payout` (atteso, B-82) |
| `winner_amount_sats` | BigInteger | sì | `NULL` | Quanto riceve il vincitore (70% meno la commissione di rete) | `RoundScheduler._trigger_payout` |
| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` nello schema atteso | `_trigger_payout` |
| `bump_reserve_sats` | BigInteger | sì | `NULL` | Output di riserva per l'RBF del pagamento, verso l'indirizzo di riserva ([DAT-73]) — *da implementare (B-79)* | `_trigger_payout`; `app/tx/broadcast.py:bump_fee` |
@@ -575,7 +575,8 @@ restano nella tabella come storico.
*Codice:* `app/rounds/service.py:open_new_round_if_needed` ·
`app/bets/service.py:place_bet` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* —
*Stato:* da implementare (B-95) · *Oggi:* `place_bet` e `_trigger_payout`
*Stato:* da implementare (B-95) · *Oggi:* `draw_confirmations` è già copiato
all'apertura (B-73); `place_bet` e `_trigger_payout`
leggono `bet_amount_sats` e `fee_address` da `round_config` al momento
dell'uso, quindi una modifica a metà round cambia il round in corso.
@@ -598,10 +599,9 @@ restano nella tabella come storico.
([sicurezza.md](sicurezza.md#sec-49) SEC-49). I round aperti prima della
migrazione hanno le tre colonne a `NULL`.
*Codice:* `app/rounds/service.py:open_new_round_if_needed` ·
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
`app/api/routes/rounds.py:current_round` · *Test:* —
*Stato:* da implementare (B-73) · *Oggi:* le colonne non esistono; il seme
è l'hash del blocco da solo.
`app/rounds/scheduler.py:RoundScheduler._complete_draw` ·
`app/api/routes/rounds.py:round_proof` ·
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_draw_proof.py`
- **[DAT-65]** All'ingresso in `drawing`, nella **stessa transazione** che
cambia lo stato, si scrivono `draw_height`, `participants_hash` e il
@@ -612,9 +612,8 @@ restano nella tabella come storico.
dei tre cambia più. Sono ciò che permette di riprendere l'estrazione dopo un
errore o un riavvio ottenendo lo stesso vincitore (B-81): la ripresa legge
questi valori dal database e non ricalcola nulla.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* —
*Stato:* da implementare (B-73, B-81) · *Oggi:* le colonne non esistono;
l'elenco dei partecipanti e la punta alla chiusura restano solo in memoria.
*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ù
@@ -662,10 +661,10 @@ Una scommessa: un utente in un round.
| `user_id` | FK `users.id`, indicizzata | no | — | Chi scommette | `place_bet` |
| `bet_amount_sats` | BigInteger | no | — | Quanto arriva davvero al pool: costo della scommessa **meno** la commissione di rete | `place_bet` |
| `bet_txid` | String(64) | no | — | Txid corrente della scommessa; dopo la conferma, il txid della versione confermata ([DAT-14]) | `place_bet`; `app/tx/broadcast.py:_retarget_txid_references` |
| `bet_vout` | Integer | no | `0` (`server_default`, backfill `0`) | Posizione dell'output verso il montepremi nella transazione della scommessa ([DAT-63]) — *da implementare (B-73)* | `place_bet` |
| `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]) — *da implementare (B-81)* | `app/rounds/scheduler.py:RoundScheduler._close_and_draw` |
| `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
@@ -727,8 +726,6 @@ Una scommessa: un utente in un round.
*Codice:* `app/bets/service.py:place_bet` ·
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-73) · *Oggi:* l'estrazione ordina i partecipanti
confermati per `broadcast_at` crescente, senza criterio di parità.
- **[DAT-63]** `bet_vout` è l'indice dell'output della scommessa che paga
`rounds.pool_address`; con il costruttore attuale è sempre `0`
@@ -736,9 +733,7 @@ Una scommessa: un utente in un round.
l'outpoint che l'elenco dell'estrazione pubblica e che il pagamento spende
([DAT-11]).
*Codice:* `app/bets/service.py:place_bet` ·
`app/wallet/psbt_builder.py:build_signed_transaction` · *Test:* —
*Stato:* da implementare (B-73) · *Oggi:* la colonna non esiste;
l'outpoint della scommessa non è registrato.
`app/wallet/psbt_builder.py:RECIPIENT_VOUT` · *Test:* `tests/unit/test_bets.py`
- **[DAT-66]** `draw_position` si scrive una sola volta, all'ingresso in
`drawing` ([DAT-65]), per ogni partecipante `confirmed` del round: i
@@ -749,9 +744,8 @@ Una scommessa: un utente in un round.
dal vincolo. `draw_position` non cambia nemmeno quando la riga passa a
`reorged_out`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
`app/db/models.py:RoundParticipant` · *Test:* —
*Stato:* da implementare (B-73, B-81) · *Oggi:* la colonna non esiste;
l'ordine esiste solo in memoria durante l'attesa del blocco.
`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
@@ -793,7 +787,7 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
| `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (10 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` |
| `rbf_timeout_seconds` | Integer | `900` | 60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) |
| `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) — *da implementare (B-82)* | Subito, per gli accrediti successivi; gli output già accreditati restano |
| `draw_confirmations` | Integer | `3` | 1 – 20 | Conferme richieste sul blocco di estrazione ([DAT-62]) — *da implementare (B-73)* | Dal round **successivo**, copiato all'apertura ([DAT-61]) |
| `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]) — *da implementare (B-95)* | Subito, anche per il round in corso: vale per le scommesse successive |
| `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa della lotteria | Subito per l'apertura del round successivo; il round in corso non è toccato |
| `paused_reason` | String(16) | `NULL` | non modificabile da `PUT /admin/config` | Perché la lotteria è in pausa ([DAT-69]) | Insieme a `paused` |
@@ -817,8 +811,8 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
valore uguale a quello attuale non conta come modifica.
*Codice:* `app/api/routes/admin.py:update_config` ·
`app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-73, B-82, B-95), per le ultime tre colonne ·
*Oggi:* le colonne modificabili sono le prime sette.
*Stato:* da implementare (B-82, B-95), per le ultime due colonne ·
*Oggi:* le colonne modificabili sono le prime otto.
- **[DAT-30]** `paused` e `paused_reason` si modificano solo con
`POST /admin/pause` e `POST /admin/resume`, che scrivono `lottery_paused` e
@@ -841,9 +835,10 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
[scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95). La migrazione le
aggiunge con un `server_default` temporaneo pari al predefinito ([DAT-55]).
*Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` ·
*Test:* —
*Stato:* da implementare (B-73, B-82, B-95) · *Oggi:* le colonne non
esistono; depositi ed estrazione usano 1 conferma e non c'è tetto.
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82, B-95) · *Oggi:* `deposit_confirmations` e
`max_round_pool_sats` non esistono: i depositi usano 1 conferma e non c'è
tetto; `draw_confirmations` esiste.
- **[DAT-69]** `paused_reason` è `NULL` esattamente quando `paused` è falso;
con `paused` vero vale uno di questi valori, e nessun altro:
@@ -1214,7 +1209,7 @@ Tabella di servizio di Alembic: una riga con la revisione applicata
| `ix_utxo_events_user_id` | `utxo_events` | indice su `user_id` | calcolo del saldo e selezione degli UTXO per utente |
| `ix_rounds_single_active` | `rounds` | `UNIQUE ((1)) WHERE status IN ('open','closing','drawing','paying_out')` | al massimo un round attivo, [DAT-17] (B-09) |
| unico (`round_id`, `user_id`) | `round_participants` | `UNIQUE` | una scommessa per utente per round, [DAT-23] |
| unico (`round_id`, `draw_position`) | `round_participants` | `UNIQUE` (i `NULL` non collidono) | una posizione di estrazione per partecipante, [DAT-66] — *da implementare (B-81)* |
| unico (`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 |