diff --git a/app/wallet/psbt_builder.py b/app/wallet/psbt_builder.py index 84ddfd0..6c9ebf8 100644 --- a/app/wallet/psbt_builder.py +++ b/app/wallet/psbt_builder.py @@ -17,6 +17,14 @@ _P2WPKH_OUTPUT_VBYTES = 31 # input we create so a stuck tx can later be fee-bumped (tx/broadcast.py, stage 9). RBF_SEQUENCE = 0xFFFFFFFD +# Below this, an output costs more to spend than it's worth and relay policy rejects +# the whole transaction as "dust" — so a small change amount must be left to the fee +# instead of being paid back to ourselves. 294 sat is the standard P2WPKH threshold +# (the output's own 31 vbytes plus the 67-vbyte input needed to spend it, at the +# 3000 sat/kvB dust relay fee). Creating such an output used to make the bet or +# withdrawal fail at broadcast with an opaque error (B-06). +DUST_LIMIT_SATS = 294 + class InsufficientFundsError(Exception): """`code` is the machine-readable identifier the API layer forwards to the @@ -81,6 +89,10 @@ def build_signed_transaction( receives `amount_sats - fee`, change = total_in - amount_sats. This matches the 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. """ selected, total_in = select_utxos(utxos, amount_sats) fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb @@ -90,6 +102,13 @@ def build_signed_transaction( "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" + ) # TransactionInput.txid is natural/display byte order (as in tx_hash from Electrum); # embit reverses it internally when serializing to wire format. @@ -147,16 +166,26 @@ def build_payout_transaction( ) -> PayoutTransaction: """Build, sign and finalize the round payout: pool -> winner + fee address, with change back to the pool itself. Per spec, only the winner's share - absorbs the tx fee — the commission (fee_address) output is untouched.""" + absorbs the tx fee — the commission (fee_address) output is untouched. + + As in build_signed_transaction, dust-sized pool change is left to the fee + rather than creating an unrelayable output (B-06).""" target = winner_share_sats + commission_sats selected, total_in = select_utxos(utxos, target) fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change winner_amount = winner_share_sats - fee - if winner_amount <= 0: + if winner_amount < DUST_LIMIT_SATS: raise InsufficientFundsError( "winner share too small to cover the network fee", code="winner_share_below_network_fee" ) + if commission_sats < DUST_LIMIT_SATS: + raise InsufficientFundsError( + "commission share too small to be paid out (dust)", code="commission_below_dust_limit" + ) change = total_in - target + if change < DUST_LIMIT_SATS: + fee += change + change = 0 vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected] vout = [ diff --git a/tests/unit/test_psbt_builder.py b/tests/unit/test_psbt_builder.py index 7f8d34b..fcd0686 100644 --- a/tests/unit/test_psbt_builder.py +++ b/tests/unit/test_psbt_builder.py @@ -1,6 +1,7 @@ import pytest from embit import script from embit.bip32 import HDKey +from embit.transaction import Transaction from app.wallet.plm_network import PLM_MAINNET from app.wallet.psbt_builder import ( @@ -111,3 +112,75 @@ def test_build_signed_transaction_raises_when_amount_smaller_than_fee(): change_address=my_address, fee_rate_sat_vb=1, ) + + +def test_dust_change_is_left_to_the_fee(): + """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.""" + from app.wallet.psbt_builder import DUST_LIMIT_SATS + + signer = _key(1) + from_script = script.p2wpkh(signer.to_public()) + to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET) + change_address = script.p2wpkh(signer.to_public()).address(network=PLM_MAINNET) + + amount = 10_000_000 + dust_change = DUST_LIMIT_SATS - 1 + built = build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=[Utxo("33" * 32, 0, amount + dust_change)], + to_address=to_address, + amount_sats=amount, + change_address=change_address, + fee_rate_sat_vb=1, + ) + + 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 built.recipient_sats + built.change_sats + built.fee_sats == amount + dust_change + + +def test_change_at_the_dust_limit_is_still_paid_back(): + from app.wallet.psbt_builder import DUST_LIMIT_SATS + + signer = _key(1) + from_script = script.p2wpkh(signer.to_public()) + to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET) + change_address = script.p2wpkh(signer.to_public()).address(network=PLM_MAINNET) + + amount = 10_000_000 + built = build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=[Utxo("44" * 32, 0, amount + DUST_LIMIT_SATS)], + to_address=to_address, + amount_sats=amount, + change_address=change_address, + fee_rate_sat_vb=1, + ) + + assert built.change_sats == DUST_LIMIT_SATS + assert len(Transaction.parse(bytes.fromhex(built.raw_hex)).vout) == 2 + + +def test_dust_sized_recipient_amount_is_refused(): + signer = _key(1) + from_script = script.p2wpkh(signer.to_public()) + to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET) + change_address = script.p2wpkh(signer.to_public()).address(network=PLM_MAINNET) + + with pytest.raises(InsufficientFundsError): + build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=[Utxo("55" * 32, 0, 1_000_000)], + to_address=to_address, + amount_sats=400, # after the ~160 sat fee this lands under the dust limit + change_address=change_address, + fee_rate_sat_vb=1, + )