Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)

The poller and the reconciler matched current_txid only. If an earlier version of
a bumped transaction confirmed it was never seen, and after 6h the row was abandoned:
a confirmed bet's participant deleted, a sent withdrawal marked failed, a paid payout
rebuilt. bump_fee also broadcast in phase 2 and persisted in phase 3 unconditionally,
so a crash or a `database is locked` in between left a transaction on the network that
the DB did not know, and an abandon or a confirmation in the meantime was overwritten.

- New table pending_transaction_txids (migration a3f5c2d9b7e1; txid unique, index on
  the owning row; existing rows backfilled with current_txid + raw and the txid their
  last bump replaced, raw_hex empty). Written with the row by place_bet,
  request_withdrawal and _trigger_payout; removed with a bet row that a verified
  refusal deletes (DAT-38).
- bump_fee in four phases: read, build and sign, commit the replacement as a candidate
  version, broadcast (verdict of B-76, fanned out when accepted; an explicit refusal
  raises RbfError and leaves the row), then switch the row over with
  `UPDATE ... WHERE status='pending' AND current_txid=<replaced>`. Zero rows matched is
  a logged conflict: nothing is overwritten and the candidate stays recorded. An
  uncertain broadcast adopts the replacement. A retry rebuilds the same candidate
  without tripping the unique txid.
- poll_once and reconcile_once look for every txid of a row. When the version found is
  not the current one, broadcast.adopt_version makes it current (raw tx included) and
  retargets spent_txid, bet_txid, withdrawals.txid and payout_txid as a bump does,
  before the confirmation handler runs. _release_inputs frees inputs claimed by any
  version of the row.
- RbfBumper takes the listener as `network`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 14:46:43 +02:00
1 parent 2b6ddb019b
commit f936d6660c
23 files changed
+836 -130

No files matched your search

+15 -40
View File
@@ -42,8 +42,7 @@ Cosa **non** c'è qui, e dove trovarlo:
## Diagramma delle relazioni
Dieci tabelle applicative nello schema atteso (nove oggi: manca
`pending_transaction_txids`, B-77). `round_config` non ha relazioni: è una
Dieci tabelle applicative. `round_config` non ha relazioni: è una
riga singola di parametri. Gli indirizzi della piattaforma che non
appartengono a un utente sono derivati dalla master key, e i loro UTXO si
leggono dalla rete, non da una tabella:
@@ -67,7 +66,7 @@ erDiagram
rounds |o--o{ pending_transactions : "bet / payout"
withdrawals |o--o{ pending_transactions : "withdrawal"
users |o--o{ pending_transactions : "bet / withdrawal"
pending_transactions ||--|{ pending_transaction_txids : "ogni versione (da implementare)"
pending_transactions ||--|{ pending_transaction_txids : "ogni versione"
users |o--o{ bug_reports : "segnala"
users |o--o{ audit_log : "riguarda"
rounds |o--o{ audit_log : "riguarda"
@@ -233,7 +232,7 @@ erDiagram
([DAT-52]). Nessuna chiave esterna ha `ON DELETE`. Le sole cancellazioni
fisiche nel codice sono quelle di una scommessa fallita (la riga
`round_participants` e la sua `pending_transactions`, con le sue versioni in
`pending_transaction_txids` nello schema atteso,
`pending_transaction_txids`,
[DAT-24], [DAT-38], [DAT-70]); nessun'altra tabella perde righe, quindi i riferimenti
restano validi per costruzione, non per vincolo.
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
@@ -402,9 +401,7 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
stesso output risulta impegnato da un'altra transazione, quella
rivendicazione è quella valida e non va toccata.
*Codice:* `app/tx/reconcile.py:_release_inputs` ·
*Test:* `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-77), per le versioni precedenti · *Oggi:* il
confronto è solo con `current_txid`.
*Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_tx_versions.py`
- **[DAT-14]** Per ogni output impegnato, `spent_txid` è sempre il txid
**corrente** della transazione che lo spende, cioè il `current_txid` della sua
@@ -419,9 +416,7 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
*Codice:* `app/tx/broadcast.py:bump_fee` ·
`app/tx/broadcast.py:_retarget_txid_references` ·
`app/tx/confirmation.py:poll_once` ·
*Test:* `tests/unit/test_broadcast.py`
*Stato:* da implementare (B-77) · *Oggi:* solo il bump riallinea; la
conferma di una versione precedente non viene riconosciuta.
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_tx_versions.py`
### `rounds`
@@ -904,7 +899,7 @@ riconciliatore (vedi
| `broadcast_at` | DateTime | no | `utcnow` | Prima trasmissione, vedi [DAT-35] | ORM, alla creazione |
| `last_broadcast_at` | DateTime | no | `utcnow` | Ultima trasmissione, vedi [DAT-35] | ORM; `bump_fee` |
| `status` | String(16) | no | `"pending"` (mai usato: si crea sempre `building`) | Vedi [DAT-33] | vedi diagramma |
| `replaced_by_txid` | String(64) | sì | `NULL` | Il txid **sostituito** dall'ultimo bump (nonostante il nome, punta all'indietro). Nello schema atteso la cronologia completa è in `pending_transaction_txids` ([DAT-70]) e questa colonna resta solo informativa | `bump_fee` |
| `replaced_by_txid` | String(64) | sì | `NULL` | Il txid **sostituito** dall'ultimo bump (nonostante il nome, punta all'indietro). La cronologia completa è in `pending_transaction_txids` ([DAT-70]) e questa colonna resta solo informativa | `bump_fee` |
| `attempt_count` | Integer | no | `1` | 1 + numero di bump riusciti | `bump_fee` |
| `failure_reason` | String(128) | sì | `NULL` | Perché la transazione è fallita; solo con `status = failed` | `_abandon`; `_release_failed_withdrawal` |
| `failed_at` | DateTime | sì | `NULL` | Istante del passaggio a `failed`; da lì si contano i 14 giorni di sorveglianza ([DAT-72]) — *da implementare (B-78)* | `_abandon`; `_release_failed_withdrawal` |
@@ -995,12 +990,7 @@ Le funzioni citate sono `app/bets/service.py:place_bet`,
`fee_rate_sat_vb` registra la commissione effettivamente pagata dalla nuova
versione, non quella obiettivo.
*Codice:* `app/tx/broadcast.py:bump_fee` · *Test:* `tests/unit/test_broadcast.py`
*Stato:* da implementare (B-77) · *Oggi:* la nuova versione è trasmessa
nella fase 2 prima di esistere nel database, e la fase 3 riscrive la riga
**senza ricontrollarne** stato e `current_txid`: un abbandono o una
conferma avvenuti nel frattempo vengono sovrascritti. `replaced_by_txid`
conserva solo il txid immediatamente precedente.
*Codice:* `app/tx/broadcast.py:bump_fee` · *Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_tx_versions.py`
- **[DAT-35]** `broadcast_at` è la **prima** trasmissione e non viene mai
riscritta: è il riferimento da cui il riconciliatore misura i propri tempi
@@ -1062,7 +1052,7 @@ Le funzioni citate sono `app/bets/service.py:place_bet`,
### `pending_transaction_txids`
*Tabella da implementare (B-77).* Ogni versione che una riga
Ogni versione che una riga
`pending_transactions` abbia mai avuto: la prima, ogni sostituzione RBF
trasmessa e ogni candidata scritta prima di un bump. Serve perché una
qualunque di queste versioni può essere quella che la rete conferma.
@@ -1086,12 +1076,9 @@ qualunque di queste versioni può essere quella che la rete conferma.
`current_txid`: la conferma di uno qualunque conferma la riga ([DAT-14]).
La riga `bet` cancellata da `_release_failed_bet` ([DAT-38]) porta con sé
le proprie versioni, nella stessa transazione.
*Codice:* `app/db/models.py` · `app/tx/broadcast.py:bump_fee` ·
*Codice:* `app/db/models.py:PendingTransactionTxid` · `app/tx/versions.py` · `app/tx/broadcast.py:bump_fee` ·
`app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` ·
*Test:* —
*Stato:* da implementare (B-77) · *Oggi:* la tabella non esiste; poller e
riconciliatore confrontano solo `current_txid`, e `replaced_by_txid`
ricorda un solo passo indietro.
*Test:* `tests/unit/test_tx_versions.py`, `tests/unit/test_migrations.py`
- **[DAT-71]** Una versione **candidata** è una riga di questa tabella il cui
txid non è (ancora) `current_txid`: `bump_fee` l'ha scritta ma la fase 3 non
@@ -1102,9 +1089,7 @@ qualunque di queste versioni può essere quella che la rete conferma.
Il riconciliatore, quando ritrasmette, usa il `raw_tx_hex` corrente, non
quello di una candidata.
*Codice:* `app/tx/broadcast.py:bump_fee` · `app/tx/reconcile.py:reconcile_once` ·
*Test:* —
*Stato:* da implementare (B-77) · *Oggi:* non esistono candidate: la
sostituzione esiste nel database solo dopo il suo broadcast riuscito.
*Test:* `tests/unit/test_tx_versions.py`
### `withdrawals`
@@ -1240,8 +1225,8 @@ Tabella di servizio di Alembic: una riga con la revisione applicata
| `ix_rounds_single_active` | `rounds` | `UNIQUE ((1)) WHERE status IN ('open','closing','drawing','paying_out')` | al massimo un round attivo, [DAT-17] (B-09) |
| unico (`round_id`, `user_id`) | `round_participants` | `UNIQUE` | una scommessa per utente per round, [DAT-23] |
| unico (`round_id`, `draw_position`) | `round_participants` | `UNIQUE` (i `NULL` non collidono) | una posizione di estrazione per partecipante, [DAT-66] — *da implementare (B-81)* |
| unico `txid` | `pending_transaction_txids` | `UNIQUE` | un txid appartiene a una sola transazione, [DAT-70] — *da implementare (B-77)* |
| `ix_pending_transaction_txids_pending_transaction_id` | `pending_transaction_txids` | indice | versioni di una transazione — *da implementare (B-77)* |
| unico `txid` | `pending_transaction_txids` | `UNIQUE` | un txid appartiene a una sola transazione, [DAT-70] |
| `ix_pending_transaction_txids_pending_transaction_id` | `pending_transaction_txids` | indice | versioni di una transazione |
| `ix_round_participants_round_id` | `round_participants` | indice | partecipanti di un round |
| `ix_round_participants_user_id` | `round_participants` | indice | round di un utente (`last-round-result`) |
| `ix_withdrawals_user_id` | `withdrawals` | indice | prelievi di un utente |
@@ -1267,12 +1252,7 @@ letti con una scansione completa.
`app/withdrawals/service.py:request_withdrawal` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-77), per le sostituzioni RBF · *Oggi:*
l'invariante vale per la prima versione; una sostituzione RBF viene
trasmessa nella fase 2 di `bump_fee` e scritta nel database solo nella
fase 3, quindi un arresto o un `database is locked` fra le due lascia sulla
rete una transazione che il database non conosce.
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_scheduler.py`, `tests/unit/test_tx_versions.py`
- **[DAT-45]** **Ogni versione è riconoscibile, una sola è corrente.** Per ogni
transazione non terminale, `pending_transactions.current_txid` coincide con
@@ -1287,12 +1267,7 @@ letti con una scansione completa.
txid.
*Codice:* `app/tx/broadcast.py:_retarget_txid_references` ·
`app/tx/broadcast.py:bump_fee` · `app/tx/confirmation.py:poll_once` ·
*Test:* `tests/unit/test_broadcast.py`
*Stato:* da implementare (B-77) · *Oggi:* l'aggiornamento dei riferimenti
avviene in una transazione del database separata dal broadcast e non
condizionata, e i txid precedenti sono dimenticati (salvo l'ultimo, in
`replaced_by_txid`, che nessuno legge): se conferma una versione
precedente, poller e riconciliatore non la riconoscono.
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_tx_versions.py`
- **[DAT-46]** **Il montepremi di ogni round si conserva, separato dagli
altri.** Per un round pagato, con i soli partecipanti rimasti `confirmed`