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>
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(solo durata e pausa) Stato: da implementare (B-95) · Oggi: sono fotografate durata, pausa edraw_confirmations; costo della scommessa efee_addresssono letti dalla configurazione a ogni uso,max_round_pool_satsnon 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_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.pyStato: da implementare (B-95) · Oggi: il pagamento leggefee_addressdalla configurazione a ogni tentativo: una modifica durante il round sposta la commissione del round in corso, e unfee_addressvuoto (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_poolStato: 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 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).