Files
plm-lottery/docs/ciclo-di-vita-del-round.md
T
davideandClaude Sonnet 5.5 54dbffb552 Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
A user could bet and then double-spend the 1-conference deposit that funded the bet with a
private chain: the bet left the chain but its participant stayed `confirmed`, and the payout
failed "insufficient pool UTXOs" for ever with everyone else's money stuck. Coinbase outputs
were also credited at 1 confirmation (maturity is 120).

Deposits
- `round_config.deposit_confirmations` (default 6, 1-100, editable from /admin) is the
  threshold for a deposit; an output of a platform transaction (any txid of a
  pending_transactions row, RBF versions included) keeps 1; a coinbase output needs 120,
  recognised from the raw transaction, which must hash to the txid (`is_coinbase`, cached).
  `read_deposit_policy` / `classify_new_deposits` run between the read and the write, so no
  session is open across the lookup.
- The quorum must also place the coin deep enough on our own tip (ELC-17).
- Confirmed outputs short of their threshold are pending deposits (memory only, replaced at
  every refresh): shown in `pending_balance_sats` / `has_pending` (also for DEP-13's withdrawal
  message), never spendable. No scripthash notification announces a maturing deposit, so each
  new block refreshes only the users that have one, one task per user (DEP-09 now says so).

Payout
- `_trigger_payout` verifies the round's confirmed participants' bet outputs with the other
  servers (`verify_pool_outpoints`: strict majority of >= 2 answers per outpoint, a
  mempool-only coin or a tie gives no verdict) and builds from exactly those outpoints
  (RND-22), never from whatever else sits at the address. No quorum: `payout_failed`, `pool
  verification unavailable`, nothing changes. Bets that are gone become `reorged_out`, one
  `participant_reorged_out` row each, `pool_amount_sats` rewritten (only downward) in the same
  transaction, and the 70/30 split is computed on what is left.
- Winner's own bet gone: nothing is built; `rounds.payout_blocked_at` (Alembic f2c64a8d1e93,
  with `deposit_confirmations`, plain add_column) is set, `payout_blocked` and the pause with
  `paused_reason = 'payout_blocked'` are written in one transaction; `POST /admin/resume` clears
  the mark and the next attempt verifies again. /admin/rounds returns it.
- Rounds opened before B-80 (pool = the shared reserve address) keep the old active-server
  `listunspent` choice, since that address holds no single round's bets.

Ambiguities resolved: the winner's row stays `confirmed` while held (AUD-45 says every
participant whose bet is gone becomes `reorged_out`, but OPS-30/DAT-68 need the verification to
repeat after the resume, which only a `confirmed` row allows); `/`'s deposit hint no longer says
"1 confirmation" (7 languages, same keys); guides updated.

Tests changed because the spec changed: listener tests that credited height-100 coins at tip 0
now set a tip past every threshold; the payout fixtures seed confirmed participants (the new
source of the inputs) instead of an active-server `listunspent`, FakeNetwork answers the
verification, and test_round_pool_address asserts the quorum is asked about the round's
address. New: thresholds at exactly 6/5, configured value, coinbase at 119/120 and a mismatching
or missing raw tx, platform outputs at 1, dust, quorum height, waiting-deposit display and
disappearance, block-triggered refresh, resume, verification verdicts, gone bets excluded with
the split recomputed, winner gone held and paused, held payouts untouched until the resume,
re-verification after it, no quorum, a payout with 1 and with 400 participants against
reserve+fee+dust, admin config bounds and migration. Mutation-checked: threshold 1, ignored
coinbase, no height bound, no verification and no winner hold each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 14:25:41 +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 (solo durata e pausa) Stato: da implementare (B-95) · Oggi: sono fotografate durata, pausa e draw_confirmations; costo della scommessa e fee_address sono letti dalla configurazione a ogni uso, max_round_pool_sats non esiste.

  • [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/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 Stato: da implementare (B-95) · Oggi: fee_address è letto dalla configurazione corrente al momento del pagamento, non fotografato all'apertura.

  • [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).