Always keep a change output, so every tx stays fee-bumpable (B-62)

The max-amount checkbox sends amount_sats == the whole confirmed balance, so
change came out at 0, the change output was dropped, and the transaction had a
single output. bump_fee has nothing to shrink there: it raised RbfError every
30s until the reconciler abandoned the row six hours later. The RBF
single-change-output limitation was a documented gap, but the UI made it the
*default* withdrawal path.

The extra-input fallback would not have helped this case: a transaction moving
the entire balance already spends every UTXO the sender has. So the fix is at
build time — build_signed_transaction never produces a change output below
DUST_LIMIT_SATS, and never folds it into the fee either:

- withdrawals pass reduce_amount_to_keep_change=True and move a dust limit less.
  The fee already comes out of the withdrawn amount by design, so this is the
  same rule applied a little harder, and Withdrawal.amount_requested_sats vs
  amount_sent_sats already existed to record the difference.
- bets don't: the bet is a fixed price that can't be quietly reduced. A balance
  exactly equal to the bet is refused with balance_leaves_no_change (translated
  into all 7 languages, carrying required_extra_sats), which turns "a user's
  balance must never exactly equal the bet" from a documented assumption into an
  enforced one — and stops an unbumpable bet from holding a round open until the
  reconciler gives up on it.

bump_fee's no-change guard stays: a single-output tx broadcast before this
change can still be pending across the deploy, and it must fail loudly rather
than start shrinking a recipient's output. Its test now hand-builds that shape,
precisely because the builder no longer will.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 23:36:55 +02:00
co-authored by Claude Opus 5
parent 37cc5eeeb5
commit 8dd913ec59
9 changed files with 197 additions and 51 deletions
+34 -7
View File
@@ -146,6 +146,7 @@ def build_signed_transaction(
amount_sats: int,
change_address: str,
fee_rate_sat_vb: int,
reduce_amount_to_keep_change: bool = False,
) -> BuiltTransaction:
"""Build, sign and finalize a single-recipient P2WPKH transaction with change
back to change_address.
@@ -155,21 +156,47 @@ def build_signed_transaction(
spec everywhere a single-recipient tx is used (bet, withdrawal): "fee deducted
from the amount being moved", not paid on top by the sender.
A change amount below DUST_LIMIT_SATS is dropped and left to the fee — paying it
back to ourselves would produce an unrelayable transaction. The fee estimate
already assumes two outputs, so dropping one never underpays.
B-62: the transaction always keeps a change output of at least DUST_LIMIT_SATS.
Change used to be folded into the fee whenever it came out below the dust limit,
which for an amount equal to the whole input total (the UI's "withdraw
everything" checkbox, or a bet from a balance exactly equal to the bet amount)
produced a single-output transaction — and `tx/broadcast.py:bump_fee` has nothing
to shrink there, so it raised RbfError every 30s until the reconciler abandoned
the row hours later. Adding inputs instead is no answer for this case in
particular: the transaction already spends every UTXO the sender has.
What happens when the change would be too small depends on who's asking, hence
`reduce_amount_to_keep_change`:
- withdrawals pass True — the amount moved is reduced just enough to leave a
dust-limit change output. The fee already comes out of the withdrawn amount by
design, so this is the same rule applied a little harder, and the caller
records what was actually sent (`Withdrawal.amount_sent_sats`).
- bets pass False (the default) and get an InsufficientFundsError instead: the
bet is a fixed price that cannot be quietly reduced, and "a user's balance must
never exactly equal the bet" is a documented invariant of the PLAY phase. The
player needs a little more than the bet amount, which is what the error says.
"""
selected, total_in = select_utxos(utxos, amount_sats)
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
change = total_in - amount_sats
if change < DUST_LIMIT_SATS:
if not reduce_amount_to_keep_change:
raise InsufficientFundsError(
f"the amount leaves no change output: {DUST_LIMIT_SATS - change} more sats are "
"needed for the transaction to stay fee-bumpable",
code="balance_leaves_no_change",
required_extra_sats=DUST_LIMIT_SATS - change,
)
amount_sats -= DUST_LIMIT_SATS - change
change = DUST_LIMIT_SATS
recipient_amount = amount_sats - fee
if recipient_amount <= 0:
raise InsufficientFundsError(
"amount too small to cover the network fee", code="amount_below_network_fee"
)
change = total_in - amount_sats
if change < DUST_LIMIT_SATS:
fee += change # dust change is unspendable and unrelayable — miners get it
change = 0
if recipient_amount < DUST_LIMIT_SATS:
raise InsufficientFundsError(
"amount too small to be sent (dust)", code="amount_below_dust_limit"