Files
plm-lottery/docs/ciclo-di-vita-del-round.md
T
davideandClaude Sonnet 5.5 8b182ac8b6 Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto
the round when it opened. The cost of a bet and the fee address were read live, so an edit from
/admin in the middle of a round gave the players of that round different stakes for the same
chance and redirected the commission of the round in progress.

- `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from
  round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column,
  existing rounds backfilled with the current configuration, i.e. what they were using; the
  expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the
  round's fee address (an empty one on the row still stops the payout, as before),
  `GET /rounds/current` advertises the round's cost while one is active and the configured one
  otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10).
- `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in
  /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry
  `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included,
  has reached the round's cap, before anything moves; the pool can exceed it by one bet at most.
  A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`.
- PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the
  bet cost the round in progress opened with, which can be lower than the configured one.
- `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the
  seven translations are generic.

Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and
"applies immediately, also to the round in progress" in the round_config table of modello-dati;
the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line).

Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and
the old "fee address missing" payout test now empties the round's address (clearing it in the
configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open,
edits reaching only the next round, every bet of a round paying the same stake whether the cost
is raised or lowered meanwhile, the payout following the round's address (and surviving it being
cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap,
a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route,
the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the
migration backfill. Mutation-checked: charging the live cost, paying the live address, not
copying the cap and `>` instead of `>=` each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:12:00 +02:00

39 KiB
Raw Blame History

Ciclo di vita del round

Fase 4 del funzionamento generale: come un round si apre, si chiude, estrae un vincitore e lo paga. Tutto è guidato da RoundScheduler, che esegue un passo ogni 5 secondi.

I requisiti usano il prefisso RND, secondo la convenzione di processo-di-modifica.md. Le colonne della tabella rounds sono descritte in modello-dati.md, gli eventi di audit in audit-log.md, le attività in background in architettura.md.

Questo documento descrive il comportamento atteso dopo la revisione del 2026-09-28. I requisiti che il codice non soddisfa ancora sono marcati Stato: da implementare, con una riga Oggi che dice cosa fa il codice adesso; l'ordine di implementazione è in piano-correzioni.md.

  • [RND-01] Lo scheduler esegue un passo, poi attende _TICK_INTERVAL_SECONDS (5s) prima del successivo. Un passo non fa nulla se non c'è connessione a Electrum: senza rete nessun round si apre, si chiude, estrae o si paga. Un errore in un passo viene registrato nei log e il passo successivo riparte dallo stato salvato nel database, in qualunque stato sia il round, drawing compreso ([RND-36]). Codice: app/rounds/scheduler.py:RoundScheduler.run, app/rounds/scheduler.py:RoundScheduler._tick · Test: tests/unit/test_scheduler.py, tests/unit/test_draw_proof.py::test_a_tick_resumes_a_drawing_round_after_an_exception_with_the_same_draw

Gli stati

Diagramma del comportamento atteso:

stateDiagram-v2
    [*] --> open: nessun round attivo,<br/>pausa tra i round trascorsa,<br/>lotteria non in pausa,<br/>configurazione pagabile
    open --> closing: scade il tempo<br/>(al primo passo dopo la scadenza)
    closing --> closing: scommesse ancora in volo,<br/>oppure altezza della catena non nota
    closing --> closed: nessun partecipante confermato
    closing --> drawing: tutte le scommesse confermate:<br/>partecipanti, participants_hash<br/>e draw_height salvati
    drawing --> drawing: conferme insufficienti, oppure<br/>i server non concordano sull'header:<br/>si richiede la stessa altezza<br/>(anche dopo un errore o un riavvio)
    drawing --> paying_out: header a draw_height concordato,<br/>vincitore salvato, server_seed rivelato
    paying_out --> paying_out: pagamento fallito o abbandonato,<br/>nuovo tentativo dopo 60s;<br/>oppure bloccato (payout_blocked)
    paying_out --> closed: pagamento confermato
    closed --> [*]
Stato Significato
open Accetta scommesse fino alla scadenza
closing Scadenza passata: nessuna nuova scommessa, si aspettano quelle già inviate
drawing Partecipanti e altezza del blocco fissati, si attendono le conferme del blocco che sceglie il vincitore
paying_out Vincitore noto, pagamento in verifica, in costruzione, inviato o in attesa di conferma
closed Concluso (con o senza vincitore)
  • [RND-02] Gli stati sono esattamente quelli della tabella e le sole transizioni sono quelle del diagramma; closed è definitivo. Codice: app/rounds/scheduler.py:RoundScheduler._tick, app/rounds/scheduler.py:RoundScheduler._close_and_draw, app/rounds/confirmation.py:_on_payout_confirmed · Test: tests/unit/test_scheduler.py, tests/unit/test_confirmation.py
  • [RND-03] Al massimo un round attivo alla volta (cioè in uno stato diverso da closed). Non è una convenzione ma un vincolo del database: un indice univoco (ix_rounds_single_active) fa fallire un secondo inserimento concorrente, e il codice di apertura in quel caso adotta il round già creato (fino a 3 tentativi, _OPEN_ROUND_ATTEMPTS). Se mai risultassero due round attivi, si usa il più recente e lo si segnala nei log come violazione. 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

0 · Apertura

L'apertura viene tentata a ogni passo dello scheduler e anche da ogni richiesta di scommessa (vedi scommesse-e-prelievi.md).

  • [RND-04] Un nuovo round si apre solo quando tutte queste condizioni sono vere:

    • nessun round è attivo (il precedente è closed);
    • è trascorsa la pausa tra i round del round chiuso più recente, contata dal suo closed_at: per un round pagato è il momento della conferma del pagamento, per un round chiuso senza partecipanti è il momento della chiusura. Serve a lasciare ai giocatori il tempo di vedere il risultato;
    • la lotteria non è in pausa, né per manutenzione (POST /admin/pause) né per una pausa automatica: riorganizzazione della catena profonda almeno 2 blocchi (connessione-electrum.md (B-85)), pagamento bloccato ([RND-39]), transazione abbandonata poi confermata (transazioni-e-riconciliazione.md (B-78)), avvio con START_PAUSED=true (operativita.md (B-90)). Il motivo è salvato in paused_reason (modello-dati.md (B-85)) e solo l'operatore toglie la pausa;
    • l'indirizzo delle commissioni (fee_address) è impostato (un valore di soli spazi conta come vuoto) e la configurazione corrente permette di pagare anche un round con un solo partecipante ([RND-40]). Altrimenti il pagamento non si potrebbe costruire, quindi il round non viene nemmeno aperto invece di bloccarsi più avanti con i soldi già nel montepremi.

    Codice: app/rounds/service.py:open_new_round_if_needed, app/rounds/service.py:rounds_can_open · Test: tests/unit/test_rounds_service.py

  • [RND-05] All'apertura il round fotografa i parametri correnti che lo governano: durata (round_duration_seconds, 600s predefiniti), pausa successiva (round_cooldown_seconds, 30s predefiniti), costo della scommessa (bet_amount_sats), indirizzo delle commissioni (fee_address), conferme richieste sul blocco di estrazione (draw_confirmations, 3 predefinite) e tetto al montepremi (max_round_pool_sats, 0 = nessun tetto; il rifiuto delle scommesse oltre il tetto è in scommesse-e-prelievi.md (B-95)). Il round usa sempre i valori fotografati: una modifica da /admin vale dal round successivo, mai per quello in corso. Codice: app/rounds/service.py:open_new_round_if_needed, app/rounds/service.py:round_deadline · Test: tests/unit/test_rounds_service.py, tests/unit/test_scheduler.py (durata e pausa), tests/unit/test_round_snapshot.py

  • [RND-06] Pause e configurazione non pagabile bloccano solo l'apertura del prossimo round: un round già in corso prosegue fino al pagamento con i valori fotografati all'apertura ([RND-05]), quindi una modifica di fee_address durante il round non ne cambia la destinazione della commissione. L'API di amministrazione non permette di svuotare fee_address (un valore vuoto non è un indirizzo PLM valido). GET /rounds/current espone i due casi separatamente (lottery_paused, lottery_configured) perché / mostri avvisi diversi. Le procedure sono in guida-admin.md. Codice: app/rounds/service.py:open_new_round_if_needed, app/api/routes/rounds.py:current_round, app/api/routes/admin.py:RoundConfigUpdate · Test: tests/unit/test_rounds_service.py, tests/unit/test_rounds_route.py, tests/unit/test_round_snapshot.py::test_the_payout_pays_the_fee_address_the_round_opened_with, tests/unit/test_round_snapshot.py::test_clearing_the_fee_address_mid_round_does_not_stop_its_payout Un fee_address vuoto sulla riga del round (solo modificando il database a mano) blocca il pagamento con no fee_address configured.

  • [RND-07] L'apertura di un round invia una notifica SSE. Codice: app/rounds/service.py:open_new_round_if_needed

  • [RND-34] Impegno sul segreto: all'apertura il server genera un segreto di 32 byte casuali (server_seed, da un generatore crittograficamente sicuro), lo salva sul round cifrato con la chiave della master xprv (server_seed_enc, modello-dati.md DAT-64: il database non contiene mai il segreto in chiaro prima della rivelazione) e pubblica solo la sua impronta server_seed_hash = sha256(server_seed), esposta da GET /rounds/current e dalla prova pubblica ([RND-37]) fin dall'apertura. server_seed non è esposto da nessun endpoint, /admin compreso, finché il vincitore non è salvato ([RND-18]). Codice: app/rounds/service.py:open_new_round_if_needed, app/rounds/draw.py:new_server_seed, app/wallet/keystore.py:encrypt_round_seed, app/api/routes/rounds.py:current_round · Test: tests/unit/test_rounds_service.py, tests/unit/test_draw_proof.py

  • [RND-35] Un indirizzo del montepremi per round: all'apertura il round riceve un proprio indirizzo m/84'/746'/0'/2/<round_id>, salvato in rounds.pool_address ed esposto da GET /rounds/current. Le scommesse del round pagano quell'indirizzo (scommesse-e-prelievi.md) e il pagamento spende solo monete di quell'indirizzo ([RND-22]). I round aperti prima di questa modifica mantengono l'indirizzo condiviso m/84'/746'/0'/1/0, che diventa l'indirizzo di riserva ([RND-38]). Il rifiuto dei prelievi verso indirizzi di montepremi, riserva o commissioni è in scommesse-e-prelievi.md. Codice: app/rounds/service.py:open_new_round_if_needed, app/wallet/hd.py:derive_pool_key, app/wallet/hd.py:derive_round_pool_key · Test: tests/unit/test_round_pool_address.py

1 · Round aperto

Il round accetta scommesse fino a apertura + durata. Le regole di accettazione sono in scommesse-e-prelievi.md.

  • [RND-08] La scadenza (closes_at) è opened_at più la durata fotografata all'apertura; è la stessa usata per rifiutare le scommesse e per chiudere il round. Codice: app/rounds/service.py:round_deadline, app/rounds/service.py:round_accepts_bets · Test: tests/unit/test_rounds_service.py

  • [RND-09] GET /rounds/current pubblica il montepremi in due forme:

    • jackpot_sats e participant_count: calcolati sulle sole scommesse confermate — esattamente ciò che l'estrazione considererà e che il pagamento potrà spendere. jackpot_sats è già il 70% del vincitore (arrotondato per difetto) della somma degli importi netti, non l'intero montepremi; resta un limite superiore, perché la commissione di rete del pagamento sarà dedotta da lì;
    • pending_jackpot_sats e pending_participant_count: gli stessi valori includendo le scommesse ancora in volo (building, broadcast), come totali e non come differenze; has_pending_bets dice se differiscono.

    Codice: app/api/routes/rounds.py:current_round, app/rounds/service.py:winner_share · Test: tests/unit/test_rounds_route.py

  • [RND-10] user_played è vero solo se chi chiama è autenticato e ha una scommessa nel round attivo; senza login la risposta non è personalizzata. Codice: app/api/routes/rounds.py:current_round · Test: tests/unit/test_rounds_route.py

L'interfaccia mostra in grande i valori confermati e la differenza come suffisso ambra "+N in attesa": chi ha appena scommesso vede subito la propria giocata, ma il montepremi pubblicizzato non supera mai quello che verrà pagato.

2 · Chiusura

  • [RND-11] Al primo passo dello scheduler che inizia dopo la scadenza (i passi sono separati da _TICK_INTERVAL_SECONDS = 5s di attesa) il round passa a closing, qualunque cosa stia accadendo: si annota closed_at e si invia una notifica SSE. Le scommesse sono però rifiutate già dall'istante della scadenza (BET-04). Codice: app/rounds/scheduler.py:RoundScheduler._tick · Test: tests/unit/test_scheduler.py (verifica solo lo stato finale; nessun test controlla closed_at né la notifica SSE)

  • [RND-12] Poi il round attende che ogni scommessa già inviata sia confermata (il "semaforo giallo"): finché esiste un partecipante in stato building o broadcast il round resta closing e il controllo si ripete al passo successivo. Il controllo viene ripetuto anche nella stessa sessione del database in cui si fissa l'elenco dei partecipanti, così una scommessa salvata tra i due controlli non può restare esclusa. Una scommessa partita un attimo prima della scadenza resta quindi valida e non va persa. L'attesa non ha un limite di tempo: finisce solo quando ogni scommessa in volo è confermata oppure la sua riga viene abbandonata dal riconciliatore, che la abbandona solo con una prova (TX-17, B-78). Una scommessa che resta nel mempool non viene mai abbandonata: a farla confermare è l'aumento della commissione (TX-11), pagato dall'utente. Codice: app/rounds/scheduler.py:RoundScheduler._tick, app/rounds/scheduler.py:RoundScheduler._close_and_draw · Test: tests/unit/test_scheduler.py (solo il secondo controllo, in _close_and_draw; nessun test copre l'attesa in _tick con una scommessa in volo), tests/unit/test_reconcile_proof.py

  • [RND-13] Quando non resta nulla in volo e nessun partecipante è confermato, il round passa a closed senza vincitore, si scrive round_closed (con participants: 0) e si invia una notifica SSE. Codice: app/rounds/scheduler.py:RoundScheduler._close_and_draw · Test: tests/unit/test_scheduler.py

  • [RND-14] Con almeno un partecipante confermato, nella stessa transazione del database che porta il round a drawing:

    • si fissa l'elenco dei partecipanti confermati, ordinato per txid della scommessa in ordine crescente (txid in esadecimale minuscolo, nell'ordine mostrato dagli explorer; due scommesse non hanno mai lo stesso txid), e si salva su ogni partecipante la sua posizione draw_position, da 0 a n−1;
    • si calcola e si salva participants_hash = sha256 delle righe txid:vout:indirizzo, una per partecipante nell'ordine di draw_position, dove vout è l'uscita della scommessa verso l'indirizzo del montepremi del round (in decimale) e indirizzo è quello dell'utente; ogni riga termina con \n, testo UTF-8;
    • si salva l'altezza del blocco di estrazione ([RND-33]);
    • si somma il montepremi dagli importi netti e si annota drawing_started_at.

    Dopo il commit si invia una notifica SSE; da quel momento participants_hash e draw_height sono pubblici ([RND-37]). Codice: app/rounds/scheduler.py:RoundScheduler._close_and_draw, app/rounds/draw.py:participants_hash · Test: tests/unit/test_scheduler.py, tests/unit/test_draw.py, tests/unit/test_draw_proof.py

  • [RND-33] L'altezza del blocco di estrazione è fissata all'ingresso in drawing come draw_height = tip_at_close + 1, dove tip_at_close è l'altezza della catena nota in quell'istante, e non cambia più: né per una conferma mancata, né per un errore, né per un riavvio. Codice: app/rounds/scheduler.py:RoundScheduler._close_and_draw · Test: tests/unit/test_scheduler.py

3 · Estrazione

L'estrazione combina tre valori che nessuno conosce tutti insieme prima che sia troppo tardi per cambiarli:

  • il segreto del server (server_seed), fissato all'apertura e noto solo al server, ma impegnato pubblicamente con la sua impronta ([RND-34]): il server non può cambiarlo dopo;
  • l'hash del blocco all'altezza draw_height, che non esisteva quando partecipanti e altezza sono stati fissati;
  • l'impronta dei partecipanti (participants_hash), pubblicata prima che quel blocco esista ([RND-14]).

Chi produce o trasmette i blocchi non conosce il segreto, quindi non sa quale blocco farebbe vincere chi: non ha motivo di nasconderne o sceglierne uno. L'operatore conosce il segreto ma non il blocco, e non può più cambiare né il segreto né l'elenco. Diagramma del comportamento atteso:

flowchart TB
    A["Ingresso in drawing: salvati<br/>partecipanti ordinati per txid,<br/>participants_hash, draw_height = tip + 1"] --> B{"La catena ha draw_confirmations<br/>blocchi a partire da draw_height?<br/>(controllo a ogni passo)"}
    B -- "No" --> B
    B -- "Sì" --> C{"Tutti i server interrogati<br/>sull'header a draw_height:<br/>il quorum concorda?"}
    C -- "No" --> D["Registrato in audit<br/>(draw_header_corroboration_failed);<br/>al passo successivo si richiede<br/>la stessa altezza"] --> B
    C -- "Sì" --> E["seme = sha256(server_seed ‖ hash del blocco ‖ participants_hash)<br/>indice = seme mod numero partecipanti"]
    E --> F["Vincitore = partecipante con draw_position = indice,<br/>salvato; poi server_seed rivelato"]
  • [RND-15] L'estrazione attende che il blocco a draw_height abbia draw_confirmations conferme (il valore fotografato all'apertura, [RND-05]), cioè che l'altezza della catena sia almeno draw_height + draw_confirmations − 1. La condizione si controlla a ogni passo dello scheduler, rileggendo dal database tutto ciò che serve ([RND-36]). Codice: app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block, app/rounds/scheduler.py:RoundScheduler._tick · Test: tests/unit/test_scheduler.py, tests/unit/test_draw_proof.py::test_a_restarted_scheduler_resumes_a_drawing_round_and_a_second_tick_draws_nothing
  • [RND-16] Header concordato: raggiunte le conferme, l'header all'altezza draw_height (e non la punta corrente) viene chiesto a tutti i server configurati, attivo compreso, e lo si usa solo se il quorum concorda sullo stesso hash, con il numero minimo di risposte fissato in connessione-electrum.md (B-84). Se il quorum non concorda o non risponde abbastanza, si scrive draw_header_corroboration_failed (altezza e hash riportati) e al passo successivo si richiede la stessa altezza: il round non passa mai a un blocco diverso. L'evento si scrive una volta per ogni hash diverso riportato dal server attivo per quell'altezza, non a ogni richiesta, così un disaccordo che dura non scrive una riga ogni 5 secondi; la durata dell'attesa resta visibile tramite draw_stalled ([RND-20]). Codice: app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block, app/electrum/listener.py:ElectrumListener.agreed_block_hash · Test: tests/unit/test_scheduler.py, tests/unit/test_electrum_listener.py
  • [RND-17] Algoritmo: seme = sha256 della concatenazione di server_seed (32 byte), hash del blocco a draw_height (32 byte, nell'ordine mostrato dagli explorer) e participants_hash (32 byte), letto come intero senza segno big-endian; indice = seme modulo numero dei partecipanti; vincitore = il partecipante con draw_position uguale all'indice. Probabilità uguale per tutti: ogni partecipante ha una e una sola posizione nell'elenco, indipendentemente dall'importo. Codice: app/rounds/draw.py:draw_seed, app/rounds/draw.py:draw_index, app/rounds/draw.py:draw_winner, app/rounds/scheduler.py:RoundScheduler._complete_draw · Test: tests/unit/test_draw.py, tests/unit/test_draw_proof.py
  • [RND-18] Verificabile: in un'unica transazione del database il round salva altezza e hash del blocco (draw_block_height = draw_height, draw_block_hash), il seme (seed_int), il vincitore e il montepremi (pool_amount_sats), passa a paying_out e scrive winner_drawn (indirizzo del vincitore, montepremi, altezza, hash, numero di partecipanti, participants_hash, draw_height, server_seed e seed in esadecimale, audit-log.md AUD-24). server_seed entra nel registro nella stessa transazione in cui il vincitore viene salvato, e diventa pubblico tramite la prova ([RND-37]) solo dopo quel commit; si invia una notifica SSE. Da quel momento chiunque, con i dati della prova pubblica e la catena, può rifare il calcolo e ottenere lo stesso vincitore (vedi Come si verifica un'estrazione). Il primo tentativo di pagamento parte subito dopo, nello stesso passo. Codice: app/rounds/scheduler.py:RoundScheduler._complete_draw · Test: tests/unit/test_draw_proof.py
  • [RND-19] Altezza sconosciuta alla chiusura: se, quando il round sarebbe pronto per drawing, l'altezza della catena non è nota (valore 0), il round resta closing e il passaggio viene ritentato al passo successivo, scrivendo draw_baseline_tip_unknown una volta per round: draw_height si fissa solo su un'altezza realmente nota ([RND-33]). In condizioni normali non accade, perché la connessione è resa disponibile solo con l'altezza già nota (ELC-07). Codice: app/rounds/scheduler.py:RoundScheduler._close_and_draw · Test: tests/unit/test_scheduler.py
  • [RND-20] Attesa lunga ma visibile: l'attesa non ha un limite, perché dipende davvero dall'arrivo dei blocchi. Ogni 60 secondi (_DRAW_PROGRESS_LOG_INTERVAL_SECONDS) se ne registra l'avanzamento nei log. Se per 360 secondi (_DRAW_STALL_THRESHOLD_SECONDS) la catena non avanza mentre il round attende le conferme, oppure il quorum continua a non concordare, viene scritto un evento draw_stalled, ripetuto ogni 360 secondi finché la situazione dura. Durante drawing, GET /rounds/current espone draw_waiting_since (l'orario di inizio dell'attesa); negli altri stati il campo è assente. Codice: app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block, app/api/routes/rounds.py:current_round · Test: tests/unit/test_scheduler.py, tests/unit/test_rounds_route.py
  • [RND-36] Estrazione ripresa: tutto ciò che l'estrazione usa (server_seed, elenco con draw_position, participants_hash, draw_height, draw_confirmations) è nel database dall'ingresso in drawing. A ogni passo, per un round in drawing, lo scheduler rifà il controllo di [RND-15] da quei valori, anche dopo un errore in un passo precedente o un riavvio del server. La ripresa è idempotente: l'estrazione ripresa dà sempre lo stesso vincitore, e un round già passato a paying_out non viene estratto di nuovo. Codice: app/rounds/scheduler.py:RoundScheduler._tick, app/rounds/scheduler.py:RoundScheduler._complete_draw · Test: tests/unit/test_draw_proof.py::test_a_drawing_round_is_completed_by_a_second_pass_with_the_same_winner, tests/unit/test_draw_proof.py::test_a_tick_resumes_a_drawing_round_after_an_exception_with_the_same_draw, tests/unit/test_draw_proof.py::test_a_restarted_scheduler_resumes_a_drawing_round_and_a_second_tick_draws_nothing, tests/unit/test_draw_proof.py::test_resuming_after_a_failure_inside_the_draw_transaction_leaves_no_half_state
  • [RND-37] Prova pubblica: GET /rounds/{id}/proof, senza autenticazione, restituisce per un round: server_seed_hash e pool_address (dall'apertura); dall'ingresso in drawing l'elenco dei partecipanti (draw_position, txid, vout, indirizzo, importo netto), participants_hash, draw_height e draw_confirmations; dal salvataggio del vincitore server_seed, draw_block_hash, il seme, l'indice, il vincitore e, quando esiste, il txid del pagamento. Un campo non ancora disponibile è null. Il formato della risposta è in api.md (B-73). Codice: app/api/routes/rounds.py:round_proof · Test: tests/unit/test_draw_proof.py

Come si verifica un'estrazione

Chiunque può controllare un round concluso con la sola prova pubblica e un qualunque nodo o explorer PLM, senza fidarsi del server:

  1. Impegno. Annotare server_seed_hash mentre il round è aperto (è pubblico dall'apertura). Dopo la rivelazione, verificare che sha256(server_seed) sia uguale a quel valore: il segreto usato è quello fissato all'apertura.
  2. Partecipanti. Per ogni riga dell'elenco verificare sulla catena che la transazione txid esista, sia confermata, abbia l'uscita vout verso pool_address con l'importo indicato e sia firmata dall'indirizzo indicato. Poiché l'indirizzo del montepremi è del solo round ([RND-35]), la sua cronologia mostra anche che nessuna scommessa confermata prima della chiusura manca dall'elenco.
  3. Ordine. Verificare che le righe siano in ordine crescente di txid e che draw_position vada da 0 a n−1 senza salti.
  4. Impronta dei partecipanti. Ricostruire le righe txid:vout:indirizzo (ognuna terminata da \n), calcolarne lo sha256 e confrontarlo con participants_hash. Chi ha osservato la prova durante drawing sa anche che impronta e draw_height erano pubblici prima che il blocco esistesse.
  5. Blocco. Verificare che draw_block_hash sia l'hash del blocco all'altezza draw_height della catena principale.
  6. Calcolo. Calcolare sha256 della concatenazione dei 32 byte di server_seed, dei 32 byte dell'hash del blocco e dei 32 byte di participants_hash; leggerlo come intero big-endian; l'indice è quel numero modulo n. Il vincitore dichiarato deve essere la riga con quella draw_position.
  7. Pagamento. Verificare che la transazione di pagamento spenda le scommesse del round e paghi l'indirizzo del vincitore ([RND-21], [RND-23]).

Il controllo non esclude un operatore che sia anche minatore: conoscendo il segreto potrebbe scegliere quale blocco pubblicare a draw_height. È un rischio accettato, descritto in limiti-noti.md (B-73). L'algoritmo resta un componente sostituibile (vedi limiti-noti.md).

4 · Pagamento

Con il vincitore noto il round è in paying_out: si verificano le monete del round, si costruisce il pagamento e lo si firma con la chiave dell'indirizzo del montepremi del round.

  • [RND-21] Ripartizione: il vincitore riceve il 70% del montepremi verificato ([RND-39]) arrotondato per difetto, meno la commissione di rete ([RND-23]); l'indirizzo delle commissioni riceve il resto (30% più l'eventuale resto dell'arrotondamento) meno la riserva per l'RBF ([RND-38]). La ripartizione è fissata nel codice, non modificabile da /admin. Codice: app/rounds/service.py:winner_share, app/rounds/scheduler.py:RoundScheduler._trigger_payout · Test: tests/unit/test_payout_builder.py, tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there

  • [RND-22] Il pagamento spende le uscite delle scommesse dei partecipanti del round verificate ([RND-39]), all'indirizzo del montepremi del round ([RND-35]), e nient'altro: altre monete arrivate a quell'indirizzo non vengono toccate. Sono escluse quelle già impegnate da un altro pagamento non risolto ([RND-26]); il limite è di 500 ingressi (MAX_PAYOUT_TX_INPUTS), superiore al massimo di partecipanti per round (BET-06). Troppi ingressi dà il motivo too_many_inputs. Per i round aperti prima dell'indirizzo per round si spendono le monete confermate dell'indirizzo condiviso, scelte dalla più grande fino a coprire il montepremi; non abbastanza monete dà insufficient pool UTXOs. Codice: app/rounds/scheduler.py:RoundScheduler._trigger_payout, app/wallet/psbt_builder.py:build_payout_transaction · Test: tests/unit/test_scheduler.py, tests/unit/test_payout_builder.py, tests/unit/test_round_pool_address.py, tests/unit/test_pool_verification.py::test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else

  • [RND-23] La transazione ha un'uscita verso l'indirizzo del vincitore, una verso l'indirizzo delle commissioni fotografato all'apertura ([RND-05]) e una verso l'indirizzo di riserva ([RND-38]). Se gli ingressi superano quanto serve (solo per i round aperti prima dell'indirizzo per round), il resto va all'indirizzo di riserva se è di almeno 294 sat (DUST_LIMIT_SATS), altrimenti va alla commissione di rete. La commissione di rete (stimata su tre uscite, a fee_rate_sat_vb) è pagata dalla quota del vincitore, così la quota delle commissioni non la paga. Se la quota del vincitore meno la commissione di rete, o la quota delle commissioni meno la riserva, è inferiore a 294 sat, il pagamento non si costruisce (winner_share_below_network_fee, commission_below_dust_limit). Codice: app/wallet/psbt_builder.py:build_payout_transaction · Test: tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share, tests/unit/test_payout_builder.py::test_payout_folds_excess_input_value_into_the_reserve_output, tests/unit/test_payout_builder.py::test_payout_leaves_a_dust_sized_excess_to_the_network_fee, tests/unit/test_payout_builder.py::test_payout_with_one_participant_at_the_smallest_viable_pool

  • [RND-24] Prima si scrive, poi si invia: prima dell'invio il round registra importo del vincitore (winner_amount_sats), commissioni (fee_amount_sats), riserva per l'RBF (bump_reserve_sats, [RND-38]) e txid (payout_txid), e si salva una transazione in corso di tipo payout in stato building. Se l'invio riesce, la transazione passa a pending e si scrive payout_sent. Se non riesce, si scrive payout_failed e la riga resta building: la risolve il riconciliatore, secondo le regole di invio e di abbandono di transazioni-e-riconciliazione.md (TX-18, B-76, B-78); solo un abbandono libera il round per un nuovo tentativo. Codice: app/rounds/scheduler.py:RoundScheduler._trigger_payout · Test: tests/unit/test_scheduler.py, tests/unit/test_scheduler.py::test_trigger_payout_always_carries_a_bump_reserve_to_the_reserve_address

  • [RND-25] Ogni tentativo che si ferma prima di un invio riuscito scrive un evento payout_failed con il motivo: electrum client not connected, no fee_address configured, winner user not found, insufficient pool UTXOs, il codice dell'errore di costruzione (too_many_inputs, winner_share_below_network_fee, commission_below_dust_limit), payout build failed per ogni altro errore di costruzione, broadcast rejected, e pool verification unavailable quando la verifica del montepremi prima del pagamento ([RND-39]) non ottiene abbastanza risposte dal quorum. Fanno eccezione due casi, che non scrivono payout_failed: un pagamento già in volo per il round ([RND-26]), che termina il tentativo senza scrivere nulla, e un pagamento bloccato ([RND-39]), che scrive payout_blocked. A ogni passo lo scheduler riesamina un round in paying_out e riprova al massimo una volta ogni 60 secondi (_PAYOUT_RETRY_INTERVAL_SECONDS), misurati dall'ultimo payout_failed di quel round; senza alcun payout_failed (ad esempio dopo un riavvio, o mentre un pagamento è in volo) riprova al passo successivo. Codice: app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due, app/rounds/scheduler.py:RoundScheduler._log_payout_failure · Test: tests/unit/test_scheduler.py (verificati solo i motivi electrum client not connected e no fee_address configured; per l'invio fallito solo la presenza di payout_failed)

  • [RND-26] Due protezioni impediscono di pagare due volte: non si costruisce un nuovo pagamento se per il round ne esiste già uno building o pending (il tentativo termina senza fare nulla e senza scrivere eventi), e le monete impegnate da un qualunque pagamento non risolto, anche di un round precedente, sono escluse dalla selezione. Codice: app/rounds/scheduler.py:RoundScheduler._trigger_payout, app/rounds/scheduler.py:_reserved_payout_outpoints · Test: tests/unit/test_scheduler.py

  • [RND-27] Se il pagamento non conferma in tempo si applica l'RBF, pagato riducendo l'uscita di riserva ([RND-38]); le uscite del vincitore e delle commissioni non cambiano (TX-11). Codice: app/tx/broadcast.py:bump_fee, app/tx/broadcast.py:_find_reducible_output · Test: tests/unit/test_broadcast.py::test_a_payout_is_bumped_out_of_its_reserve_output_and_nothing_else, tests/unit/test_broadcast.py::test_a_payout_can_be_bumped_repeatedly_while_the_reserve_lasts

  • [RND-28] Quando il pagamento è confermato il round passa a closed e closed_at viene riscritto con l'orario della conferma: da lì parte la pausa prima del round successivo. Il vincitore si vede accreditare la vincita come un normale deposito (registrazione-e-depositi.md). Codice: app/rounds/confirmation.py:_on_payout_confirmed · Test: tests/unit/test_confirmation.py

  • [RND-38] Riserva per l'RBF: ogni pagamento ha un'uscita di riserva verso l'indirizzo di riserva m/84'/746'/0'/1/0 di valore max(DUST_LIMIT_SATS, estimate_vsize(ingressi, 3) × 10 sat/vB), dove 10 sat/vB è la costante PAYOUT_BUMP_RESERVE_SAT_VB. La riserva è tolta dalla quota delle commissioni, mai da quella del vincitore; è ciò che l'RBF riduce ([RND-27]). Codice: app/wallet/psbt_builder.py:build_payout_transaction, app/wallet/psbt_builder.py:payout_bump_reserve, app/wallet/hd.py:derive_pool_key · Test: tests/unit/test_payout_builder.py, tests/unit/test_scheduler.py::test_trigger_payout_always_carries_a_bump_reserve_to_the_reserve_address

  • [RND-39] Verifica prima del pagamento: prima di costruire il pagamento, le uscite delle scommesse dei partecipanti vengono verificate all'indirizzo del montepremi del round con il quorum dei server (connessione-electrum.md (B-82)).

    • Un partecipante la cui uscita il quorum dice assente (la scommessa è sparita dalla catena, ad esempio per una riorganizzazione) passa a reorged_out (modello-dati.md (B-82)) ed è escluso dal montepremi; pool_amount_sats viene riscritto con la somma delle uscite verificate e la ripartizione ([RND-21]) si calcola su quella.
    • Se è assente la scommessa del vincitore, il pagamento non si costruisce: si scrive payout_blocked, si annota il blocco sul round, la lotteria va in pausa automatica ([RND-04]) e il round resta in paying_out senza altri tentativi finché l'operatore non toglie la pausa (POST /admin/resume); al tentativo successivo la verifica si ripete. Lo schema è in modello-dati.md (B-82), la procedura in operativita.md (B-82). La riga del vincitore non passa a reorged_out in questo caso: resta confirmed, così che la verifica dopo la ripresa la riesamini davvero; i partecipanti diversi dal vincitore la cui scommessa è assente sono invece esclusi nella stessa transazione.
    • Se il quorum non risponde o non concorda, non si conclude nulla: nessun partecipante cambia stato, il tentativo termina con payout_failed e si riprova secondo [RND-25].

    Codice: app/rounds/scheduler.py:RoundScheduler._trigger_payout, app/rounds/scheduler.py:RoundScheduler._verify_round_pool, app/electrum/listener.py:ElectrumListener.verify_pool_outpoints · Test: tests/unit/test_pool_verification.py

  • [RND-40] Configurazione pagabile: rounds_can_open rifiuta una configurazione in cui un round non si potrebbe pagare, verificandola per n = 1 e per n = MAX_PARTICIPANTS_PER_ROUND partecipanti (i due estremi: con uno solo la commissione è minima, con il massimo è massima la riserva, che cresce con gli ingressi). Con netto = bet_amount_sats − estimate_vsize(MAX_TX_INPUTS, 2) × fee_rate_sat_vb (la scommessa più piccola che può arrivare al montepremi) e montepremi = n × netto, per entrambi i valori di n devono valere:

    • winner_share(montepremi) − estimate_vsize(n, 3) × fee_rate_sat_vb ≥ DUST_LIMIT_SATS;
    • montepremi − winner_share(montepremi) − riserva(n) ≥ DUST_LIMIT_SATS, con riserva(n) quella di [RND-38] calcolata su n ingressi.

    La stessa verifica si applica a PUT /admin/config: una modifica di bet_amount_sats o fee_rate_sat_vb che la farebbe fallire è rifiutata, così un aumento della commissione di rete a round in corso non può rendere impagabile il round (il pagamento legge fee_rate_sat_vb al momento della costruzione). Codice: app/rounds/service.py:payout_config_problem, app/rounds/service.py:rounds_can_open, app/api/routes/admin.py:update_config · Test: tests/unit/test_rounds_service.py::test_payout_config_problem_at_both_extremes, tests/unit/test_rounds_service.py::test_no_round_opens_while_the_config_could_not_pay_one, tests/unit/test_admin.py::test_config_refuses_a_change_that_would_make_a_round_unpayable, tests/unit/test_admin.py::test_a_fee_rate_raised_mid_round_cannot_make_the_round_unpayable

Tempi reali

Tra lo scadere del timer e l'arrivo della vincita ci sono tre attese di conferma distinte:

  1. l'ultima scommessa in volo (può essere già confermata prima della scadenza);
  2. il blocco dell'estrazione a draw_height, il primo dopo il punto 1, più i blocchi successivi fino a draw_confirmations conferme (3 predefinite, quindi 3 blocchi);
  3. il pagamento, costruito solo dopo il punto 2, che richiede un altro blocco.

I blocchi PLM arrivano in media ogni 120 secondi, ma l'intervallo fra due blocchi non ha un limite superiore: non esiste un "caso peggiore". A valori medi, dalla conferma dell'ultima scommessa servono 3 blocchi per l'estrazione e 1 per il pagamento, cioè 4 blocchi (8 minuti in media), indipendentemente da draw_animation_seconds. I depositi richiedono a parte più conferme prima di poter essere giocati (registrazione-e-depositi.md (B-82)).

Cosa vede il giocatore

Due livelli indipendenti:

  • [RND-29] A tutti i visitatori, per l'intera durata di closing, drawing e paying_out, un riquadro con la fase in corso (ad esempio "Pagamento al vincitore in corso…"). Codice: app/static/app.js:refreshRound
  • [RND-30] In più, solo a chi ha giocato quel round (user_played), il riquadro personale "Hai vinto!" / "Non hai vinto". Il risultato compare quando il vincitore è noto (quindi da paying_out), non prima di draw_animation_seconds (20s predefiniti) dall'orario di chiusura del server closes_at — così ricaricare la pagina non fa ripartire il conto alla rovescia — e, per il vincitore, solo quando è noto l'importo vinto. Codice: app/static/app.js:refreshRound
  • [RND-31] Una volta rivelato, il risultato resta salvato nel browser (localStorage.plm_persisted_result) anche dopo che il round è closed (quando GET /rounds/current non lo restituisce più), fino all'apertura di un round nuovo. Per un dispositivo che si è perso del tutto la finestra, GET /users/me/last-round-result restituisce dal database l'ultimo round closed a cui l'utente ha partecipato, se ha vinto e l'importo vinto. Codice: app/static/app.js:checkLastRoundResult, app/api/routes/users.py:last_round_result

Limite noto

  • [RND-32] Assorbito da [RND-36]: nel comportamento atteso un round in drawing riprende da solo dopo un riavvio o un errore (B-81).