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

490 lines
31 KiB
Markdown

# 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](piano-correzioni.md#le-decisioni)).
I requisiti usano il prefisso `TX`, secondo la convenzione di
[processo-di-modifica.md](processo-di-modifica.md). Le colonne delle tabelle
`pending_transactions` e `pending_transaction_txids` sono descritte in
[modello-dati.md](modello-dati.md), gli eventi di audit in
[audit-log.md](audit-log.md), le attività in background e le loro cadenze in
[architettura.md](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):
```mermaid
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](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](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](scommesse-e-prelievi.md#cosa-succede), [WDR-12](scommesse-e-prelievi.md#prelievo)) |
| 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](connessione-electrum.md#5-il-quorum)), 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](scommesse-e-prelievi.md#cosa-succede),
[WDR-16](scommesse-e-prelievi.md#prelievo)) 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](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](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](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](ciclo-di-vita-del-round.md#4--pagamento)
(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](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](scommesse-e-prelievi.md#cosa-succede),
[WDR-09](scommesse-e-prelievi.md#prelievo)), 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](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](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](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](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](ciclo-di-vita-del-round.md#2--chiusura)) 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](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](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](ciclo-di-vita-del-round.md#4--pagamento):
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](ciclo-di-vita-del-round.md#4--pagamento)); 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](ciclo-di-vita-del-round.md#4--pagamento)). 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](guida-admin.md) e
[operativita.md](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.