Files
plm-lottery/docs/ciclo-di-vita-del-round.md
T
davideandClaude Sonnet 5.5 fd6a91e93d Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
All bets of all rounds paid one address (m/84'/746'/0'/1/0), so its history
grew forever, other rounds' money and stray coins mixed with a round's own, and
the payout picked from everything it held.

- hd.py: derive_pool_key/derive_pool_address(round_id) on branch 2
  (m/84'/746'/0'/2/<round_id>); no argument is the reserve address 1/0.
  derive_round_pool_key checks the key against the address stored on the round,
  so a payout can never be signed for money its key does not control.
- rounds.pool_address (migration 477bddae1a84, existing rounds backfilled with
  the reserve address since that is where their bets went; add_column, not a
  batch alter, so the expression indexes stay): written in the transaction that
  inserts the round, once its id exists.
- place_bet pays round.pool_address; the payout reads, signs and takes change at
  the round's own address; own_address_for and bump_fee's _signing_context follow
  the round (confirmation poller and reconciler pass round_id).
- Withdrawals to any round pool address, the reserve address or fee_address are
  refused (withdrawal_to_platform_address, 400, all seven languages).
- GET /rounds/current and GET /admin/rounds expose pool_address.
- RND-22/DAT-11 stay "da implementare" for B-82: the payout still spends every
  confirmed coin at the round's address, not only the participants' outpoints
  (participants carry no bet_vout yet).
- tests/unit/conftest.py: every test now gets a throwaway master key. Opening a
  round derives an address, and tests that merely opened a round were silently
  reading the real key from the local .env.
- Existing tests changed because the spec changed: payout fixtures give their
  round a pool_address; the reconciler's address-cache test uses two payouts of
  one round (rows of different rounds no longer share an address);
  own_address_for now takes the round id.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:52:32 +02:00

41 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 Stato: da implementare (B-81) · Oggi: un errore durante l'attesa del blocco lascia il round in drawing per sempre, perché _tick non fa nulla sui round in quello stato.

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 Stato: da implementare (B-85, B-79) · Oggi: l'unica pausa è quella manuale (paused) e rounds_can_open controlla solo che fee_address non sia vuoto.

  • [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 (solo durata e pausa) Stato: da implementare (B-95, B-73) · Oggi: sono fotografate solo durata e pausa; costo della scommessa e fee_address sono letti dalla configurazione a ogni uso, draw_confirmations e max_round_pool_sats non esistono.

  • [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 Stato: da implementare (B-95) · Oggi: il pagamento legge fee_address dalla configurazione a ogni tentativo: una modifica durante il round sposta la commissione del round in corso, e un fee_address vuoto (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/api/routes/rounds.py:current_round · Test: — Stato: da implementare (B-73) · Oggi: il round non ha alcun segreto; il seme dipende solo dall'hash del blocco.

  • [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) Stato: da implementare (B-78) · Oggi: l'attesa è già senza limite, ma il riconciliatore abbandona una scommessa anche solo perché la cronologia del server attivo non contiene il suo txid, senza altra prova.

  • [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 · Test: — (nessun test porta _close_and_draw con partecipanti confermati fino a drawing) Stato: da implementare (B-73, B-81) · Oggi: l'elenco è ordinato per broadcast_at (che il pubblico non vede e che non ha spareggio) e resta solo in memoria; non esistono draw_position né participants_hash.

  • [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: — Stato: da implementare (B-73) · Oggi: si usa la punta vista al primo controllo utile, che può essere un blocco successivo; dopo una corroborazione fallita il riferimento avanza all'altezza rifiutata.

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_next_block · Test: tests/unit/test_scheduler.py (solo il comportamento attuale) Stato: da implementare (B-73, B-81) · Oggi: si attende che la punta superi l'altezza di riferimento e si usa la punta stessa, con una sola conferma; per tutta l'attesa lo scheduler resta bloccato dentro _close_and_draw e non esegue altri passi.
  • [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_next_block, app/electrum/listener.py:ElectrumListener.corroborate_header · Test: tests/unit/test_scheduler.py (solo il comportamento attuale) Stato: da implementare (B-73) · Oggi: si usa l'header della punta ricevuto dal server attivo, chiedendo agli altri solo di confermarlo; se la conferma manca, si attende un blocco ulteriore e si usa quello, così il server attivo può scegliere quale blocco reale seminare tacendone uno.
  • [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_winner, app/rounds/scheduler.py:RoundScheduler._close_and_draw · Test: tests/unit/test_draw.py (solo draw_winner; nessun test lato scheduler) Stato: da implementare (B-73) · Oggi: seme = hash del blocco letto come numero esadecimale, applicato all'elenco ordinato per broadcast_at.
  • [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._close_and_draw · Test: — (nessun test porta _close_and_draw con partecipanti confermati fino a paying_out) Stato: da implementare (B-73) · Oggi: il calcolo non è ripetibile da terzi: l'ordine dei partecipanti è per broadcast_at, noto solo al server e senza spareggio fra valori uguali.
  • [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, app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip · Test: tests/unit/test_scheduler.py (solo il comportamento attuale) Stato: da implementare (B-73) · Oggi: il round entra comunque in drawing, attende il primo header, ne adotta l'altezza come riferimento, scrive draw_baseline_tip_unknown e aspetta un blocco successivo.
  • [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_next_block, app/api/routes/rounds.py:current_round · Test: tests/unit/test_scheduler.py, tests/unit/test_rounds_route.py Stato: da implementare (B-73) · Oggi: i 360 secondi si contano dall'inizio dell'attesa; con 3 conferme richieste l'evento scatterebbe in quasi ogni round.
  • [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._close_and_draw · Test: — Stato: da implementare (B-81) · Oggi: elenco dei partecipanti e altezza di riferimento esistono solo in memoria e _tick salta i round in drawing: dopo un errore o un riavvio il round resta in drawing per sempre.
  • [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 · Test: — Stato: da implementare (B-73) · Oggi: non esiste alcun endpoint di prova; GET /rounds/current non espone partecipanti né dati dell'estrazione, e i round chiusi non sono consultabili pubblicamente.

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 Stato: da implementare (B-79, B-82) · Oggi: la ripartizione si calcola su pool_amount_sats fissato all'estrazione e l'indirizzo delle commissioni riceve l'intera quota del 30%.

  • [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 Stato: da implementare (B-82) · Oggi: spende le monete confermate dell'indirizzo del round (per un round vecchio, di quello condiviso) lette dal solo server attivo, scelte dalla più grande fino a coprire il montepremi, e non solo le uscite delle scommesse dei partecipanti: un'altra moneta arrivata a quell'indirizzo verrebbe spesa.

  • [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 (nessun test copre commission_below_dust_limit) Stato: da implementare (B-79, B-95) · Oggi: la terza uscita è un resto verso l'indirizzo del montepremi, presente solo se è di almeno 294 sat e assente quando le monete spese sono esattamente le scommesse del round; non c'è riserva, la soglia di 294 sat si applica all'intera quota delle commissioni e fee_address è letto dalla configurazione.

  • [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 Stato: da implementare (B-79) · Oggi: tutto come descritto, tranne bump_reserve_sats, che non esiste.

  • [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 — da implementare (B-82) — 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 Stato: da implementare (B-79) · Oggi: l'RBF può ridurre solo il resto verso il montepremi, che non esiste quando le monete spese sono esattamente le scommesse del round: un pagamento bloccato nel mempool non si può accelerare, e poiché il server lo conosce non viene nemmeno abbandonato, quindi il round resta in paying_out e nessun nuovo round si apre.

  • [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/hd.py:derive_pool_key · Test: — Stato: da implementare (B-79) · Oggi: non esiste alcuna riserva.

  • [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).
    • 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 · Test: — Stato: da implementare (B-82) · Oggi: le monete sono lette dal solo server attivo e nessun partecipante viene mai escluso: se una scommessa sparisce dalla catena il pagamento fallisce con insufficient pool UTXOs a ogni tentativo, con i soldi di tutti gli altri bloccati.

  • [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:rounds_can_open, app/api/routes/admin.py · Test: — Stato: da implementare (B-79) · Oggi: rounds_can_open controlla solo che fee_address non sia vuoto, e PUT /admin/config non fa verifiche di pagabilità.

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. Finché B-81 non è implementato il limite resta: lo scheduler riprende i round in closing e paying_out, ma su un round in drawing non fa nulla.