From ccd15807ed534a1f7f4cf4e3658f4ff903a0f6fa Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 16 Oct 2026 14:53:10 +0200 Subject: [PATCH] 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 --- BUGS.md | 10 - CLAUDE.md | 8 +- README.md | 2 +- app/tx/broadcast.py | 110 +++++-- ...ransazione-utente-conserva-uscita-resto.md | 7 +- docs/audit-log.md | 24 +- docs/limiti-noti.md | 61 ++-- docs/operativita.md | 24 +- docs/storico-bug.md | 6 +- docs/transazioni-e-riconciliazione.md | 12 +- tests/unit/test_rbf_dust.py | 284 ++++++++++++++++++ 11 files changed, 440 insertions(+), 108 deletions(-) create mode 100644 tests/unit/test_rbf_dust.py diff --git a/BUGS.md b/BUGS.md index edbe9a9..453c035 100644 --- a/BUGS.md +++ b/BUGS.md @@ -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; diff --git a/CLAUDE.md b/CLAUDE.md index 7cebd28..70b7bc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 0ff9cb8..e125206 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/tx/broadcast.py b/app/tx/broadcast.py index e105724..8cfef99 100644 --- a/app/tx/broadcast.py +++ b/app/tx/broadcast.py @@ -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) diff --git a/docs/adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md b/docs/adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md index 0e48bb0..26418d4 100644 --- a/docs/adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md +++ b/docs/adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md @@ -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` diff --git a/docs/audit-log.md b/docs/audit-log.md index 412dc65..cd35d9f 100644 --- a/docs/audit-log.md +++ b/docs/audit-log.md @@ -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 diff --git a/docs/limiti-noti.md b/docs/limiti-noti.md index 1e8607c..b68eaa4 100644 --- a/docs/limiti-noti.md +++ b/docs/limiti-noti.md @@ -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 diff --git a/docs/operativita.md b/docs/operativita.md index 215f19f..c4aef1e 100644 --- a/docs/operativita.md +++ b/docs/operativita.md @@ -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` diff --git a/docs/storico-bug.md b/docs/storico-bug.md index df5349a..c12f334 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -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`. diff --git a/docs/transazioni-e-riconciliazione.md b/docs/transazioni-e-riconciliazione.md index c12545f..6b7a576 100644 --- a/docs/transazioni-e-riconciliazione.md +++ b/docs/transazioni-e-riconciliazione.md @@ -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), diff --git a/tests/unit/test_rbf_dust.py b/tests/unit/test_rbf_dust.py new file mode 100644 index 0000000..629cd64 --- /dev/null +++ b/tests/unit/test_rbf_dust.py @@ -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