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:
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user