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

+20 -23
View File
@@ -34,7 +34,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `POST` | `/withdrawals` | Bearer | [API-24](#post-withdrawals) |
| `GET` | `/rounds/current` | facoltativa | [API-25](#get-roundscurrent) |
| `GET` | `/rounds/stream` | nessuna | [API-26](#get-roundsstream) |
| `GET` | `/rounds/{round_id}/proof` | nessuna | [API-45](#get-roundsround_idproof) — *da implementare (B-73)* |
| `GET` | `/rounds/{round_id}/proof` | nessuna | [API-45](#get-roundsround_idproof) |
| `GET` | `/qr/{address}` | nessuna | [API-27](#get-qraddress) |
| `POST` | `/bug-reports` | facoltativa | [API-28](#post-bug-reports) |
| `GET` | `/bug-reports/mine` | Bearer | [API-29](#get-bug-reportsmine) |
@@ -548,10 +548,10 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `draw_block_height` | intero o `null` | altezza del blocco usato per l'estrazione |
| `draw_block_hash` | stringa o `null` | hash di quel blocco; è **uno** degli ingressi del seme, non il seme (vedi [API-45](#get-roundsround_idproof)) |
| `draw_waiting_since` | stringa ISO o `null` | inizio dell'attesa del blocco; valorizzato **solo** mentre `status` è `drawing` |
| `server_seed_hash` | stringa o `null` | impegno del server sul segreto del round, pubblicato dall'apertura; `null` per un round aperto prima di B-73 — *da implementare (B-73)* |
| `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` — *da implementare (B-73)* |
| `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` — *da implementare (B-73)* |
| `draw_confirmations` | intero o `null` | conferme richieste sul blocco di estrazione, fissate all'apertura del round — *da implementare (B-73, B-95)* |
| `server_seed_hash` | stringa o `null` | impegno del server sul segreto del round, pubblicato dall'apertura; `null` per un round aperto prima di B-73 |
| `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` |
| `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` |
| `draw_confirmations` | intero o `null` | conferme richieste sul blocco di estrazione, fissate all'apertura del round |
| `pool_address` | stringa o `null` | indirizzo del montepremi del round, verificabile su un esploratore |
| `chain_tip_height` | intero o `null` | ultima altezza nota della catena; `null` finché non è nota |
| `lottery_paused` | booleano | lotteria in pausa, per qualunque motivo |
@@ -577,9 +577,8 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`app/rounds/service.py:round_deadline`, `app/rounds/service.py:winner_share`,
`app/rounds/service.py:rounds_can_open` ·
*Test:* `tests/unit/test_rounds_route.py`
*Stato:* da implementare (B-73, B-78, B-82, B-85, B-95), per i campi
marcati · *Oggi:* quei campi non esistono, `bet_amount_sats` è sempre il
costo configurato, e `draw_block_hash` è il seme dell'estrazione.
*Stato:* da implementare (B-82, B-95), per i campi marcati · *Oggi:*
`bet_amount_sats` è sempre il costo configurato.
#### GET /rounds/stream
@@ -641,7 +640,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `server_seed` | stringa o `null` | **solo dopo** che il vincitore è stato scritto (`paying_out` e `closed`) | il segreto del round, 32 byte in esadecimale |
| `draw_height` | intero o `null` | ingresso in `drawing` | altezza del blocco di estrazione |
| `draw_confirmations` | intero | apertura | conferme richieste su quel blocco prima di leggerne l'hash |
| `participants` | lista | ingresso in `drawing` | elenco in ordine di `draw_position`: `{"draw_position", "txid", "vout", "address", "status"}`, dove `txid:vout` è l'output della scommessa verso `pool_address`, `address` l'indirizzo del partecipante (dove andrebbe la vincita) e `status` `confirmed` o `reorged_out` |
| `participants` | lista | ingresso in `drawing` | elenco in ordine di `draw_position`: `{"draw_position", "txid", "vout", "address", "amount_sats", "status"}`, dove `txid:vout` è l'output della scommessa verso `pool_address`, `address` l'indirizzo del partecipante (dove andrebbe la vincita), `amount_sats` l'importo netto della scommessa e `status` `confirmed` o `reorged_out` |
| `participants_hash` | stringa o `null` | ingresso in `drawing` | impronta dell'elenco `participants` |
| `draw_block_hash` | stringa o `null` | estrazione | hash del blocco a `draw_height` |
| `seed` | stringa o `null` | estrazione | il seme in decimale (`rounds.seed_int`) |
@@ -675,11 +674,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`pool_address`.
- **Errori:** `404 round_not_found`; `422`.
- **Effetti:** nessuno.
*Codice:* `app/api/routes/rounds.py` (endpoint da scrivere),
`app/rounds/draw.py:draw_winner` · *Test:* —
*Stato:* da implementare (B-73) · *Oggi:* l'endpoint non esiste; l'unico
dato pubblico dell'estrazione è `draw_block_hash` in `GET /rounds/current`,
finché il round è attivo.
*Codice:* `app/api/routes/rounds.py:round_proof`,
`app/rounds/draw.py:draw_winner` ·
*Test:* `tests/unit/test_draw_proof.py`
### QR
@@ -763,9 +760,8 @@ dei parametri operativi in
"draw_confirmations", "max_round_pool_sats", "paused", "paused_reason"}`.
*Codice:* `app/api/routes/admin.py:read_config` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-73, B-82, B-85, B-95) · *Oggi:* mancano
`deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats` e
`paused_reason`.
*Stato:* da implementare (B-82, B-95) · *Oggi:* mancano
`deposit_confirmations` e `max_round_pool_sats`.
#### PUT /admin/config
@@ -783,7 +779,7 @@ dei parametri operativi in
| `rbf_timeout_seconds` | 60 – 604 800 |
| `draw_animation_seconds` | 0 – 600 |
| `deposit_confirmations` | 1 – 100 — *da implementare (B-82)* |
| `draw_confirmations` | 1 – 20 — *da implementare (B-73)* |
| `draw_confirmations` | 1 – 20 |
| `max_round_pool_sats` | `0` (disattivato), oppure da `bet_amount_sats` a 4 000 000 000 000 000 — *da implementare (B-95)* |
I limiti sono quelli di [modello-dati.md](modello-dati.md) (tabella
@@ -802,8 +798,9 @@ dei parametri operativi in
*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 i tre campi nuovi e la
copia all'apertura · *Oggi:* sono accettati solo i primi sette campi.
*Stato:* da implementare (B-82, B-95), per i due campi nuovi e la copia
all'apertura di costo ed `fee_address` · *Oggi:* sono accettati i primi
otto campi.
#### POST /admin/pause e POST /admin/resume
@@ -891,8 +888,8 @@ dei parametri operativi in
completa di un round è in [API-45](#get-roundsround_idproof).
*Codice:* `app/api/routes/admin.py:list_rounds` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-73, B-79, B-82), per gli ultimi quattro
campi · *Oggi:* la lista si ferma a `pool_address`.
*Stato:* da implementare (B-79, B-82), per gli ultimi due campi · *Oggi:*
la lista si ferma a `draw_height`.
#### GET /admin/audit-log
@@ -1065,7 +1062,7 @@ dei parametri operativi in
| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `bug_report_not_found` | 404 | `app/api/routes/admin.py` | — | segnalazione inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `round_not_found` | 404 | `app/api/routes/rounds.py` (`GET /rounds/{id}/proof`) | — | round inesistente — *da implementare (B-73)* | no — da aggiungere |
| `round_not_found` | 404 | `app/api/routes/rounds.py` (`GET /rounds/{id}/proof`) | — | round inesistente | sì |
| `not_found` | 404 | gestore delle eccezioni HTTP in `app/main.py` | — | percorso inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `method_not_allowed` | 405 | gestore delle eccezioni HTTP in `app/main.py` | — | metodo non ammesso — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `too_many_streams` | 503 | `app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |