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

+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