Files
plm-lottery/docs/transazioni-e-riconciliazione.md
T
davideandClaude Sonnet 5.5 7ba8e22bb4 Real SQLite transactions: BEGIN IMMEDIATE and synchronous=FULL (B-88, DAT-74, DAT-52, ARC-07, TX-28, BET-11, DAT-16)
pysqlite's legacy mode emitted BEGIN only at the first DML, so every read
before it ran outside any transaction and a lock wait surfaced as "database is
locked" at an arbitrary later statement; place_bet also mistook that error for
a write-snapshot conflict, which SQLite cannot produce.

- _register_sqlite_pragmas: synchronous=FULL, isolation_level=None and a
  `begin` event that emits BEGIN / BEGIN IMMEDIATE.
- begin_immediate(session): takes the write lock before the first read; called
  first thing in place_bet, request_withdrawal, the scheduler transitions,
  _trigger_payout, the confirmation poller, the reconciler, bump_fee's persist
  phase, deposit crediting / external-spend marking / reinstating, and the
  derivation-index allocation at registration.
- A lock not granted before a broadcast answers service_busy (503, all seven
  languages), nothing built or sent; after a broadcast nothing is undone, the
  rows stay `building` for the reconciler.
- Spec, storico, BUGS.md and CLAUDE.md updated; 37 new tests on a real file DB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 16:26:01 +02:00

31 KiB

Transazioni e riconciliazione

Come ogni movimento di denaro — scommessa, prelievo, pagamento del round — viene registrato, inviato, accelerato e infine confrontato con la catena. L'obiettivo è che il sistema si rimetta in ordine da solo dopo un crash o un problema di rete, senza interventi manuali sul database, e che nel dubbio non liberi mai i fondi: una transazione si annulla solo con una prova che non è partita, mai per un timeout o per il silenzio di un solo server (decisione 2 di piano-correzioni.md).

I requisiti usano il prefisso TX, secondo la convenzione di processo-di-modifica.md. Le colonne delle tabelle pending_transactions e pending_transaction_txids sono descritte in modello-dati.md, gli eventi di audit in audit-log.md, le attività in background e le loro cadenze in architettura.md.

Il principio: prima si scrive, poi si invia

Nessuna transazione viene inviata alla rete senza che il database ne abbia già traccia. Ogni transazione in uscita ha una riga PendingTransaction che attraversa questi stati (comportamento atteso):

stateDiagram-v2
    [*] --> building: transazione firmata,<br/>monete segnate come spese,<br/>salvato PRIMA dell'invio
    building --> pending: il server accetta l'invio<br/>(anche "già nota")
    building --> building: invio incerto (timeout,<br/>connessione caduta): nulla liberato
    building --> [*]: scommessa rifiutata esplicitamente<br/>(riga cancellata, tutto annullato)
    building --> failed: prelievo rifiutato esplicitamente<br/>(tutto annullato)
    building --> pending: il riconciliatore trova in rete<br/>uno dei suoi txid (dopo 120s)
    building --> failed: il riconciliatore ha la prova<br/>che non è partita (dopo 120s)
    pending --> pending: nessuna conferma<br/>entro il tempo limite → RBF
    pending --> confirmed: prova merkle del quorum<br/>per uno dei suoi txid
    pending --> failed: il riconciliatore ha la prova<br/>che non è partita (dopo 6h dal primo invio)
    failed --> failed: conferma tardiva entro 14 giorni:<br/>pending_tx_resurrected + pausa
    confirmed --> [*]
    failed --> [*]: dopo 14 giorni
  • [TX-01] Ogni transazione in uscita ha una riga PendingTransaction di tipo bet, withdrawal o payout, con stato building, pending, confirmed o failed. La riga (stato building, con la transazione firmata completa) viene salvata e committata prima dell'invio, nella stessa transazione del database in cui le monete spese vengono segnate come tali e si registra l'oggetto collegato (partecipante, prelievo, dati di pagamento del round). Codice: app/bets/service.py:place_bet, app/withdrawals/service.py:request_withdrawal, app/rounds/scheduler.py:RoundScheduler._trigger_payout · Test: tests/unit/test_bets.py, tests/unit/test_scheduler.py
  • [TX-02] Il database non viene mai tenuto aperto durante una chiamata di rete: la scrittura di building è committata, poi si invia, poi si aggiorna (in una nuova sessione per il pagamento). Se il processo muore proprio in mezzo, resta la prova di cosa stava succedendo, non monete spese in catena senza alcun record. Codice: app/rounds/scheduler.py:RoundScheduler._trigger_payout, app/tx/broadcast.py:bump_fee · Test: tests/unit/test_scheduler.py, tests/unit/test_broadcast.py
  • [TX-03] broadcast_at è l'orario in cui la riga è stata scritta, cioè del primo invio, e non viene mai riscritto; last_broadcast_at è quello dell'ultimo invio, ed è l'unico che l'RBF aggiorna. Codice: app/db/models.py:PendingTransaction, app/tx/broadcast.py:bump_fee · Test: tests/unit/test_broadcast.py
  • [TX-19] Tutte le versioni sono registrate. Ogni txid che una riga ha avuto, con la sua transazione completa, è una riga di pending_transaction_txids (modello-dati.md (B-77)): la prima versione è scritta insieme alla riga PendingTransaction ([TX-01]), ogni sostituzione RBF prima di essere inviata ([TX-12]). Conferma ([TX-06]), riconciliazione ([TX-15]) e sorveglianza delle righe failed ([TX-23]) cercano ogni txid della riga, non solo current_txid. Quando la versione trovata in catena non è quella corrente, current_txid e raw_tx_hex diventano quelli della versione trovata e il txid viene propagato ovunque sia registrato, come dopo un RBF ([TX-12]). Codice: app/tx/confirmation.py:poll_once, app/tx/reconcile.py:reconcile_once, app/tx/broadcast.py:_retarget_txid_references · Test: — Stato: da implementare (B-77) · Oggi: si conserva solo il txid corrente (più il precedente in replaced_by_txid, che nessuno legge); se conferma una versione precedente a un RBF non viene riconosciuta.
  • [TX-28] Ogni transazione del database che sposta denaro — la scrittura di building ([TX-01]), la promozione dopo l'invio, l'annullamento dopo un rifiuto, le due scritture dell'RBF ([TX-12]), la promozione e l'abbandono del riconciliatore, la conferma ([TX-07]) — si apre con BEGIN IMMEDIATE, cioè prende il blocco in scrittura di SQLite prima delle proprie letture, così quelle letture non possono cambiare fino al commit. Se il blocco non si ottiene entro il tempo di attesa di SQLite (5 secondi, _SQLITE_BUSY_TIMEOUT_MS) l'operazione fallisce con database is locked e viene annullata: prima dell'invio non si è mosso nulla; dopo un invio riuscito le righe restano building e vale [TX-20]. La durabilità dei commit (synchronous=FULL) è descritta in architettura.md (B-88). Codice: app/db/base.py:begin_immediate, app/bets/service.py:place_bet, app/withdrawals/service.py:request_withdrawal, app/rounds/scheduler.py:RoundScheduler._trigger_payout, app/tx/broadcast.py:bump_fee, app/tx/reconcile.py:reconcile_once · Test: tests/unit/test_sqlite_transactions.py

Invio rifiutato

Un invio può finire in tre modi, e solo uno di essi permette di liberare le monete:

Esito Cosa significa Cosa si fa
Accettato Il server risponde con il txid, oppure rifiuta ma almeno un server conosce la transazione Si promuove la riga (BET-17, WDR-12)
Rifiutato esplicitamente Il server risponde con un errore JSON-RPC e nessun server interrogato conosce la transazione Si annulla tutto ([TX-04])
Incerto Timeout, connessione caduta, errore di scrittura sul socket, oppure un rifiuto la cui verifica non ottiene risposte: la transazione può essere partita Non si libera nulla ([TX-20])
  • [TX-04] Solo un rifiuto esplicito — una risposta del server con un oggetto error JSON-RPC — all'invio di una scommessa o di un prelievo annulla tutto: monete liberate, saldo ripristinato, evento scritto nel registro di audit (bet_broadcast_failed, withdrawal_broadcast_failed), notifica SSE. Per una scommessa partecipante e riga vengono cancellati; per un prelievo prelievo e riga restano, in stato failed. L'utente riceve broadcast_failed con codice HTTP 502, perché è stata la rete a rifiutare, non la sua richiesta a essere sbagliata.

    Prima di annullare, il rifiuto si verifica. Il testo dell'errore non basta a distinguere un rifiuto vero da un "già nota" (i server usano messaggi diversi e non esiste un codice standard), quindi dopo ogni rifiuto si chiede blockchain.transaction.get per il txid al server attivo e a tutti i server di riserva (10 s ciascuno):

    • se almeno uno restituisce la transazione, l'invio vale come accettato (la transazione è in rete);
    • se tutti quelli che rispondono dicono di non conoscerla, e rispondono almeno il server attivo e il numero minimo di server del quorum (connessione-electrum.md), si annulla;
    • se le risposte non bastano, l'esito è incerto ([TX-20]).

    Il riconoscimento dei messaggi "già nota" (already known, already in mempool, txn-already-known, txn-already-in-mempool, transaction already in block chain) resta solo una scorciatoia che salta la verifica: un messaggio non riconosciuto non può più far liberare le monete di una transazione partita, al massimo costa una verifica in più. Codice: app/bets/service.py:_release_failed_bet, app/withdrawals/service.py:_release_failed_withdrawal, app/electrum/client.py:ElectrumClient.broadcast, app/electrum/client.py:ElectrumClient.get_transaction · Test: tests/unit/test_bets.py, tests/unit/test_withdrawals.py Stato: da implementare (B-76) · Oggi: qualunque eccezione dell'invio, compresi il timeout di 15 secondi, la connessione caduta dopo la scrittura e il "già nota", annulla tutto e risponde 502, anche se la transazione può essere già in rete.

  • [TX-20] Invio incerto. Se l'invio di una scommessa o di un prelievo finisce senza una risposta del server (timeout, connessione caduta, errore di scrittura), o se il commit della promozione successiva a un invio accettato fallisce ([TX-28]), non si libera nulla: monete spese, partecipante o prelievo e riga PendingTransaction restano building, e sarà il riconciliatore a stabilire se la transazione è partita ([TX-16], [TX-17]). L'utente riceve HTTP 202 con l'oggetto nello stato building (BET-23, WDR-16) e si invia una notifica SSE. Codice: app/bets/service.py:place_bet, app/withdrawals/service.py:request_withdrawal · Test: — Stato: da implementare (B-76) · Oggi: l'invio incerto è trattato come un rifiuto ([TX-04]); un commit fallito dopo un invio accettato solleva un errore 500 lasciando le righe building.

  • [TX-21] Invio a tutti i server. Dopo che il server attivo ha accettato una transazione ([TX-04]) — scommessa, prelievo, pagamento o sostituzione RBF — la stessa transazione viene inviata anche a ogni altro server configurato. È un tentativo in più, non una condizione: un errore di questi invii viene solo registrato nei log, non cambia lo stato della riga e non ritarda la risposta all'utente. Codice: app/electrum/listener.py:ElectrumListener, app/bets/service.py:place_bet, app/withdrawals/service.py:request_withdrawal, app/rounds/scheduler.py:RoundScheduler._trigger_payout, app/tx/broadcast.py:bump_fee · Test: — Stato: da implementare (B-76) · Oggi: ogni transazione viene inviata solo al server attivo.

  • [TX-05] Se l'invio di un pagamento non è accettato — rifiuto esplicito o esito incerto — la riga resta building e si scrive payout_failed (broadcast rejected): è il riconciliatore a decidere, interrogando la rete, se la transazione è partita o no ([TX-16], [TX-17]). Un rifiuto "già nota" ([TX-04]) vale come invio accettato: la riga passa a pending e si scrive payout_sent. Codice: app/rounds/scheduler.py:RoundScheduler._trigger_payout · Test: tests/unit/test_scheduler.py Stato: da implementare (B-76) · Oggi: anche il "già nota" lascia la riga building con payout_failed, finché il riconciliatore non la promuove.

Conferma

  • [TX-06] ConfirmationPoller controlla ogni 10 secondi le transazioni in stato pending (non quelle building), e salta il giro se non c'è connessione. Per ciascuna legge la cronologia (blockchain.scripthash.get_history) dell'indirizzo che la spende — l'indirizzo dell'utente, o l'indirizzo del montepremi del round per un pagamento — e cerca ogni txid della riga ([TX-19]); un txid che vi compare con altezza maggiore di 0 è il candidato alla conferma, che viene accettata solo alle condizioni di [TX-22]. La cronologia è letta una volta per indirizzo a ogni giro; un indirizzo che non si riesce a leggere non blocca gli altri. Codice: app/tx/confirmation.py:poll_once, app/tx/pending_address.py:own_address_for · Test: tests/unit/test_confirmation.py Stato: da implementare (B-77, B-80) · Oggi: cerca solo current_txid, nella cronologia dell'unico indirizzo del montepremi condiviso da tutti i round per i pagamenti; un'altezza maggiore di 0 riportata dal solo server attivo basta a confermare ([TX-22]).

  • [TX-22] Conferma verificata. Un candidato ([TX-06]) è confermato solo quando il quorum dei server (connessione-electrum.md (B-84)) restituisce per quel txid una prova merkle valida (blockchain.transaction.get_merkle) contro l'header dell'altezza indicata. L'header è quello già validato dal listener (connessione-electrum.md (B-74)); se il listener non lo tiene più, è quello su cui il quorum concorda. Senza quorum, o con una prova non valida, la riga resta pending e si riprova al giro successivo. Codice: app/tx/confirmation.py:poll_once, app/electrum/listener.py:ElectrumListener._corroborate_majority · Test: — Stato: da implementare (B-83) · Oggi: basta la parola del server attivo: un server ostile può dichiarare confermato un pagamento mai trasmesso.

  • [TX-07] Alla conferma la riga passa a confirmed e, nella stessa transazione del database, viene chiamato il gestore del suo tipo; poi si invia una notifica SSE:

    Tipo Cosa succede alla conferma
    Scommessa Il partecipante (se building o broadcast) diventa confirmed, con confirmed_at, ed entra nell'estrazione
    Prelievo Il prelievo (se building o broadcast) diventa confirmed, con confirmed_at
    Pagamento Il round (se paying_out) passa a closed, closed_at diventa l'orario della conferma e parte la pausa prima del successivo

    Codice: app/tx/confirmation.py:poll_once, app/bets/confirmation.py:_on_bet_confirmed, app/withdrawals/confirmation.py:_on_withdrawal_confirmed, app/rounds/confirmation.py:_on_payout_confirmed · Test: tests/unit/test_confirmation.py

  • [TX-08] I gestori ritrovano l'oggetto a cui la transazione si riferisce tramite identificativi fissi (round e utente per la scommessa, prelievo, round per il pagamento), mai principalmente tramite il txid, perché il txid cambia a ogni RBF; il txid serve solo come ripiego per righe prive di quei collegamenti. Codice: app/bets/confirmation.py:_on_bet_confirmed, app/rounds/confirmation.py:_on_payout_confirmed · Test: tests/unit/test_confirmation.py

I gestori sono registrati all'avvio importando i moduli confirmation di scommesse, round e prelievi (vedi architettura.md).

RBF: accelerare una transazione lenta

Se una transazione resta senza conferma oltre rbf_timeout_seconds (900s predefiniti), RbfBumper la ricostruisce con una commissione più alta e la reinvia (Replace-By-Fee). Tutte le transazioni della piattaforma segnalano la sostituibilità fin dal primo invio.

  • [TX-09] RbfBumper controlla ogni 30 secondi le righe in stato pending (salta il giro senza connessione) e accelera quelle il cui last_broadcast_at è più vecchio di rbf_timeout_seconds: dopo ogni accelerazione la transazione ha di nuovo l'intero tempo limite. Un errore su una riga non ferma le altre. Codice: app/tx/broadcast.py:RbfBumper._tick, app/tx/broadcast.py:should_bump · Test: tests/unit/test_broadcast.py
  • [TX-10] La nuova commissione punta a 1 sat/vB in più della tariffa registrata (_FEE_RATE_INCREMENT), senza superare 10.000 sat/vB (MAX_FEE_RATE_SAT_VB); l'aumento assoluto non è mai inferiore alla dimensione stimata della transazione per 1 sat/vB, il minimo richiesto da BIP125 per una sostituzione. Una riga già a 10.000 sat/vB non viene più accelerata. Gli importi delle monete spese sono letti dalle transazioni precedenti come interi in satoshi. Codice: app/tx/broadcast.py:bump_fee, app/tx/broadcast.py:_prevout_amount · Test: tests/unit/test_broadcast.py
  • [TX-11] Chi paga l'accelerazione. La sostituzione spende le stesse monete e riduce del solo aumento un'unica uscita, quella che torna alla piattaforma o al mittente: il resto dell'utente per scommesse e prelievi, l'uscita di riserva verso l'indirizzo di riserva del montepremi (m/84'/746'/0'/1/0) per i pagamenti (ciclo-di-vita-del-round.md (B-79)). Le uscite verso la controparte (montepremi, indirizzo esterno, vincitore, indirizzo commissioni) non vengono mai toccate. Se dopo la riduzione quell'uscita scenderebbe sotto DUST_LIMIT_SATS (294 sat), l'uscita viene eliminata e tutto il suo valore va in commissione: la sostituzione non ha più nulla da ridurre ed è quindi l'ultima, e la riga non viene più accelerata. Se l'uscita non esiste, o se nemmeno la sua eliminazione raggiunge l'aumento minimo di [TX-10], l'accelerazione non è possibile ([TX-24]). Una transazione che non si può accelerare resta pending finché conferma o finché il riconciliatore ha la prova che non è partita ([TX-17]): finché almeno un server la tiene in mempool non viene mai abbandonata. Codice: app/tx/broadcast.py:bump_fee, app/tx/broadcast.py:_find_change_output · Test: tests/unit/test_broadcast.py Stato: da implementare (B-79, B-87) · Oggi: per il pagamento si cerca un resto verso l'indirizzo del montepremi, che esiste solo se le monete scelte superano vincitore più commissioni di almeno 294 sat: quando il montepremi contiene esattamente le scommesse del round il pagamento le spende tutte per esattamente pool_amount_sats, non ha resto e non è accelerabile; per scommesse e prelievi basta che il resto superi l'aumento, così un resto di 294 sat diventa 153 sat, sotto la soglia di dust, e la rete rifiuta la sostituzione a ogni giro.
  • [TX-12] Prima si scrive la sostituzione, poi si invia. La sostituzione firmata viene registrata come candidata in pending_transaction_txids (txid e transazione completa, [TX-19]) e committata prima dell'invio. Dopo l'invio (accettato o incerto) la riga viene aggiornata con un aggiornamento condizionato (UPDATE … WHERE id = … AND status = 'pending' AND current_txid = <txid sostituito>): nuovo txid (current_txid), nuova transazione (raw_tx_hex), txid sostituito (replaced_by_txid), tariffa effettivamente pagata, numero di tentativi e last_broadcast_at; nella stessa transazione il nuovo txid viene propagato ovunque sia registrato: monete spese (spent_txid), partecipante (bet_txid), prelievo (txid), round (payout_txid). Se la sostituzione è rifiutata esplicitamente la riga non cambia ([TX-24]). Codice: app/tx/broadcast.py:bump_fee, app/tx/broadcast.py:_retarget_txid_references · Test: tests/unit/test_broadcast.py Stato: da implementare (B-77) · Oggi: la sostituzione viene inviata senza essere registrata, e la riga viene aggiornata solo dopo l'invio, senza condizioni: un crash o un database is locked in mezzo lascia in rete una transazione che il database non conosce.
  • [TX-13] L'RBF non tocca una riga che è cambiata sotto di lui. Prima di costruire la sostituzione, se la riga non esiste più o non è più pending (ad esempio è appena stata confermata), non si fa nulla. Dopo l'invio, se l'aggiornamento condizionato di [TX-12] non trova la riga (stato o current_txid cambiati nel frattempo: confermata, abbandonata o già sostituita), è un conflitto: la riga non viene sovrascritta, la candidata resta registrata — la conferma e la riconciliazione la cercano comunque ([TX-19]) — e il conflitto viene scritto nei log. Codice: app/tx/broadcast.py:bump_fee · Test: tests/unit/test_broadcast.py Stato: da implementare (B-77) · Oggi: il controllo avviene solo prima della costruzione; dopo l'invio la riga viene aggiornata senza ricontrollare né lo stato né current_txid, anche se nel frattempo è stata confermata o abbandonata.
  • [TX-24] Un'accelerazione impossibile ([TX-11], o la riga già a MAX_FEE_RATE_SAT_VB) o una sostituzione rifiutata esplicitamente dalla rete scrivono un evento rbf_bump_failed (audit-log.md (B-87)) con la riga, il txid corrente e il motivo; la riga resta pending e il tentativo si ripete al giro successivo in cui è dovuto ([TX-09]). L'evento si scrive una volta per coppia (txid corrente, motivo): un tentativo che fallisce di nuovo per lo stesso motivo sullo stesso txid va solo nei log, così una transazione non accelerabile non riempie il registro con una riga ogni 30 secondi. Codice: app/tx/broadcast.py:RbfBumper._tick · Test: — Stato: da implementare (B-87) · Oggi: l'errore finisce solo nei log dell'applicazione, invisibile da /admin.

Il resto minimo di scommesse e prelievi è di 294 sat (BET-14, WDR-09), ma non basta a una riduzione: una scommessa con una moneta in ingresso e due uscite misura 141 vB, quindi l'aumento minimo a 1 sat/vB è di 141 sat e lascerebbe un resto di 153 sat, sotto la soglia di dust. Con un resto minimo la prima accelerazione elimina il resto ([TX-11]) ed è anche l'ultima; un resto più grande permette più accelerazioni prima di arrivare lì. Il pagamento del round, invece, non ha resto quando il montepremi contiene esattamente le scommesse del round: la riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.

Riconciliazione

  • [TX-14] PendingTransactionReconciler gira all'avvio e poi ogni 120 secondi (salta il giro senza connessione), e considera ogni transazione ancora building o pending trascorso il suo periodo di tolleranza, misurato da broadcast_at (il primo invio, che l'RBF non sposta): 120s per building, 6 ore per pending, così l'RBF ha il tempo di fare i suoi tentativi. Il periodo di tolleranza stabilisce solo da quando la riga viene esaminata: l'abbandono richiede comunque una prova ([TX-17]). Codice: app/tx/reconcile.py:PendingTransactionReconciler.run, app/tx/reconcile.py:_is_due · Test: tests/unit/test_reconcile.py

  • [TX-15] Il riconciliatore chiede a ogni server del quorum (connessione-electrum.md (B-84)) la cronologia (blockchain.scripthash.get_history) dell'indirizzo che spende la riga, e cerca ogni txid della riga ([TX-19]), in mempool o in un blocco. La transazione è presente se almeno un server conosce almeno uno dei suoi txid ([TX-16]); è assente solo se nessun server del quorum ne conosce alcuno, e anche in quel caso l'assenza da sola non basta ad abbandonarla ([TX-17]). Un errore di rete non porta a nulla: la riga resta com'è fino al giro successivo. Codice: app/tx/reconcile.py:reconcile_once, app/electrum/listener.py:ElectrumListener._corroborate_majority · Test: tests/unit/test_reconcile.py Stato: da implementare (B-78) · Oggi: si interroga solo il server attivo e solo per current_txid, e un txid assente dalla sua cronologia è trattato come "sconosciuto alla rete": dopo un riavvio su un server diverso una riga building viene abbandonata dopo 120s anche se la transazione è nella mempool degli altri nodi (che la tengono fino a 336 ore con le impostazioni predefinite di Bitcoin Core, contro le 6 ore di tolleranza).

  • [TX-26] Prima di scrivere un esito, il riconciliatore rilegge la riga nella propria transazione del database ([TX-28]) e non fa nulla se il suo stato o il suo current_txid non sono più quelli che aveva quando ha interrogato la rete (una conferma o un RBF avvenuti nel frattempo). Codice: app/tx/reconcile.py:reconcile_once · Test: — Stato: da implementare (B-78) · Oggi: ricontrolla solo lo stato, quindi può abbandonare una riga appena sostituita dall'RBF sulla base del txid precedente.

  • [TX-16] Presente in rete: una riga building viene promossa a pending (e partecipante o prelievo da building a broadcast), con un evento pending_tx_recovered e l'evento che l'invio riuscito avrebbe scritto: bet_placed per una scommessa, withdrawal_sent per un prelievo, payout_sent per un pagamento. Se il txid trovato non è quello corrente vale [TX-19]. Da lì la conferma segue la via normale. Una riga già pending presente in rete non viene toccata. Codice: app/tx/reconcile.py:_promote · Test: tests/unit/test_reconcile.py Stato: da implementare (B-96) · Oggi: si scrive solo pending_tx_recovered: una scommessa o un pagamento recuperati non hanno mai bet_placed o payout_sent (e un prelievo recuperato non ha withdrawal_sent).

  • [TX-17] Abbandono solo con prova. Una riga viene abbandonata solo se vale una di queste due prove:

    1. un suo ingresso risulta speso in un blocco da una transazione che non è nessuna delle sue versioni ([TX-19]), confermato dal quorum (connessione-electrum.md (B-84));
    2. nessun server del quorum conosce alcuno dei suoi txid ([TX-15]) e il reinvio della sua versione più recente (raw_tx_hex) a tutti i server viene rifiutato esplicitamente perché gli ingressi mancano o sono già spesi (missing-inputs, bad-txns-inputs-missingorspent).

    Un rifiuto del reinvio per commissione insufficiente non è una prova: una riga pending resta com'è e la accelera l'RBF ([TX-09]); una riga building passa a pending come in [TX-16], così che l'RBF la possa accelerare. Un reinvio accettato da almeno un server significa che la transazione è viva: vale [TX-16]. Con la prova, la riga passa a failed con il motivo in failure_reason; le monete che risultano ancora impegnate da questa transazione vengono liberate (non quelle già spese da altro); il partecipante viene rimosso, o il prelievo segnato failed senza txid; il saldo dell'utente viene ricalcolato; si scrive pending_tx_abandoned (con il numero di monete liberate) e si invia una notifica SSE. Codice: app/tx/reconcile.py:_abandon, app/tx/reconcile.py:_release_inputs, app/tx/reconcile.py:reconcile_once · Test: tests/unit/test_reconcile.py Stato: da implementare (B-78) · Oggi: basta che la cronologia del server attivo non contenga current_txid (motivo not found on chain), senza reinvio e senza guardare gli ingressi.

  • [TX-23] Le righe failed restano sorvegliate per 14 giorni dal passaggio a failed (failed_at, modello-dati.md (B-78)): a ogni giro il riconciliatore cerca i loro txid come in [TX-15]. Se uno di essi risulta confermato ([TX-22]) — una transazione data per morta che invece è arrivata in un blocco — si scrive una sola volta pending_tx_resurrected (audit-log.md (B-78)) e la lotteria viene messa in pausa (paused_reason = 'resurrected_tx') perché l'operatore rimetta in ordine partecipante, prelievo o pagamento; il riconciliatore non corregge da solo le righe collegate. Codice: app/tx/reconcile.py:reconcile_once · Test: — Stato: da implementare (B-78) · Oggi: una riga failed non viene più guardata: se la transazione conferma dopo l'abbandono, nessuno se ne accorge.

  • [TX-27] Il riconciliatore invia una notifica SSE solo per le righe di cui ha cambiato qualcosa (promozione, abbandono, riallineamento del txid), non per ogni riga esaminata. Codice: app/tx/reconcile.py:reconcile_once · Test: — Stato: da implementare (B-96) · Oggi: pubblica una notifica per ogni riga che ha interrogato, anche quando non cambia nulla.

  • [TX-25] Una scommessa in volo esce dall'attesa del round in closing (ciclo-di-vita-del-round.md) in due soli modi: confermata ([TX-07]) o abbandonata con prova ([TX-17]). Non esiste un tempo massimo: finché almeno un server tiene la transazione in mempool, il round resta in closing. Codice: app/rounds/scheduler.py:RoundScheduler._tick, app/tx/reconcile.py:reconcile_once · Test: tests/unit/test_scheduler.py

  • [TX-29] L'attesa in closing senza tempo massimo ([TX-25]) è visibile: quando un round resta in closing oltre la scadenza per più di CLOSING_STALL_THRESHOLD_SECONDS (1800 s), lo scheduler scrive un evento closing_stalled (audit-log.md (B-96)) con il round, il numero di scommesse ancora in volo e i loro txid, e lo ripete ogni 1800 s finché l'attesa dura; GET /rounds/current espone da quando il round attende (closing_waiting_since). L'operatore decide con la procedura di operativita.md (B-96); il sistema non esclude da solo una scommessa in volo dall'estrazione. Codice: app/rounds/scheduler.py:RoundScheduler._tick · Test: — Stato: da implementare (B-96) · Oggi: l'attesa non produce alcun segnale oltre al round che resta in closing.

Pagamenti del round: riprovare senza pagare due volte

Il pagamento ha bisogno di più protezioni degli altri tipi, perché è l'operazione che lo scheduler ritenta da solo. Le regole sono in ciclo-di-vita-del-round.md: ogni tentativo fallito scrive un evento payout_failed con il motivo, così /admin mostra perché un round è fermo, e i tentativi sono limitati a uno ogni 60 secondi usando proprio l'ultimo payout_failed come orologio (RND-25); non si costruisce un pagamento se ne esiste già uno building o pending per quel round, e le monete del montepremi già impegnate da un pagamento non risolto sono escluse dalla selezione (RND-26). Senza queste ultime due, un nuovo tentativo potrebbe pagare il vincitore due volte.

  • [TX-18] Un pagamento abbandonato ([TX-17]) non libera monete utente (quelle del montepremi non sono registrate): azzera payout_txid, winner_amount_sats e fee_amount_sats del round, che resta in paying_out senza transazione né importi di un pagamento che non esiste, e lo scheduler ricostruisce il pagamento al tentativo successivo. Codice: app/tx/reconcile.py:_abandon, app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due · Test: — (tests/unit/test_scheduler.py verifica i nuovi tentativi di un round in paying_out, non la sequenza abbandono → ricostruzione) Stato: da implementare (B-96) · Oggi: azzera solo payout_txid; winner_amount_sats e fee_amount_sats restano quelli del pagamento abbandonato.

Dove guardare

Nel pannello /admin (vedi guida-admin.md e operativita.md):

  • Transazioni pendenti — tutto ciò che è building o pending, con orari di invio e candidati all'RBF;
  • Audit log — payout_sent, payout_failed, pending_tx_recovered, pending_tx_abandoned, draw_stalled e tutti gli altri eventi; con le correzioni in corso anche rbf_bump_failed ([TX-24]) e pending_tx_resurrected ([TX-23]);
  • Round — stato di ogni round, vincitore e txid del pagamento.