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
+7
View File
@@ -87,6 +87,13 @@ async def request_withdrawal(
amount_sats=amount_sats,
change_address=user.address,
fee_rate_sat_vb=config.fee_rate_sat_vb,
# B-62: "withdraw everything" asks for the whole confirmed balance, which
# would leave no change output and therefore nothing bump_fee could
# shrink — the one tx shape RBF cannot rescue, and the UI's default
# withdrawal path at that. Move a dust limit less instead of producing an
# unbumpable transaction; amount_sent_sats below records what actually
# went out, which is already how a fee-deducted withdrawal is reported.
reduce_amount_to_keep_change=True,
)
except InsufficientFundsError as exc:
raise WithdrawalError(exc.code, str(exc), **exc.params) from exc