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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user