Drop a dust-sized change into the fee on an RBF bump and audit every refused bump (B-87, TX-11, TX-24, AUD-40, AUD-09, ADR-19)

`bump_fee` only required `change > fee delta`. A change at the 294 sat minimum (a full-balance
withdrawal) shrank to ~153 sat after the very first minimum bump, relay policy refuses a
replacement with a sub-dust output, and the bumper tried again every 30 seconds for ever, with
nothing but a log line to show for it.

- When what would be left of the reducible output (the user's change, a payout's bump
  reserve) is under DUST_LIMIT_SATS the output is dropped and its whole value goes to the fee:
  that replacement is valid and the last one possible. If the output does not exist, or even
  its whole value is below BIP125's minimum increase (vsize x 1 sat/vB), RbfError. The
  recorded fee rate uses the replacement's own, smaller, size.
- RbfError now carries a `reason` (`change_too_small`, `max_fee_rate`, `replacement_rejected`);
  RbfBumper._record_failure writes one `rbf_bump_failed` row {pending_transaction_id, kind,
  txid, reason, message[:200]} with the row's user_id/round_id, in a transaction of its own,
  once per (current txid, reason) - the audit log is the dedupe clock (AUD-09) - and the pending
  row is untouched. A new txid or a new reason writes a new row.
- bump_fee's docstring no longer cites the "bet without change" case that B-62 removed.

No existing test changed (all bumps they make leave a change well above dust). New
tests/unit/test_rbf_dust.py: the 294 sat case end to end (recipient untouched, whole change to
the fee, last bump impossible), the exact boundary (leaves 294 kept / leaves 293 dropped), a
change under the minimum increase, a payout's reserve dropped with the winner and fee outputs
byte-identical, and the audit rows for each reason, once per pair, new row on a new txid or
reason, round/user attribution. Mutation-checked: the dust test of the output and the
once-per-pair check each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-16 14:53:10 +02:00
1 parent 54dbffb552
commit ccd15807ed
11 files changed
+440 -108

No files matched your search

@@ -1,6 +1,6 @@
# ADR-19 — Ogni transazione utente conserva un'uscita di resto verso l'utente, e un'accelerazione che porterebbe quel resto sotto `DUST_LIMIT_SATS` (294 sat) lo elimina
- **Stato:** Accettata — implementazione da completare (B-87)
- **Stato:** Accettata
- **Area:** Economia del gioco
- **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*.
@@ -31,7 +31,4 @@ e di [TX-11](../transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-
utente: la sua uscita riducibile è la riserva di [ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md).
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused`
*Stato:* da implementare (B-87) · *Oggi:* `bump_fee` richiede solo che il
resto superi l'aumento, e può lasciare un resto sotto soglia che nessun nodo
inoltra.
*Test:* `tests/unit/test_rbf_dust.py::test_a_minimum_change_is_dropped_into_the_fee_by_the_first_bump`, `tests/unit/test_rbf_dust.py::test_the_dust_boundary_of_what_a_bump_leaves`, `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused`
+11 -13
View File
@@ -44,9 +44,9 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
`app/tx/broadcast.py:RbfBumper._tick`, `app/main.py:lifespan`,
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/electrum/listener.py:ElectrumListener._record_reorg`
*Stato:* da implementare (B-87, B-90, B-96), per tre degli eventi
nuovi · *Oggi:* gli eventi osservativi sono solo i primi quattro e
`chain_reorg_detected`.
*Stato:* da implementare (B-90, B-96), per due degli eventi
nuovi · *Oggi:* gli eventi osservativi sono i primi quattro,
`chain_reorg_detected` e `rbf_bump_failed`.
- **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna
`payload_json`. `created_at` è impostato dall'applicazione, non dal
@@ -115,9 +115,9 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py` (solo `payout_failed`)
*Stato:* da implementare (B-87, B-96), per le ultime due letture ·
*Oggi:* le letture sono quelle di `payout_failed` e di
`draw_baseline_tip_unknown`.
*Stato:* da implementare (B-96), per l'ultima lettura ·
*Oggi:* le letture sono quelle di `payout_failed`, di
`draw_baseline_tip_unknown` e di `rbf_bump_failed`.
## Catalogo degli eventi
@@ -590,10 +590,8 @@ flowchart LR
nel registro una riga uguale ([AUD-09]); un nuovo fallimento con la stessa
coppia va solo nei log, mentre un nuovo txid (dopo un aumento riuscito) o un
motivo diverso scrivono una riga nuova.
*Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:bump_fee` ·
*Test:* —
*Stato:* da implementare (B-87) · *Oggi:* l'errore va solo nel log
(`could not bump pending_transaction N`), ogni 30 secondi.
*Codice:* `app/tx/broadcast.py:RbfBumper._record_failure`, `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_rbf_dust.py::test_a_change_too_small_bump_writes_one_rbf_bump_failed_row_per_txid_and_reason`, `tests/unit/test_rbf_dust.py::test_the_maximum_fee_rate_is_reported`, `tests/unit/test_rbf_dust.py::test_a_replacement_the_network_refuses_is_reported_and_leaves_the_row_alone`, `tests/unit/test_rbf_dust.py::test_a_new_txid_or_a_new_reason_writes_a_new_row`
### Catena
@@ -636,9 +634,9 @@ flowchart LR
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/confirmation.py:_on_payout_confirmed`,
`app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee`
*Stato:* da implementare (B-87, B-92, B-94), per i tre fatti che escono
dall'elenco · *Oggi:* gli RBF impossibili, la registrazione e il cambio
password riuscito non producono alcuna riga.
*Stato:* da implementare (B-92, B-94), per i due fatti che escono
dall'elenco · *Oggi:* la registrazione e il cambio password riuscito non
producono alcuna riga.
## Log applicativo
+27 -34
View File
@@ -33,7 +33,7 @@ implementato il [piano delle correzioni](piano-correzioni.md).
| Limite | Blocca il non presidiato oggi? | Dopo il piano |
|---|---|---|
| ~~[LIM-01]~~ `drawing` non riprende dopo un errore o un riavvio | Non più (chiuso da B-81) | — |
| [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** | Chiuso da B-79 e B-87 |
| [LIM-02] RBF di una sola forma | Non più il pagamento senza resto (B-79) né il resto sotto soglia (B-87); resta una transazione che esaurisce resto o riserva | Visibile in `rbf_bump_failed`; resta in attesa senza abbandono |
| [LIM-03] Attesa in `closing` senza limite di tempo | **Sì, in caso di congestione** | Resta senza limite di tempo per scelta ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)); con B-78 e B-87 finisce quando la rete decide |
| [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** | Da chiudere con la prova su rete reale ([operativita.md](operativita.md#prova-su-rete-reale)) prima di aprire al pubblico |
| ~~[LIM-10]~~ Riorganizzazioni non gestite | Non più (chiuso da B-82 e B-85) | Resta [LIM-24] |
@@ -50,38 +50,31 @@ implementato il [piano delle correzioni](piano-correzioni.md).
stesso vincitore ([RND-36](ciclo-di-vita-del-round.md)).
- **[LIM-02]** **L'RBF gestisce una sola forma di transazione**: un'unica uscita
di resto verso il mittente, abbastanza grande da assorbire l'aumento. Non c'è
il ripiego «aggiungi un'altra moneta». Ogni aumento costa almeno 1 sat/vB
sull'intera dimensione della transazione (141 sat per una transazione
con un ingresso e due uscite) e si toglie dal resto; quando l'aumento supera
il resto, `bump_fee` solleva `RbfError`. Inoltre `bump_fee` **non controlla
che il resto ridotto resti sopra la soglia di polvere**: con un resto vicino
al minimo di 294 sat ([ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md)) già il primo aumento produce un'uscita sotto
soglia, che le regole di inoltro standard rifiutano. **Il pagamento del
round, nel caso normale, non ha alcun resto** (spende esattamente il
montepremi del round, e un resto sotto soglia va alla commissione), quindi
non è accelerabile affatto.
- *Impatto:* con la rete congestionata una transazione a 1 sat/vB può
restare in mempool indefinitamente. Poiché il riconciliatore abbandona solo
ciò che il server **non conosce**, una transazione ferma ma presente in
mempool non viene mai abbandonata: una scommessa così blocca il round in
`closing`, un pagamento così lo blocca in `paying_out`. `RbfBumper`
registra un errore ogni 30s.
- *Mitigazione attuale:* resto minimo garantito sulle transazioni utente;
tetto al fee rate (`MAX_FEE_RATE_SAT_VB`); abbandono dopo 6 ore per ciò che
sparisce dalla rete; ritentativo automatico del pagamento dopo un
abbandono.
- *Stato:* da chiudere con B-87 (la riserva del pagamento, B-79, c'è).
- *Per chiuderlo:* ogni pagamento porta un'uscita di riserva che
l'accelerazione riduce ([ADR-17](adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md), B-79); un aumento che lascerebbe un
resto sotto `DUST_LIMIT_SATS` elimina il resto e ne versa il valore in
commissione, e quella sostituzione è l'ultima ([ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md), B-87); ogni
`RbfError` e ogni sostituzione rifiutata scrivono `rbf_bump_failed` in
audit. Resta accettato, dopo il piano: nessun ripiego «aggiungi un'altra
moneta» né CPFP; una transazione che ha esaurito resto o riserva e non
conferma nemmeno al tetto del fee rate resta in attesa, visibile, e non
viene abbandonata ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)). Il docstring di `bump_fee` cita ancora come
esempio «una scommessa senza resto», caso che dopo B-62 non esiste più.
riducibile — il resto verso il mittente per scommesse e prelievi, l'uscita
di riserva per il pagamento ([RND-38](ciclo-di-vita-del-round.md#4--pagamento), B-79).
Non c'è il ripiego «aggiungi un'altra moneta» né il CPFP. Ogni aumento costa
almeno 1 sat/vB sull'intera dimensione della transazione (141 sat per una
transazione con un ingresso e due uscite) e si toglie da quell'uscita; se
quanto resta sarebbe sotto la soglia di polvere l'uscita viene eliminata e
il suo valore va in commissione, e quella sostituzione è l'ultima (B-87,
[ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md)); se
nemmeno l'intera uscita raggiunge l'aumento minimo, o non esiste, `bump_fee`
solleva `RbfError`.
- *Impatto:* con la rete congestionata una transazione a 1 sat/vB che ha
esaurito resto o riserva può restare in mempool indefinitamente. Poiché il
riconciliatore abbandona solo ciò che il server **non conosce**, una
transazione ferma ma presente in mempool non viene mai abbandonata: una
scommessa così blocca il round in `closing`, un pagamento così lo blocca
in `paying_out`.
- *Mitigazione attuale:* la riserva del pagamento è dimensionata per circa
10 sat/vB; resto minimo garantito sulle transazioni utente; tetto al fee
rate (`MAX_FEE_RATE_SAT_VB`); ogni accelerazione impossibile o rifiutata
scrive `rbf_bump_failed` (una volta per transazione e motivo); abbandono
dopo 6 ore per ciò che sparisce dalla rete; ritentativo automatico del
pagamento dopo un abbandono.
- *Stato:* accettato dopo B-79 e B-87: una transazione che ha esaurito resto
o riserva e non conferma nemmeno al tetto del fee rate resta in attesa,
visibile, e non viene abbandonata ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)).
*Codice:* `app/tx/broadcast.py:bump_fee` · `app/wallet/psbt_builder.py:build_payout_transaction` · `app/tx/reconcile.py:reconcile_once`
@@ -95,7 +88,7 @@ implementato il [piano delle correzioni](piano-correzioni.md).
nuovo round si apre nel frattempo.
- *Mitigazione attuale:* lo stato è visibile (`closing`) e il pannello
«Transazioni pendenti» mostra la scommessa che trattiene il round.
- *Stato:* B-78 e B-77 chiusi; resta da chiudere B-87 (resto minimo), e per scelta l'assenza di un limite di tempo.
- *Stato:* B-78, B-77 e B-87 chiusi; resta per scelta l'assenza di un limite di tempo.
- *Per chiuderlo:* con B-78 l'attesa finisce quando la scommessa conferma
(una qualsiasi delle sue versioni, B-77) o quando c'è la prova che non
confermerà mai ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)), non dopo un tempo fisso; con B-87 la scommessa
+12 -12
View File
@@ -608,15 +608,18 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
### RBF impossibile
- **[OPS-17]**
**Sintomi.** Nel log, ogni 30 secondi per la stessa riga,
`could not bump pending_transaction N` con un `RbfError`: "no change output
large enough to absorb a fee bump" oppure "already at the maximum fee rate".
Nessuna riga di audit. `attempt_count` smette di salire. Un aumento che la
rete rifiuta (per esempio perché il resto scenderebbe sotto la soglia di
polvere) compare invece come errore di invio nello stesso messaggio.
**Causa.** L'RBF riduce solo il resto che torna al mittente; se il resto non
basta a coprire l'aumento, o la commissione è già al massimo (10.000 sat/vB),
non si può fare. Un pagamento ha un'uscita di riserva
**Sintomi.** Una riga `rbf_bump_failed` nel registro di audit (sezione
Audit log del pannello, con `pending_transaction_id`, `txid` e `reason`):
`change_too_small`, `max_fee_rate` o `replacement_rejected`. Se ne scrive una
sola per transazione e motivo; nel log, ogni 30 secondi, resta
`could not bump pending_transaction N`. `attempt_count` smette di salire.
**Causa.** L'RBF riduce un'unica uscita, il resto che torna al mittente; se
l'aumento la porterebbe sotto la soglia di polvere l'uscita viene eliminata
e il suo valore va in commissione, e quella sostituzione è l'ultima
(`change_too_small` ai tentativi successivi). Non si può fare nemmeno se
l'uscita non esiste o non basta all'aumento minimo, o se la commissione è già
al massimo (10.000 sat/vB, `max_fee_rate`); `replacement_rejected` è una
sostituzione che la rete ha rifiutato esplicitamente. Un pagamento ha un'uscita di riserva
(B-79) che l'aumento riduce; la riserva è dimensionata per arrivare a circa
10 sat/vB, oltre la quale (o per un pagamento costruito prima di B-79, che
non ce l'ha) la transazione non si può accelerare.
@@ -628,9 +631,6 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
([OPS-23]); il sistema accrediterà l'uscita della nuova transazione come un
normale deposito. Per un pagamento non c'è un'alternativa supportata: la
chiave del montepremi non è esportabile ([OPS-24]).
Dopo B-87 un aumento che lascerebbe un resto sotto la soglia di polvere
toglie il resto invece di fallire, e ogni fallimento scrive
`rbf_bump_failed`.
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:RbfBumper._tick` ·
*Test:* `tests/unit/test_broadcast.py`
+3 -3
View File
@@ -235,7 +235,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | *da registrare* | `test_electrum_client.py`, `test_electrum_listener.py` | ELC, SEC |
| B-85 | Le riorganizzazioni della catena non vengono né seguite né segnalate | Alta | *da registrare* | `test_electrum_listener.py` | ELC, RND |
| B-86 | Un server connesso ma fermo non viene mai sostituito | Alta | *da registrare* | `test_electrum_listener.py` | ELC, OPS |
| B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | **aperto** | — | TX |
| B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | *da registrare* | `test_rbf_dust.py`, `test_broadcast.py` | TX |
| B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC |
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | **aperto** | — | SEC, OPS |
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | **aperto** | — | OPS, REG |
@@ -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, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82.
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87.
- **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 (0 critici,
4 alti, 6 medi), da implementare nell'ordine di
3 alti, 6 medi), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-99`.
+3 -9
View File
@@ -243,11 +243,7 @@ sostituibilità fin dal primo invio.
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-87) · *Oggi:* il pagamento si accelera già
dall'uscita di riserva (B-79), ma per ogni transazione basta che l'uscita
superi l'aumento: un resto di 294 sat diventa 153 sat, sotto la soglia di
dust, e la rete rifiuta la sostituzione a ogni giro.
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_rbf_dust.py`
- **[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
@@ -282,10 +278,8 @@ sostituibilità fin dal primo invio.
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`.
*Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:RbfBumper._record_failure` ·
*Test:* `tests/unit/test_rbf_dust.py`
Il resto minimo di scommesse e prelievi è di 294 sat
([BET-14](scommesse-e-prelievi.md#cosa-succede),