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:
1 parent
2b6ddb019b
commit
f936d6660c
23 files changed
+836
-130
No files matched your search
+15
-40
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user