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
+73
View File
@@ -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,
)