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>
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
PendingTransactiondi tipobet,withdrawalopayout, con statobuilding,pending,confirmedofailed. La riga (statobuilding, 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 rigaPendingTransaction([TX-01]), ogni sostituzione RBF prima di essere inviata ([TX-12]). Conferma ([TX-06]), riconciliazione ([TX-15]) e sorveglianza delle righefailed([TX-23]) cercano ogni txid della riga, non solocurrent_txid. Quando la versione trovata in catena non è quella corrente,current_txideraw_tx_hexdiventano 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 inreplaced_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 conBEGIN 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 condatabase is lockede viene annullata: prima dell'invio non si è mosso nulla; dopo un invio riuscito le righe restanobuildinge 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
errorJSON-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 statofailed. L'utente ricevebroadcast_failedcon 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.getper 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.pyStato: 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
PendingTransactionrestanobuilding, e sarà il riconciliatore a stabilire se la transazione è partita ([TX-16], [TX-17]). L'utente riceve HTTP 202 con l'oggetto nello statobuilding(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 righebuilding. -
[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
buildinge si scrivepayout_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 apendinge si scrivepayout_sent. Codice:app/rounds/scheduler.py:RoundScheduler._trigger_payout· Test:tests/unit/test_scheduler.pyStato: da implementare (B-76) · Oggi: anche il "già nota" lascia la rigabuildingconpayout_failed, finché il riconciliatore non la promuove.
Conferma
-
[TX-06]
ConfirmationPollercontrolla ogni 10 secondi le transazioni in statopending(non quellebuilding), 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.pyStato: da implementare (B-77, B-80) · Oggi: cerca solocurrent_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 restapendinge 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
confirmede, 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 buildingobroadcast) diventaconfirmed, conconfirmed_at, ed entra nell'estrazionePrelievo Il prelievo (se buildingobroadcast) diventaconfirmed, conconfirmed_atPagamento Il round (se paying_out) passa aclosed,closed_atdiventa l'orario della conferma e parte la pausa prima del successivoCodice:
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]
RbfBumpercontrolla ogni 30 secondi le righe in statopending(salta il giro senza connessione) e accelera quelle il cuilast_broadcast_atè più vecchio dirbf_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 sottoDUST_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 restapendingfinché 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.pyStato: 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 esattamentepool_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 elast_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.pyStato: 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 undatabase is lockedin 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 ocurrent_txidcambiati 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.pyStato: 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 eventorbf_bump_failed(audit-log.md (B-87)) con la riga, il txid corrente e il motivo; la riga restapendinge 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]
PendingTransactionReconcilergira all'avvio e poi ogni 120 secondi (salta il giro senza connessione), e considera ogni transazione ancorabuildingopendingtrascorso il suo periodo di tolleranza, misurato dabroadcast_at(il primo invio, che l'RBF non sposta): 120s perbuilding, 6 ore perpending, 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.pyStato: da implementare (B-78) · Oggi: si interroga solo il server attivo e solo percurrent_txid, e un txid assente dalla sua cronologia è trattato come "sconosciuto alla rete": dopo un riavvio su un server diverso una rigabuildingviene 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_txidnon 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
buildingviene promossa apending(e partecipante o prelievo dabuildingabroadcast), con un eventopending_tx_recoverede l'evento che l'invio riuscito avrebbe scritto:bet_placedper una scommessa,withdrawal_sentper un prelievo,payout_sentper un pagamento. Se il txid trovato non è quello corrente vale [TX-19]. Da lì la conferma segue la via normale. Una riga giàpendingpresente in rete non viene toccata. Codice:app/tx/reconcile.py:_promote· Test:tests/unit/test_reconcile.pyStato: da implementare (B-96) · Oggi: si scrive solopending_tx_recovered: una scommessa o un pagamento recuperati non hanno maibet_placedopayout_sent(e un prelievo recuperato non hawithdrawal_sent). -
[TX-17] Abbandono solo con prova. Una riga viene abbandonata solo se vale una di queste due prove:
- 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));
- 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
pendingresta com'è e la accelera l'RBF ([TX-09]); una rigabuildingpassa apendingcome 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 afailedcon il motivo infailure_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 segnatofailedsenza txid; il saldo dell'utente viene ricalcolato; si scrivepending_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.pyStato: da implementare (B-78) · Oggi: basta che la cronologia del server attivo non contengacurrent_txid(motivonot found on chain), senza reinvio e senza guardare gli ingressi. -
[TX-23] Le righe
failedrestano sorvegliate per 14 giorni dal passaggio afailed(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 voltapending_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 rigafailednon 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 inclosing. Codice:app/rounds/scheduler.py:RoundScheduler._tick,app/tx/reconcile.py:reconcile_once· Test:tests/unit/test_scheduler.py -
[TX-29] L'attesa in
closingsenza tempo massimo ([TX-25]) è visibile: quando un round resta inclosingoltre la scadenza per più diCLOSING_STALL_THRESHOLD_SECONDS(1800 s), lo scheduler scrive un eventoclosing_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/currentespone 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 inclosing.
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_satsefee_amount_satsdel round, che resta inpaying_outsenza 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.pyverifica i nuovi tentativi di un round inpaying_out, non la sequenza abbandono → ricostruzione) Stato: da implementare (B-96) · Oggi: azzera solopayout_txid;winner_amount_satsefee_amount_satsrestano quelli del pagamento abbandonato.
Dove guardare
Nel pannello /admin (vedi guida-admin.md e
operativita.md):
- Transazioni pendenti — tutto ciò che è
buildingopending, con orari di invio e candidati all'RBF; - Audit log —
payout_sent,payout_failed,pending_tx_recovered,pending_tx_abandoned,draw_stallede tutti gli altri eventi; con le correzioni in corso ancherbf_bump_failed([TX-24]) epending_tx_resurrected([TX-23]); - Round — stato di ogni round, vincitore e txid del pagamento.