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>
490 lines
31 KiB
Markdown
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.
|