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
+52 -12
View File
@@ -114,28 +114,62 @@ def test_build_signed_transaction_deducts_fee_from_amount_not_change():
assert len(parsed.vout) == 2
def test_build_signed_transaction_omits_change_output_when_exact_amount():
def test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change(): # B-62
"""A single-output transaction is the one shape RBF cannot rescue: bump_fee has
no change to shrink, and adding inputs is no answer either since this spends
every UTXO the sender has. The bet is a fixed price, so it is refused rather
than quietly reduced."""
from app.wallet.psbt_builder import DUST_LIMIT_SATS
signer = _key(3)
from_script = script.p2wpkh(signer.to_public())
my_address = from_script.address(network=PLM_MAINNET)
to_address = script.p2wpkh(_key(4).to_public()).address(network=PLM_MAINNET)
utxos = [Utxo("22" * 32, 0, 10_000_000)] # exactly amount_sats, zero change
with pytest.raises(InsufficientFundsError) as excinfo:
build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=utxos,
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
assert excinfo.value.code == "balance_leaves_no_change"
assert excinfo.value.params == {"required_extra_sats": DUST_LIMIT_SATS}
def test_build_signed_transaction_can_reduce_the_amount_to_keep_change(): # B-62
"""What "withdraw everything" does instead: move a dust limit less and stay
fee-bumpable. The caller records the reduced amount as what was actually sent."""
from embit.transaction import Transaction
from app.wallet.psbt_builder import DUST_LIMIT_SATS
signer = _key(3)
from_script = script.p2wpkh(signer.to_public())
my_address = from_script.address(network=PLM_MAINNET)
to_address = script.p2wpkh(_key(4).to_public()).address(network=PLM_MAINNET)
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=utxos,
utxos=[Utxo("22" * 32, 0, 10_000_000)],
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
reduce_amount_to_keep_change=True,
)
assert built.change_sats == 0
from embit.transaction import Transaction
assert built.change_sats == DUST_LIMIT_SATS
assert built.recipient_sats == 10_000_000 - DUST_LIMIT_SATS - built.fee_sats
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vout) == 1
assert len(parsed.vout) == 2
assert built.recipient_sats + built.change_sats + built.fee_sats == 10_000_000
def test_build_signed_transaction_raises_when_amount_smaller_than_fee():
@@ -159,10 +193,13 @@ def test_build_signed_transaction_raises_when_amount_smaller_than_fee():
)
def test_dust_change_is_left_to_the_fee():
def test_a_below_dust_change_output_is_never_created():
"""B-06: `if change > 0` created change outputs below the dust limit, which makes
the whole transaction unrelayable — the bet or withdrawal then failed at broadcast
with an opaque error the user could do nothing about."""
with an opaque error the user could do nothing about. B-62 changed the remedy
(the change is topped up to the dust limit by moving slightly less, instead of
being folded into the fee and leaving an unbumpable single-output tx) but not
this rule: an output below DUST_LIMIT_SATS is never produced."""
from app.wallet.psbt_builder import DUST_LIMIT_SATS
signer = _key(1)
@@ -180,13 +217,16 @@ def test_dust_change_is_left_to_the_fee():
amount_sats=amount,
change_address=change_address,
fee_rate_sat_vb=1,
reduce_amount_to_keep_change=True,
)
tx = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(tx.vout) == 1 # no dust output
assert built.change_sats == 0
# Nothing vanishes: the dust ends up in the fee, and inputs still equal outputs+fee.
assert built.fee_sats >= dust_change
assert len(tx.vout) == 2
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
assert built.change_sats == DUST_LIMIT_SATS
# Nothing vanishes: inputs still equal outputs + fee, the recipient just gets
# the one satoshi that was missing from a relayable change output.
assert built.recipient_sats == amount - 1 - built.fee_sats
assert built.recipient_sats + built.change_sats + built.fee_sats == amount + dust_change