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

-10
View File
@@ -102,16 +102,6 @@ decisions, see `docs/limiti-noti.md`):
## High
### B-87 — The RBF bump can leave a dust change output
`bump_fee` only requires change > fee delta. A 294-sat change (full-balance
withdrawal) becomes ~153 sat after the minimum bump and every replacement is refused
by relay, every 30s.
Fix: when a bump would leave change below `DUST_LIMIT_SATS`, the change output is
dropped and its value goes to the fee (the replacement is then final); `RbfError`
and every refused bump write a `rbf_bump_failed` audit row.
### B-89 — Key problems surface only at first use
Startup checks only that the key file exists and `XPRV_ENCRYPTION_KEY` is non-empty;
+4 -4
View File
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 stages of the original build order are code-complete and unit-tested — 670 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
All 10 stages of the original build order are code-complete and unit-tested — 681 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
@@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # all 670 tests
python -m pytest # all 681 tests
python -m pytest tests/unit/test_hd.py # one file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
```
@@ -256,14 +256,14 @@ Explicit design choices, not derivable from any single file — respect them:
- `GET /admin/users/{id}/privkey` exporting a raw WIF is **intentional**, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes `admin_privkey_accessed` — don't remove that logging.
- **Usernames are case-insensitive** (B-57): one namespace, enforced by a unique index on `lower(username)` (`app/db/models.py`) and matched with `func.lower(...)` on both register and login. The name is still *stored* as typed — that's what `/admin` and the audit log show. The migration refuses to run if two existing accounts differ only by case, rather than guessing which one to rename: both may hold funds.
- Argon2 hashing means **no password recovery, only reset**: `POST /admin/users/{id}/reset-password` sets a new random password, returns it once for the operator to relay, and logs `admin_password_reset`. No self-service reset exists (no email is ever collected); a logged-in user can only *change* their password by supplying the current one.
- RBF bumps are paid by whoever's change the tx pays back to — the user for bets/withdrawals, the pool for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only the sender's own change shrinks (`bump_fee`).
- RBF bumps are paid by whoever's output the tx reserves for it — the user's change for bets/withdrawals, the bump reserve (paid to the reserve address) for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only that one output shrinks, or is dropped into the fee when it would fall under dust (`bump_fee`, B-87).
## Known gaps / TODO
Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here.
- **The `drawing` wait is unbounded by design** (the draw's entropy genuinely depends on a future block). It resumes on its own after an exception or a restart (B-81), and is observable rather than silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36).
- **RBF handles one shape only**: a single reducible output — the sender's change for a bet/withdrawal, the payout's bump reserve — big enough to absorb the increase. No extra-input fallback, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why `build_signed_transaction` now guarantees a change output of at least `DUST_LIMIT_SATS` instead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused with `balance_leaves_no_change`. What's left is a bump whose *delta* exceeds an otherwise-fine change output, which still raises `RbfError`; that tx is eventually abandoned and its UTXOs released.
- **RBF handles one shape only**: a single reducible output — the sender's change for a bet/withdrawal, the payout's bump reserve (B-79). No extra-input fallback and no CPFP, and none would help the case that used to hurt (a transaction that already spends everything the sender has) — which is why `build_signed_transaction` guarantees a change output of at least `DUST_LIMIT_SATS` (B-62) and every payout carries the reserve. Since B-87 a bump that would leave that output under dust drops it and gives its whole value to the fee (the replacement is then the last possible one); a bump with no output, or one whose output cannot even pay BIP125's minimum increase, raises `RbfError`. Every such failure — and `max_fee_rate` and a replacement the network refuses — is written by `RbfBumper._record_failure` as one `rbf_bump_failed` audit row per (current txid, reason). What stays accepted: a transaction that has exhausted its change or reserve and still does not confirm waits, visibly, and is never abandoned while a server knows it.
- **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network).
- **No user-facing history of rounds or transactions.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above) and `GET /bug-reports/mine` one more (the reporter's own reports). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
- **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
+1 -1
View File
@@ -62,7 +62,7 @@ python -m pytest # all tests
python -m pytest tests/unit/test_hd.py # one file
```
670 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
681 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
the pending-inclusive balance calculation, and the SSE push channel. No
automated integration tests against a live Electrum connection — mainnet
+93 -17
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import logging
from datetime import datetime, timedelta, timezone
@@ -9,8 +10,10 @@ from embit.finalizer import finalize_psbt
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import (
AuditLog,
PendingTransaction,
PendingTransactionTxid,
Round,
@@ -25,7 +28,7 @@ from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_
from app.tx.versions import record_version
from app.wallet.hd import derive_pool_address, derive_round_pool_key, derive_user_key
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
from app.wallet.psbt_builder import DUST_LIMIT_SATS, MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
logger = logging.getLogger(__name__)
@@ -39,8 +42,17 @@ _FEE_RATE_INCREMENT = 1 # how much pending.fee_rate_sat_vb's *target* rises by
_INCREMENTAL_RELAY_FEE_RATE_SAT_VB = 1
# B-87 (TX-24, AUD-40): why a bump could not be made — the `reason` of the `rbf_bump_failed`
# audit row the bumper writes.
REASON_CHANGE_TOO_SMALL = "change_too_small"
REASON_MAX_FEE_RATE = "max_fee_rate"
REASON_REPLACEMENT_REJECTED = "replacement_rejected"
class RbfError(Exception):
pass
def __init__(self, message: str, reason: str = REASON_CHANGE_TOO_SMALL):
super().__init__(message)
self.reason = reason
def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool:
@@ -145,13 +157,16 @@ async def bump_fee(
matched is a conflict: the row is left alone, the candidate stays recorded, and
the reconciler resolves it.
Only handles the common case: exactly one change output paying back to the
tx's own sender address, large enough to absorb the increase. If there's no
such output (e.g. an exact-amount bet with no change), this raises RbfError —
bumping such a tx would require selecting additional inputs, which isn't
implemented for the MVP; it needs manual operator intervention. Also raises
RbfError, rather than bumping, once the row is already at MAX_FEE_RATE_SAT_VB
(B-32), and when the network explicitly refuses the replacement (TX-24).
Only handles one shape: exactly one output to take the increase from — the sender's
own change, or a payout's bump reserve (B-79) — and when what would be left of it is
below the dust limit the output is dropped and its whole value goes to the fee, which
makes that replacement the last possible one (B-87, TX-11). With no such output, or
when even its whole value is below BIP125's minimum increase, this raises RbfError —
adding inputs would not help the case that used to hurt (a transaction that already
spends everything the sender has). Also raises RbfError, rather than bumping, once the
row is already at MAX_FEE_RATE_SAT_VB (B-32), and when the network explicitly refuses
the replacement (TX-24). Each carries a `reason` for the `rbf_bump_failed` audit row
(RbfBumper._record_failure).
"""
# --- Phase 1: read what's needed, close the session before any network call ---
async with session_factory() as session:
@@ -162,7 +177,8 @@ async def bump_fee(
if pending.fee_rate_sat_vb >= MAX_FEE_RATE_SAT_VB:
raise RbfError(
f"pending_transaction {pending_id}: already at the maximum fee rate "
f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further"
f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further",
REASON_MAX_FEE_RATE,
)
kind = pending.kind
@@ -193,12 +209,28 @@ async def bump_fee(
fee_delta = max(target_fee - old_fee, min_valid_delta)
change_index = _find_reducible_output(old_tx, kind, own_address)
if change_index is None or old_tx.vout[change_index].value <= fee_delta:
raise RbfError(f"pending_transaction {pending_id}: no change output large enough to absorb a fee bump")
if change_index is None:
raise RbfError(f"pending_transaction {pending_id}: there is no output to take a fee bump from")
reducible_value = old_tx.vout[change_index].value
if reducible_value < min_valid_delta:
# Not even giving the whole output to the fee reaches BIP125's minimum increase.
raise RbfError(
f"pending_transaction {pending_id}: its change ({reducible_value} sat) cannot even cover the "
f"minimum relay increase ({min_valid_delta} sat)"
)
new_vout = list(old_tx.vout)
bumped_change = new_vout[change_index].value - fee_delta
new_vout[change_index] = TransactionOutput(bumped_change, new_vout[change_index].script_pubkey)
bumped_change = reducible_value - fee_delta
if bumped_change >= DUST_LIMIT_SATS:
new_vout[change_index] = TransactionOutput(bumped_change, new_vout[change_index].script_pubkey)
else:
# B-87 (TX-11): what would be left is below the dust limit, an output no node relays — a
# 294 sat change shrinks to ~153 after the very first minimum bump, and every replacement
# kept by it was refused, every 30 seconds, for ever. Drop the output instead and give its
# whole value to the fee: the replacement is valid, and it is the last one this
# transaction can have, since nothing is left to reduce.
del new_vout[change_index]
fee_delta = reducible_value
new_vin = [TransactionInput(v.txid, v.vout, sequence=RBF_SEQUENCE) for v in old_tx.vin]
new_tx = Transaction(vin=new_vin, vout=new_vout)
@@ -232,7 +264,10 @@ async def bump_fee(
# The network explicitly refused the replacement and no server knows it: the row
# is unchanged and keeps the version that is on the network (TX-24). The
# candidate stays recorded — it was never relayed, so nobody will confirm it.
raise RbfError(f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}")
raise RbfError(
f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}",
REASON_REPLACEMENT_REJECTED,
)
# ACCEPTED, or UNCERTAIN: the replacement may well be on the network, so the row
# follows it (TX-12); the old version stays recorded and is still recognised should
# it be the one that confirms.
@@ -259,7 +294,7 @@ async def bump_fee(
replaced_by_txid=old_txid, # points backwards: what current_txid replaced
current_txid=new_txid,
raw_tx_hex=raw_hex,
fee_rate_sat_vb=(old_fee + fee_delta) // vsize,
fee_rate_sat_vb=(old_fee + fee_delta) // estimate_vsize(len(new_vin), len(new_vout)),
attempt_count=PendingTransaction.attempt_count + 1,
last_broadcast_at=datetime.now(timezone.utc),
)
@@ -367,6 +402,46 @@ class RbfBumper:
logger.exception("RBF bump tick failed")
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
async def _record_failure(self, pending_id: int, exc: RbfError) -> None:
"""B-87 (TX-24, AUD-40, AUD-09): a bump that cannot be made is visible from /admin as one
`rbf_bump_failed` row — in a transaction of its own, the row stays `pending` — and only
**once per (current txid, reason)**: the same failure on the same transaction every 30
seconds goes to the log alone, while a new txid (after a successful bump) or another
reason writes a new row."""
try:
async with self._session_factory() as session:
pending = await session.get(PendingTransaction, pending_id)
if pending is None:
return
already = (
await session.scalars(
select(AuditLog.payload_json).where(
AuditLog.event_type == "rbf_bump_failed",
AuditLog.payload_json.contains(f'"txid": "{pending.current_txid}"'),
)
)
).all()
for payload_json in already:
payload = json.loads(payload_json)
if payload.get("txid") == pending.current_txid and payload.get("reason") == exc.reason:
return
await write_audit_log(
session,
"rbf_bump_failed",
{
"pending_transaction_id": pending.id,
"kind": pending.kind,
"txid": pending.current_txid,
"reason": exc.reason,
"message": str(exc)[:200],
},
user_id=pending.user_id,
round_id=pending.round_id,
)
await session.commit()
except Exception:
logger.exception("could not record the failed bump of pending_transaction %s", pending_id)
async def _tick(self, client: ElectrumClient) -> None:
now = datetime.now(timezone.utc)
async with self._session_factory() as session:
@@ -379,7 +454,8 @@ class RbfBumper:
for pending_id in due_ids:
try:
await bump_fee(self._session_factory, client, pending_id, network=self._network)
except RbfError:
except RbfError as exc:
logger.exception("could not bump pending_transaction %s", pending_id)
await self._record_failure(pending_id, exc)
except Exception:
logger.exception("unexpected error bumping pending_transaction %s", pending_id)
@@ -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),
+284
View File
@@ -0,0 +1,284 @@
"""B-87 (TX-11, TX-24, AUD-40, AUD-09): a bump never leaves a dust-sized output, and every bump
that cannot be made is visible from /admin.
`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, a replacement with a
sub-dust output is refused by relay policy, and the bumper retried it every 30 seconds for
ever with nothing but a log line to show for it.
"""
import json
from datetime import datetime, timedelta, timezone
import pytest
from embit import script
from embit.finalizer import finalize_psbt
from embit.psbt import PSBT
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from sqlalchemy import select
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User
from app.tx.broadcast import RbfBumper, RbfError, bump_fee
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import (
DUST_LIMIT_SATS,
MAX_FEE_RATE_SAT_VB,
RBF_SEQUENCE,
Utxo,
build_signed_transaction,
estimate_vsize,
)
from tests.unit.fake_network import FakeNetwork, rpc_error
from tests.unit.test_broadcast import FakeClient, _key, _seed_payout, session_factory # noqa: F401 (fixture)
_AMOUNT = 10_000_000
_DELTA = estimate_vsize(1, 2) # the first minimum bump of a one-input, two-output tx at 1 sat/vB: 141 sat
def _stale() -> datetime:
return datetime.now(timezone.utc) - timedelta(seconds=5000)
async def _pending_bet(session_factory, *, change: int, fee_rate: int = 1, username: str = "alice"):
"""A real bet whose change output is exactly `change` sats, in `pending`, due for a bump."""
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
address = derive_user_address(0)
utxo_txid = "11" * 32
built = build_signed_transaction(
signing_key=signer,
from_script=script.p2wpkh(signer.to_public()),
utxos=[Utxo(utxo_txid, 0, _AMOUNT + change)],
to_address=script.p2wpkh(_key(99).to_public()).address(network=PLM_MAINNET),
amount_sats=_AMOUNT,
change_address=address,
fee_rate_sat_vb=fee_rate,
)
assert built.change_sats == change
async with session_factory() as session:
user = User(username=username, password_hash="x", derivation_index=0, address=address)
session.add(user)
await session.commit()
row = PendingTransaction(
kind="bet", user_id=user.id, current_txid=built.txid, fee_rate_sat_vb=fee_rate,
raw_tx_hex=built.raw_hex, status="pending", broadcast_at=_stale(), last_broadcast_at=_stale(),
)
session.add(row)
await session.commit()
return row.id, built, FakeClient({utxo_txid: _AMOUNT + change})
def _outputs(raw_hex: str) -> list[int]:
return [o.value for o in Transaction.parse(bytes.fromhex(raw_hex)).vout]
# --- the change that would fall under dust is dropped into the fee (TX-11) ------------------------------
async def test_a_minimum_change_is_dropped_into_the_fee_by_the_first_bump(session_factory):
"""The case of the finding: a 294 sat change. The replacement carries one output (the
recipient's, untouched), pays the whole change as fee, and is the last one possible."""
pending_id, built, client = await _pending_bet(session_factory, change=DUST_LIMIT_SATS)
new_txid = await bump_fee(session_factory, client, pending_id)
assert new_txid is not None and new_txid != built.txid
old, new = _outputs(built.raw_hex), _outputs(client.broadcasted[0])
assert len(old) == 2 and len(new) == 1
assert new[0] == old[0] # the recipient is never touched
assert sum(old) - sum(new) == DUST_LIMIT_SATS # the whole change went to the fee
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
assert row.current_txid == new_txid and row.status == "pending"
assert row.fee_rate_sat_vb == (built.fee_sats + DUST_LIMIT_SATS) // estimate_vsize(1, 1)
# nothing is left to reduce: any further bump is impossible, with the reason to show for it
async with session_factory() as session:
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
await session.commit()
with pytest.raises(RbfError) as refused:
await bump_fee(session_factory, client, pending_id)
assert refused.value.reason == "change_too_small" and len(client.broadcasted) == 1
@pytest.mark.parametrize(
"change, kept",
[
pytest.param(DUST_LIMIT_SATS + _DELTA, True, id="leaves-exactly-dust-limit-and-is-kept"),
pytest.param(DUST_LIMIT_SATS + _DELTA - 1, False, id="one-sat-under-and-is-dropped"),
pytest.param(DUST_LIMIT_SATS + _DELTA + 1_000, True, id="plenty-and-is-kept"),
],
)
async def test_the_dust_boundary_of_what_a_bump_leaves(session_factory, change, kept):
pending_id, built, client = await _pending_bet(session_factory, change=change)
await bump_fee(session_factory, client, pending_id)
new = _outputs(client.broadcasted[0])
if kept:
assert len(new) == 2 and new[1] == change - _DELTA >= DUST_LIMIT_SATS
else:
assert len(new) == 1
assert sum(_outputs(built.raw_hex)) - sum(new) == change # all of it, not just the delta
async def test_a_change_that_cannot_even_pay_the_minimum_increase_cannot_be_bumped(session_factory):
"""Giving the whole output to the fee must still reach BIP125's minimum increase."""
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
address = derive_user_address(0)
legacy = Transaction(
vin=[TransactionInput(bytes.fromhex("22" * 32), 0, sequence=RBF_SEQUENCE)],
vout=[
TransactionOutput(9_999_000, script.Script.from_address(script.p2wpkh(_key(97).to_public()).address(network=PLM_MAINNET))),
TransactionOutput(_DELTA - 1, script.Script.from_address(address)),
],
)
async with session_factory() as session:
user = User(username="bob", password_hash="x", derivation_index=0, address=address)
session.add(user)
await session.commit()
row = PendingTransaction(
kind="bet", user_id=user.id, current_txid=legacy.txid().hex(), fee_rate_sat_vb=1,
raw_tx_hex=legacy.serialize().hex(), status="pending", broadcast_at=_stale(), last_broadcast_at=_stale(),
)
session.add(row)
await session.commit()
pending_id = row.id
client = FakeClient({"22" * 32: 10_000_000})
with pytest.raises(RbfError):
await bump_fee(session_factory, client, pending_id)
assert client.broadcasted == []
async def test_a_payouts_bump_reserve_is_dropped_the_same_way(session_factory):
"""TX-11 names the reserve too: when a bump would shrink it under dust it is dropped, and
the winner's and the fee address' outputs are still byte-for-byte what they were."""
built, pending_id, prevouts = await _seed_payout(session_factory, n=1, fee_rate=1)
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
row.fee_rate_sat_vb = 9 # the next target (10 sat/vB) leaves 172 sat of a 1 720 sat reserve
await session.commit()
client = FakeClient(prevouts)
await bump_fee(session_factory, client, pending_id)
old_tx = Transaction.parse(bytes.fromhex(built.raw_hex))
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
assert len(old_tx.vout) == 3 and len(new_tx.vout) == 2
assert new_tx.vout[0].serialize() == old_tx.vout[0].serialize()
assert new_tx.vout[1].serialize() == old_tx.vout[1].serialize()
assert sum(o.value for o in old_tx.vout) - sum(o.value for o in new_tx.vout) == old_tx.vout[2].value
# --- every refused bump is visible (TX-24, AUD-40, AUD-09) ------------------------------------------------------
def _bumper(session_factory, client, network=None):
return RbfBumper(session_factory, lambda: client, network=network or FakeNetwork())
async def _failures(session_factory) -> list[dict]:
async with session_factory() as session:
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "rbf_bump_failed"))).all()
return [dict(json.loads(r.payload_json), user_id=r.user_id, round_id=r.round_id) for r in rows]
async def test_a_change_too_small_bump_writes_one_rbf_bump_failed_row_per_txid_and_reason(session_factory):
pending_id, built, client = await _pending_bet(session_factory, change=DUST_LIMIT_SATS)
bumper = _bumper(session_factory, client)
await bumper._tick(client) # succeeds: the change is dropped
assert await _failures(session_factory) == []
async with session_factory() as session:
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
await session.commit()
for _ in range(3): # three 30 second rounds with the same impossible bump
await bumper._tick(client)
async with session_factory() as session:
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
await session.commit()
failures = await _failures(session_factory)
assert len(failures) == 1 # once, not once per tick
row = failures[0]
assert row["reason"] == "change_too_small" and row["kind"] == "bet"
assert row["pending_transaction_id"] == pending_id and row["user_id"] is not None and row["round_id"] is None
async with session_factory() as session:
pending = await session.get(PendingTransaction, pending_id)
assert row["txid"] == pending.current_txid != built.txid # the current txid, i.e. the one that cannot be bumped
assert pending.status == "pending" # the row itself is untouched (TX-24)
assert len(row["message"]) <= 200
async def test_the_maximum_fee_rate_is_reported(session_factory):
pending_id, built, client = await _pending_bet(session_factory, change=1_000_000, fee_rate=MAX_FEE_RATE_SAT_VB)
await _bumper(session_factory, client)._tick(client)
failures = await _failures(session_factory)
assert [f["reason"] for f in failures] == ["max_fee_rate"] and failures[0]["txid"] == built.txid
assert client.broadcasted == []
async def test_a_replacement_the_network_refuses_is_reported_and_leaves_the_row_alone(session_factory):
pending_id, built, client = await _pending_bet(session_factory, change=1_000_000)
async def refuse(raw):
raise rpc_error("min relay fee not met")
client.broadcast = refuse
bumper = _bumper(session_factory, client, FakeNetwork(known=False))
await bumper._tick(client)
failures = await _failures(session_factory)
assert [f["reason"] for f in failures] == ["replacement_rejected"]
assert "min relay fee not met" in failures[0]["message"]
async with session_factory() as session:
assert (await session.get(PendingTransaction, pending_id)).current_txid == built.txid
async def test_a_new_txid_or_a_new_reason_writes_a_new_row(session_factory):
"""AUD-09: the pair (current txid, reason) is what makes two failures the same one."""
pending_id, built, client = await _pending_bet(session_factory, change=1_000_000)
bumper = _bumper(session_factory, client, FakeNetwork(known=False))
async def refuse(raw):
raise rpc_error("min relay fee not met")
real_broadcast = client.broadcast
client.broadcast = refuse
for _ in range(2):
await bumper._tick(client)
async with session_factory() as session:
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
await session.commit()
assert [f["reason"] for f in await _failures(session_factory)] == ["replacement_rejected"] # same pair: once
client.broadcast = real_broadcast # the bump now succeeds ...
await bumper._tick(client)
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
assert row.current_txid != built.txid
row.fee_rate_sat_vb = MAX_FEE_RATE_SAT_VB # ... and the next one hits the ceiling: new txid, new reason
row.last_broadcast_at = _stale()
await session.commit()
await bumper._tick(client)
failures = await _failures(session_factory)
assert [f["reason"] for f in failures] == ["replacement_rejected", "max_fee_rate"]
assert failures[0]["txid"] == built.txid and failures[1]["txid"] != built.txid
async def test_a_payout_failure_carries_its_round(session_factory):
built, pending_id, prevouts = await _seed_payout(session_factory, n=1)
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
row.fee_rate_sat_vb = MAX_FEE_RATE_SAT_VB
row.last_broadcast_at = _stale()
await session.commit()
client = FakeClient(prevouts)
await _bumper(session_factory, client)._tick(client)
failures = await _failures(session_factory)
assert len(failures) == 1 and failures[0]["kind"] == "payout" and failures[0]["round_id"] == 1
assert failures[0]["user_id"] is None