Leave dust-sized change to the fee instead of creating it

`if change > 0` created a change output for any leftover at all. Below the
P2WPKH dust threshold (294 sat: the output's 31 vbytes plus the 67 needed to
spend it, at the 3000 sat/kvB dust relay fee) relaying nodes reject the whole
transaction, so the bet or withdrawal failed at broadcast with an error the user
could do nothing about — and which arrived as a 500 (BUGS.md B-06).

Sub-dust change now goes to the fee in both builders, and a sub-dust
recipient/winner/commission amount is refused up front with its own error code.
The fee estimate already assumed two outputs, so dropping one never underpays.

Cross-checked against PalladiumWallet, the source of truth for PLM parameters:
it delegates to NBitcoin's TransactionBuilder (same 294 sat threshold) and has
an explicit test — Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee —
asserting the same behaviour, so both the value and the semantics match the
reference implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:31:41 +02:00
co-authored by Claude Opus 5
parent d528c5b475
commit b4d70385a6
2 changed files with 104 additions and 2 deletions
+31 -2
View File
@@ -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). # input we create so a stuck tx can later be fee-bumped (tx/broadcast.py, stage 9).
RBF_SEQUENCE = 0xFFFFFFFD 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): class InsufficientFundsError(Exception):
"""`code` is the machine-readable identifier the API layer forwards to the """`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 receives `amount_sats - fee`, change = total_in - amount_sats. This matches the
spec everywhere a single-recipient tx is used (bet, withdrawal): "fee deducted spec everywhere a single-recipient tx is used (bet, withdrawal): "fee deducted
from the amount being moved", not paid on top by the sender. 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) selected, total_in = select_utxos(utxos, amount_sats)
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb 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" "amount too small to cover the network fee", code="amount_below_network_fee"
) )
change = total_in - amount_sats 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); # TransactionInput.txid is natural/display byte order (as in tx_hash from Electrum);
# embit reverses it internally when serializing to wire format. # embit reverses it internally when serializing to wire format.
@@ -147,16 +166,26 @@ def build_payout_transaction(
) -> PayoutTransaction: ) -> PayoutTransaction:
"""Build, sign and finalize the round payout: pool -> winner + fee address, """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 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 target = winner_share_sats + commission_sats
selected, total_in = select_utxos(utxos, target) selected, total_in = select_utxos(utxos, target)
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
winner_amount = winner_share_sats - fee winner_amount = winner_share_sats - fee
if winner_amount <= 0: if winner_amount < DUST_LIMIT_SATS:
raise InsufficientFundsError( raise InsufficientFundsError(
"winner share too small to cover the network fee", code="winner_share_below_network_fee" "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 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] vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
vout = [ vout = [
+73
View File
@@ -1,6 +1,7 @@
import pytest import pytest
from embit import script from embit import script
from embit.bip32 import HDKey from embit.bip32 import HDKey
from embit.transaction import Transaction
from app.wallet.plm_network import PLM_MAINNET from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import ( 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, change_address=my_address,
fee_rate_sat_vb=1, 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,
)