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>
39 KiB
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,drawingcompreso ([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 conSTART_PAUSED=true(operativita.md (B-90)). Il motivo è salvato inpaused_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 - nessun round è attivo (il precedente è
-
[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/adminvale 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_addressdurante il round non ne cambia la destinazione della commissione. L'API di amministrazione non permette di svuotarefee_address(un valore vuoto non è un indirizzo PLM valido).GET /rounds/currentespone 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_payoutUnfee_addressvuoto sulla riga del round (solo modificando il database a mano) blocca il pagamento conno 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 improntaserver_seed_hash = sha256(server_seed), esposta daGET /rounds/currente dalla prova pubblica ([RND-37]) fin dall'apertura.server_seednon è esposto da nessun endpoint,/admincompreso, 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 inrounds.pool_addressed esposto daGET /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 condivisom/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_atpiù 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/currentpubblica il montepremi in due forme:jackpot_satseparticipant_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_satsepending_participant_count: gli stessi valori includendo le scommesse ancora in volo (building,broadcast), come totali e non come differenze;has_pending_betsdice 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 aclosing, qualunque cosa stia accadendo: si annotaclosed_ate 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 controllaclosed_atné 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
buildingobroadcastil round restaclosinge 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_tickcon 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
closedsenza vincitore, si scriveround_closed(conparticipants: 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 righetxid:vout:indirizzo, una per partecipante nell'ordine didraw_position, dovevoutè l'uscita della scommessa verso l'indirizzo del montepremi del round (in decimale) eindirizzoè 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_hashedraw_heightsono 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 - 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
-
[RND-33] L'altezza del blocco di estrazione è fissata all'ingresso in
drawingcomedraw_height = tip_at_close + 1, dovetip_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_heightabbiadraw_confirmationsconferme (il valore fotografato all'apertura, [RND-05]), cioè che l'altezza della catena sia almenodraw_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 scrivedraw_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 tramitedraw_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 adraw_height(32 byte, nell'ordine mostrato dagli explorer) eparticipants_hash(32 byte), letto come intero senza segno big-endian; indice = seme modulo numero dei partecipanti; vincitore = il partecipante condraw_positionuguale 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 apaying_oute scrivewinner_drawn(indirizzo del vincitore, montepremi, altezza, hash, numero di partecipanti,participants_hash,draw_height,server_seedeseedin esadecimale, audit-log.md AUD-24).server_seedentra 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 restaclosinge il passaggio viene ritentato al passo successivo, scrivendodraw_baseline_tip_unknownuna volta per round:draw_heightsi 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 eventodraw_stalled, ripetuto ogni 360 secondi finché la situazione dura. Durantedrawing,GET /rounds/currentesponedraw_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 condraw_position,participants_hash,draw_height,draw_confirmations) è nel database dall'ingresso indrawing. A ogni passo, per un round indrawing, 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 apaying_outnon 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_hashepool_address(dall'apertura); dall'ingresso indrawingl'elenco dei partecipanti (draw_position, txid,vout, indirizzo, importo netto),participants_hash,draw_heightedraw_confirmations; dal salvataggio del vincitoreserver_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:
- Impegno. Annotare
server_seed_hashmentre il round è aperto (è pubblico dall'apertura). Dopo la rivelazione, verificare chesha256(server_seed)sia uguale a quel valore: il segreto usato è quello fissato all'apertura. - Partecipanti. Per ogni riga dell'elenco verificare sulla catena che la
transazione
txidesista, sia confermata, abbia l'uscitavoutversopool_addresscon 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. - Ordine. Verificare che le righe siano in ordine crescente di txid e
che
draw_positionvada da 0 a n−1 senza salti. - Impronta dei partecipanti. Ricostruire le righe
txid:vout:indirizzo(ognuna terminata da\n), calcolarne lo sha256 e confrontarlo conparticipants_hash. Chi ha osservato la prova durantedrawingsa anche che impronta edraw_heighterano pubblici prima che il blocco esistesse. - Blocco. Verificare che
draw_block_hashsia l'hash del blocco all'altezzadraw_heightdella catena principale. - Calcolo. Calcolare sha256 della concatenazione dei 32 byte di
server_seed, dei 32 byte dell'hash del blocco e dei 32 byte diparticipants_hash; leggerlo come intero big-endian; l'indice è quel numero modulo n. Il vincitore dichiarato deve essere la riga con quelladraw_position. - 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 motivotoo_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, afee_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 tipopayoutin statobuilding. Se l'invio riesce, la transazione passa apendinge si scrivepayout_sent. Se non riesce, si scrivepayout_failede la riga restabuilding: 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_failedcon 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 failedper ogni altro errore di costruzione,broadcast rejected, epool verification unavailablequando la verifica del montepremi prima del pagamento ([RND-39]) non ottiene abbastanza risposte dal quorum. Fanno eccezione due casi, che non scrivonopayout_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 scrivepayout_blocked. A ogni passo lo scheduler riesamina un round inpaying_oute riprova al massimo una volta ogni 60 secondi (_PAYOUT_RETRY_INTERVAL_SECONDS), misurati dall'ultimopayout_faileddi quel round; senza alcunpayout_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 motivielectrum client not connectedeno fee_address configured; per l'invio fallito solo la presenza dipayout_failed) -
[RND-26] Due protezioni impediscono di pagare due volte: non si costruisce un nuovo pagamento se per il round ne esiste già uno
buildingopending(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
closedeclosed_atviene 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/0di valoremax(DUST_LIMIT_SATS, estimate_vsize(ingressi, 3) × 10 sat/vB), dove 10 sat/vB è la costantePAYOUT_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_satsviene 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 inpaying_outsenza 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 areorged_outin questo caso: restaconfirmed, 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_failede 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 - Un partecipante la cui uscita il quorum dice assente (la scommessa è
sparita dalla catena, ad esempio per una riorganizzazione) passa a
-
[RND-40] Configurazione pagabile:
rounds_can_openrifiuta una configurazione in cui un round non si potrebbe pagare, verificandola per n = 1 e per n =MAX_PARTICIPANTS_PER_ROUNDpartecipanti (i due estremi: con uno solo la commissione è minima, con il massimo è massima la riserva, che cresce con gli ingressi). Connetto=bet_amount_sats − estimate_vsize(MAX_TX_INPUTS, 2) × fee_rate_sat_vb(la scommessa più piccola che può arrivare al montepremi) emontepremi = 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, conriserva(n)quella di [RND-38] calcolata su n ingressi.
La stessa verifica si applica a
PUT /admin/config: una modifica dibet_amount_satsofee_rate_sat_vbche la farebbe fallire è rifiutata, così un aumento della commissione di rete a round in corso non può rendere impagabile il round (il pagamento leggefee_rate_sat_vbal 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:
- l'ultima scommessa in volo (può essere già confermata prima della scadenza);
- il blocco dell'estrazione a
draw_height, il primo dopo il punto 1, più i blocchi successivi fino adraw_confirmationsconferme (3 predefinite, quindi 3 blocchi); - 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,drawingepaying_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 dapaying_out), non prima didraw_animation_seconds(20s predefiniti) dall'orario di chiusura del servercloses_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(quandoGET /rounds/currentnon 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-resultrestituisce dal database l'ultimo roundcloseda 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 indrawingriprende da solo dopo un riavvio o un errore (B-81).