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:
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from embit import script
|
||||
@@ -215,24 +216,27 @@ async def test_bump_fee_leaves_broadcast_at_untouched(session_factory):
|
||||
|
||||
|
||||
async def test_bump_fee_raises_when_no_change_output(session_factory):
|
||||
"""The guard still matters after B-62 even though the builder no longer produces
|
||||
this shape: a single-output transaction broadcast before that change can still be
|
||||
sitting in `pending` across the deploy, and it must fail loudly rather than
|
||||
silently shrink the recipient's output. Hence a hand-built tx here — the point is
|
||||
exactly that build_signed_transaction won't make one any more."""
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
from app.wallet.psbt_builder import RBF_SEQUENCE
|
||||
|
||||
signer = derive_user_key(0)
|
||||
my_address = derive_user_address(0)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
to_address = script.p2wpkh(_key(98).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
utxo_amount = 10_000_000 # exact amount, no change output
|
||||
utxo_amount = 10_000_000 # entirely consumed by the single recipient output
|
||||
utxo_txid = "22" * 32
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
||||
to_address=to_address,
|
||||
amount_sats=10_000_000,
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
legacy_tx = Transaction(
|
||||
vin=[TransactionInput(bytes.fromhex(utxo_txid), 0, sequence=RBF_SEQUENCE)],
|
||||
vout=[TransactionOutput(utxo_amount - 141, script.Script.from_address(to_address))],
|
||||
)
|
||||
built = SimpleNamespace(raw_hex=legacy_tx.serialize().hex(), txid=legacy_tx.txid().hex())
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(username="bob", password_hash="x", derivation_index=0, address=my_address)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -219,3 +219,75 @@ async def test_failed_broadcast_marks_the_withdrawal_failed_and_frees_the_coins(
|
||||
assert (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().spent_txid is None
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 3_000_000_000
|
||||
|
||||
|
||||
# --- B-62: "withdraw everything" must not build an unbumpable transaction ---------
|
||||
|
||||
|
||||
async def test_full_balance_withdrawal_keeps_a_bumpable_change_output(session_factory):
|
||||
"""The UI's max-amount checkbox sends the whole confirmed balance, so change came
|
||||
out at 0, the change output was dropped, and the tx had a single output —
|
||||
bump_fee then had nothing to shrink and raised RbfError every 30s until the
|
||||
reconciler abandoned the row hours later. Adding inputs is no answer here: the tx
|
||||
already spends every UTXO the user has. So a dust limit stays behind instead."""
|
||||
from embit.transaction import Transaction
|
||||
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
balance = 2_000_000_000
|
||||
user_id = await _make_funded_user(session_factory, 40, balance)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
withdrawal = await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, balance)
|
||||
|
||||
tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
|
||||
assert len(tx.vout) == 2 # recipient + change: bumpable
|
||||
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
|
||||
|
||||
# The user asked for everything and is told what actually went out — the row
|
||||
# already distinguishes the two, since the fee comes out of the amount anyway.
|
||||
assert withdrawal.amount_requested_sats == balance
|
||||
fee = balance - sum(o.value for o in tx.vout)
|
||||
change = min(o.value for o in tx.vout)
|
||||
assert change == DUST_LIMIT_SATS
|
||||
assert withdrawal.amount_sent_sats == balance - DUST_LIMIT_SATS - fee
|
||||
|
||||
|
||||
async def test_a_bet_from_a_balance_equal_to_the_bet_is_refused(session_factory):
|
||||
"""The same shape on the PLAY side, where reducing the amount isn't an option —
|
||||
the bet is a fixed price. "A user's balance must never exactly equal the bet" is
|
||||
a documented invariant of the PLAY phase; this is where it's enforced, with an
|
||||
error that says how much more is needed rather than a bare "insufficient"."""
|
||||
from app.bets.service import BetError
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
user_id = await _make_funded_user(session_factory, 41, BET_AMOUNT_SATS) # exactly the bet
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, client, user)
|
||||
|
||||
assert excinfo.value.code == "balance_leaves_no_change"
|
||||
assert excinfo.value.params == {"required_extra_sats": DUST_LIMIT_SATS}
|
||||
assert not client.broadcasted
|
||||
|
||||
async with session_factory() as session:
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
assert utxo.spent_txid is None # refused before anything moved
|
||||
|
||||
|
||||
async def test_a_bet_with_a_dust_limit_of_headroom_is_accepted(session_factory):
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
user_id = await _make_funded_user(session_factory, 42, BET_AMOUNT_SATS + DUST_LIMIT_SATS)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(session, client, user)
|
||||
|
||||
assert participant.status == "broadcast"
|
||||
|
||||
Reference in New Issue
Block a user