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

+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)