Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)

place_bet and request_withdrawal released everything on any exception from the
broadcast, including the 15s timeout and a connection dropped after the write. The
tx may have propagated: a bet's sats landed in the pool with no participant, a
withdrawal left while its row said failed and its coins were spendable again.

- client: ElectrumRpcError (subclass of ElectrumError) is raised only for a reply
  carrying a JSON-RPC `error`; timeouts, drops and write errors stay plain
  ElectrumError.
- tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused /
  uncertain. A refusal is verified first: ElectrumListener.transaction_known asks
  blockchain.transaction.get of the active server and every other one (10s each) —
  any server returning the tx means accepted; the active server plus
  MIN_QUORUM_RESPONSES others all denying it means refused (ALLOW_NO_QUORUM keeps
  the reduced rule); too few answers is uncertain. "already known" wording is only a
  shortcut that skips the check. With no network to ask, a refusal is uncertain.
- place_bet / request_withdrawal / _trigger_payout use it: only a verified refusal
  releases; uncertain leaves the rows building for the reconciler, publishes SSE and
  POST /bets and POST /withdrawals answer 202 with status building; "already known"
  promotes a payout (payout_sent) like any accepted broadcast.
- An accepted transaction is fanned out to every other server
  (ElectrumListener.fan_out_transaction, best effort, off the caller's path). The RBF
  replacement in bump_fee is left for B-77, which rewrites that function.

Tests changed because the spec changed: the existing "refused broadcast" tests raised
a bare RuntimeError (now an uncertain outcome) and are made explicit refusals
(rpc_error) with a network that denies the tx; direct route calls pass a Response;
FakePayoutListener gains the network surface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 14:22:20 +02:00
1 parent 13cb66cc06
commit 2b6ddb019b
27 files changed
+965 -150

No files matched your search

@@ -1,6 +1,6 @@
# ADR-31 — Tutto ciò che spende denaro si scrive prima di inviarlo
- **Stato:** Accettata — implementazione da completare (B-76, B-77)
- **Stato:** Accettata — implementazione da completare (B-77)
- **Area:** Denaro in movimento
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -19,7 +19,6 @@ riconciliatore e risponde **202** ([ADR-51](ADR-51-dubbio-liberano-mai-fondi.md)
[transazioni-e-riconciliazione.md](../transazioni-e-riconciliazione.md).
*Codice:* `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_bets.py::test_bet_is_persisted_before_it_is_broadcast`, `tests/unit/test_scheduler.py::test_trigger_payout_persists_before_broadcasting`, `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-76, B-77) · *Oggi:* qualunque eccezione
dell'invio, timeout compreso, annulla tutto con 502; `bump_fee` invia la
sostituzione prima di salvarla.
*Test:* `tests/unit/test_bets.py::test_bet_is_persisted_before_it_is_broadcast`, `tests/unit/test_broadcast_outcome.py`, `tests/unit/test_scheduler.py::test_trigger_payout_persists_before_broadcasting`, `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-77), per le sostituzioni RBF · *Oggi:*
`bump_fee` invia la sostituzione prima di salvarla.
+3 -3
View File
@@ -1,6 +1,6 @@
# ADR-51 — Nel dubbio non si liberano mai i fondi
- **Stato:** Accettata — implementazione da completare (B-76, B-77, B-78)
- **Stato:** Accettata — implementazione da completare (B-77, B-78)
- **Area:** Denaro in movimento
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -36,8 +36,8 @@ pausa la lotteria ([ADR-28](ADR-28-pausa-manutenzione-ferma-round-successivo.md)
congelato e visibile, mai duplicato.
*Codice:* `app/tx/reconcile.py:reconcile_once` · `app/tx/reconcile.py:_is_due` · `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/confirmation.py:poll_once` · `app/tx/broadcast.py:bump_fee` ·
*Test:* —
*Stato:* da implementare (B-76, B-77, B-78) · *Oggi:* l'abbandono avviene
*Test:* `tests/unit/test_broadcast_outcome.py`
*Stato:* da implementare (B-77, B-78) · *Oggi:* l'abbandono avviene
quando la cronologia del solo server attivo non contiene il txid corrente,
dopo 120s per `building` e 6 ore per `pending`; si segue solo l'ultimo txid;
le righe `failed` non sono più controllate.
+3 -10
View File
@@ -220,10 +220,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
"già noto / già nel mempool" vale come successo (`201`).
*Codice:* `app/api/routes/bets.py:create_bet`,
`app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_bets.py`
*Stato:* da implementare (B-76) · *Oggi:* qualunque eccezione del broadcast,
compresi il timeout di 15 s e la caduta della connessione, annulla tutto e
risponde `502 broadcast_failed`.
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_broadcast_outcome.py`
- **[API-14]** Qualunque eccezione non gestita risponde `500` con lo stesso
formato strutturato, codice `internal_error`, messaggio
@@ -471,9 +468,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*Codice:* `app/api/routes/bets.py:create_bet`,
`app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`
*Stato:* da implementare (B-76, B-95, B-96) · *Oggi:* ogni esito diverso
dal successo è `502 broadcast_failed` con tutto annullato; la trasmissione
va solo al server attivo; un costo coperto solo da resto non confermato
*Stato:* da implementare (B-95, B-96) · *Oggi:* un costo coperto solo da resto non confermato
risponde `insufficient_balance`; non c'è tetto al montepremi; il costo è
quello configurato al momento della scommessa, non quello del round.
@@ -521,9 +516,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`,
`app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
*Stato:* da implementare (B-76, B-96) · *Oggi:* ogni esito diverso
dal successo è `502 broadcast_failed` con il prelievo `failed`; la
trasmissione va solo al server attivo; l'importo ridotto di un prelievo
*Stato:* da implementare (B-96) · *Oggi:* l'importo ridotto di un prelievo
dell'intero saldo non è riconfrontato con il minimo.
### Round
+2 -6
View File
@@ -352,9 +352,7 @@ flowchart LR
200 caratteri. `user_id` sì, `round_id` **no** (a differenza di
`bet_placed`). Stessa transazione del ripristino.
*Codice:* `app/bets/service.py:_release_failed_bet` ·
*Test:* `tests/unit/test_bets.py`
*Stato:* da implementare (B-76) · *Oggi:* scritto per qualunque errore
d'invio, compresi timeout e disconnessioni.
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_broadcast_outcome.py`
- **[AUD-21]** `withdrawal_sent` — la rete ha accettato la transazione di un
prelievo. Payload `{"txid", "amount_sent_sats", "external_address"}`:
@@ -373,9 +371,7 @@ flowchart LR
(`reason` troncato a 200 caratteri). `user_id` sì, `round_id` no. Stessa
transazione del ripristino.
*Codice:* `app/withdrawals/service.py:_release_failed_withdrawal` ·
*Test:* nessun test verifica la riga di audit.
*Stato:* da implementare (B-76) · *Oggi:* scritto per qualunque errore
d'invio, compresi timeout e disconnessioni.
*Test:* `tests/unit/test_broadcast_outcome.py`
### Round
+2 -1
View File
@@ -349,7 +349,8 @@ L'**invio** di una transazione non è una corroborazione ma usa gli stessi
server: dopo che il server attivo l'ha accettata, la transazione grezza viene
inviata anche a ogni server di riserva, al meglio. La regola è in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) (B-76,
B-83); oggi l'invio va al solo server attivo.
B-83); oggi l'invio va anche a tutti gli altri server, salvo la sostituzione
RBF di `bump_fee` (B-77).
## Riconnessione
+8 -11
View File
@@ -392,10 +392,9 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
*Codice:* le funzioni in tabella ·
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_bets.py`,
`tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-76, B-78, B-83) · *Oggi:* il rilascio dopo il
broadcast avviene su qualunque eccezione (anche un timeout), l'abbandono
sul silenzio del solo server attivo, il reintegro sulla parola del solo
server attivo.
*Stato:* da implementare (B-78, B-83) · *Oggi:* l'abbandono avviene sul
silenzio del solo server attivo, il reintegro sulla parola del solo server
attivo.
- **[DAT-13]** Il riconciliatore libera un input solo se il suo `spent_txid` è
uno dei txid della riga che sta abbandonando (`current_txid` o una sua
@@ -975,11 +974,9 @@ Le funzioni citate sono `app/bets/service.py:place_bet`,
*Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_reconcile.py`,
`tests/unit/test_scheduler.py`, `tests/unit/test_bets.py`,
`tests/unit/test_withdrawals.py`
*Stato:* da implementare (B-76, B-78) · *Oggi:* qualunque eccezione del
broadcast di una scommessa o di un prelievo è trattata come un rifiuto;
l'abbandono avviene quando la cronologia del solo server attivo non
contiene `current_txid`; il poller guarda solo le righe `pending` e una
riga `failed` non viene più guardata.
*Stato:* da implementare (B-78) · *Oggi:* l'abbandono avviene quando la
cronologia del solo server attivo non contiene `current_txid`; il poller
guarda solo le righe `pending` e una riga `failed` non viene più guardata.
- **[DAT-34]** Dopo la creazione, `current_txid`, `raw_tx_hex`,
`fee_rate_sat_vb`, `replaced_by_txid`, `attempt_count` e `last_broadcast_at`
@@ -1157,8 +1154,8 @@ dall'utente.
([DAT-72]), il prelievo resta `failed` finché l'operatore non lo riallinea.
*Codice:* le funzioni in tabella ·
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-76, B-78) · *Oggi:* qualunque eccezione del
broadcast porta a `failed`, e l'abbandono non richiede una prova.
*Stato:* da implementare (B-78) · *Oggi:* l'abbandono non richiede una
prova.
- **[DAT-40]** `amount_requested_sats` è ciò che l'utente ha chiesto;
`amount_sent_sats` è l'output effettivo verso il destinatario: l'importo
+6 -23
View File
@@ -203,11 +203,7 @@ flowchart TB
registro di audit, notifica SSE; la risposta è `broadcast_failed`
(HTTP 502).
*Codice:* `app/bets/service.py:_release_failed_bet`, `app/api/routes/bets.py:create_bet` ·
*Test:* `tests/unit/test_bets.py`
*Stato:* da implementare (B-76) · *Oggi:* tutto viene annullato per
qualunque errore dell'invio, anche un timeout o una connessione caduta
dopo la scrittura, quando la transazione può essere già in rete: i suoi
sat arrivano al montepremi senza partecipante.
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_broadcast_outcome.py`
- **[BET-23]** Se l'invio ha un **esito incerto**
([TX-20](transazioni-e-riconciliazione.md#invio-rifiutato)), partecipante e
transazione in corso restano `building` con le monete ancora segnate come
@@ -216,9 +212,7 @@ flowchart TB
limite di [BET-06] e tiene il round in `closing` finché il riconciliatore
non lo promuove o lo abbandona.
*Codice:* `app/bets/service.py:place_bet`, `app/api/routes/bets.py:create_bet` ·
*Test:* —
*Stato:* da implementare (B-76) · *Oggi:* un esito incerto è trattato come
un rifiuto ([BET-16]).
*Test:* `tests/unit/test_broadcast_outcome.py`
- **[BET-17]** Se l'invio è accettato — anche con un rifiuto "già nota"
([TX-04](transazioni-e-riconciliazione.md#invio-rifiutato)) — il
partecipante passa a `broadcast`, la transazione in corso a `pending`, si
@@ -229,9 +223,7 @@ flowchart TB
scommessa promossa dal riconciliatore scrive `bet_placed` in quel momento
([TX-16](transazioni-e-riconciliazione.md#riconciliazione)).
*Codice:* `app/bets/service.py:place_bet`, `app/api/routes/bets.py:create_bet` ·
*Test:* `tests/unit/test_bets.py`
*Stato:* da implementare (B-76) · *Oggi:* un "già nota" è trattato come un
rifiuto e la transazione va solo al server attivo.
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_broadcast_outcome.py`
- **[BET-18]** Alla conferma il partecipante passa a `confirmed`
([TX-07](transazioni-e-riconciliazione.md#conferma)). Solo le scommesse
**confermate** partecipano all'estrazione; il round, allo scadere, aspetta
@@ -324,12 +316,7 @@ solo se la rete rifiuta esplicitamente, RBF se non conferma in tempo.
`failure_reason`); si scrive `withdrawal_broadcast_failed`, si invia una
notifica SSE e la risposta è `broadcast_failed` (HTTP 502).
*Codice:* `app/withdrawals/service.py:_release_failed_withdrawal`, `app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py` (nessun test verifica l'evento
`withdrawal_broadcast_failed`)
*Stato:* da implementare (B-76) · *Oggi:* tutto viene annullato per
qualunque errore dell'invio, anche un timeout o una connessione caduta
dopo la scrittura: la transazione può essere partita mentre il prelievo
risulta `failed` e le monete di nuovo spendibili.
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_broadcast_outcome.py`
- **[WDR-16]** Se l'invio ha un **esito incerto**
([TX-20](transazioni-e-riconciliazione.md#invio-rifiutato)), prelievo e
transazione in corso restano `building` con le monete ancora segnate come
@@ -337,9 +324,7 @@ solo se la rete rifiuta esplicitamente, RBF se non conferma in tempo.
`amount_sent_sats` e `status` = `building`; sarà il riconciliatore a
promuoverlo o abbandonarlo.
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* —
*Stato:* da implementare (B-76) · *Oggi:* un esito incerto è trattato come
un rifiuto ([WDR-11]).
*Test:* `tests/unit/test_broadcast_outcome.py`
- **[WDR-12]** Se l'invio è accettato — anche con un rifiuto "già nota"
([TX-04](transazioni-e-riconciliazione.md#invio-rifiutato)) — il prelievo
passa a `broadcast`, la transazione in corso a `pending`, si scrive
@@ -350,9 +335,7 @@ solo se la rete rifiuta esplicitamente, RBF se non conferma in tempo.
`status`. Alla conferma il prelievo passa a `confirmed`
([TX-07](transazioni-e-riconciliazione.md#conferma)).
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` ·
*Test:* `tests/unit/test_withdrawals.py`
*Stato:* da implementare (B-76) · *Oggi:* un "già nota" è trattato come un
rifiuto e la transazione va solo al server attivo.
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_broadcast_outcome.py`
## Scommessa e prelievo insieme
+3 -3
View File
@@ -224,7 +224,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-73 | L'estrazione dipende solo dall'hash del blocco: un minatore con biglietti o il server Electrum attivo possono pilotarla; ordine dei partecipanti non verificabile | Critica | **aperto** | — | RND, ELC |
| B-74 | Header accettati senza controllo della difficoltà minima; un salto di più blocchi non viene collegato; una riorganizzazione di un blocco chiude la sessione | Critica | **aperto** | — | ELC |
| B-75 | Una risposta Electrum oltre 64 KiB chiude la connessione, e la cronologia dell'indirizzo del montepremi la supera dopo ~800 transazioni | Critica | *da registrare* | `test_electrum_client.py`, `test_deposits.py`, `test_psbt_builder.py` | ELC, TX |
| B-76 | Un invio ambiguo (timeout, connessione caduta) viene annullato come se fosse rifiutato | Critica | **aperto** | — | TX, BET, WDR |
| B-76 | Un invio ambiguo (timeout, connessione caduta) viene annullato come se fosse rifiutato | Critica | *da registrare* | `test_broadcast_outcome.py`, `test_electrum_client.py` | TX, BET, WDR |
| B-77 | Si ricorda solo l'ultimo txid di una transazione accelerata, e la sostituzione viene inviata prima di essere salvata | Critica | **aperto** | — | TX |
| B-78 | Una transazione viene abbandonata sulla base del silenzio di un solo server | Critica | **aperto** | — | TX, ELC |
| B-79 | Il pagamento di norma non ha resto, quindi non si può accelerare e può bloccare la lotteria | Critica | **aperto** | — | RND, TX |
@@ -252,9 +252,9 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84.
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76.
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (8 critici,
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (7 critici,
7 alti, 6 medi), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-99`.
+9 -19
View File
@@ -127,12 +127,8 @@ monete:
`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.
*Codice:* `app/tx/outcome.py:broadcast_with_verdict`, `app/electrum/client.py:ElectrumRpcError`, `app/electrum/listener.py:ElectrumListener.transaction_known`, `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal` ·
*Test:* `tests/unit/test_broadcast_outcome.py`, `tests/unit/test_electrum_client.py`, `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`
- **[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
@@ -142,21 +138,18 @@ monete:
[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`.
*Codice:* `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/api/routes/bets.py:create_bet`, `app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_broadcast_outcome.py`, `tests/unit/test_sqlite_transactions.py`
- **[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.
*Codice:* `app/electrum/listener.py:ElectrumListener.fan_out_transaction`, `app/tx/outcome.py:broadcast_with_verdict`, `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:* `tests/unit/test_broadcast_outcome.py`
*Stato:* da implementare (B-77), per la sostituzione RBF · *Oggi:* la
sostituzione di `bump_fee` 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
@@ -164,10 +157,7 @@ monete:
"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.
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast_outcome.py`
## Conferma