Files
plm-lottery/tests/unit/test_psbt_builder.py
T
davideandClaude Sonnet 5.5 32df50cb75 Select coins until the change output is guaranteed (B-98, BET-24)
select_utxos took the first coin covering the amount, so 10 + 1 PLM and a
10 PLM bet was refused with balance_leaves_no_change and a 10 PLM withdrawal
was reduced. User txs now ask for target + DUST_LIMIT_SATS; the old outcome is
kept only when no allowed combination leaves change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 00:35:17 +02:00

334 lines
13 KiB
Python

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 (
MAX_PAYOUT_TX_INPUTS,
MAX_TX_INPUTS,
InsufficientFundsError,
Utxo,
build_signed_transaction,
estimate_vsize,
select_utxos,
)
def _key(seed_byte: int) -> HDKey:
root = HDKey.from_seed(bytes([seed_byte]) * 32, version=PLM_MAINNET["xprv"])
return root.derive("m/84h/746h/0h/0/0")
def test_estimate_vsize_grows_with_inputs_and_outputs():
assert estimate_vsize(1, 2) < estimate_vsize(2, 2)
assert estimate_vsize(1, 1) < estimate_vsize(1, 2)
def test_select_utxos_picks_largest_first():
utxos = [Utxo("a" * 64, 0, 5_000_000), Utxo("b" * 64, 0, 20_000_000), Utxo("c" * 64, 0, 1_000_000)]
selected, total = select_utxos(utxos, target_sats=10_000_000)
assert selected == [utxos[1]] # the 20M UTXO alone covers 10M
assert total == 20_000_000
def test_select_utxos_ignores_dust(): # B-75 (DEP-01)
from app.wallet.psbt_builder import DUST_LIMIT_SATS
utxos = [Utxo("a" * 64, 0, DUST_LIMIT_SATS - 1), Utxo("b" * 64, 0, DUST_LIMIT_SATS)]
selected, total = select_utxos(utxos, target_sats=DUST_LIMIT_SATS)
assert [u.txid for u in selected] == ["b" * 64]
with pytest.raises(InsufficientFundsError):
select_utxos(utxos, target_sats=DUST_LIMIT_SATS + 1)
def test_select_utxos_raises_when_insufficient():
utxos = [Utxo("a" * 64, 0, 1_000_000)]
with pytest.raises(InsufficientFundsError):
select_utxos(utxos, target_sats=10_000_000)
def test_select_utxos_never_exceeds_the_input_cap(): # B-48
# 200 dust-ish UTXOs that together cover the target, but only past the cap.
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
with pytest.raises(InsufficientFundsError) as excinfo:
select_utxos(utxos, target_sats=100_000 * MAX_TX_INPUTS + 1)
assert excinfo.value.code == "too_many_inputs"
assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS}
def test_select_utxos_allows_exactly_the_input_cap():
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
selected, total = select_utxos(utxos, target_sats=100_000 * MAX_TX_INPUTS)
assert len(selected) == MAX_TX_INPUTS
assert total == 100_000 * MAX_TX_INPUTS
def test_select_utxos_honours_a_caller_supplied_cap(): # B-52
"""The cap is per-caller: MAX_TX_INPUTS protects a user from a fee eating into
their own bet/withdrawal, while the payout needs MAX_PAYOUT_TX_INPUTS to be able
to drain a pool holding one UTXO per bet at all."""
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(MAX_TX_INPUTS + 10)]
target = 100_000 * (MAX_TX_INPUTS + 10)
with pytest.raises(InsufficientFundsError):
select_utxos(utxos, target_sats=target) # default cap: too fragmented
selected, total = select_utxos(utxos, target_sats=target, max_inputs=MAX_PAYOUT_TX_INPUTS)
assert len(selected) == MAX_TX_INPUTS + 10
assert total == target
def test_select_utxos_still_caps_at_the_payout_limit(): # B-52
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(MAX_PAYOUT_TX_INPUTS + 5)]
with pytest.raises(InsufficientFundsError) as excinfo:
select_utxos(
utxos,
target_sats=100_000 * (MAX_PAYOUT_TX_INPUTS + 1),
max_inputs=MAX_PAYOUT_TX_INPUTS,
)
assert excinfo.value.code == "too_many_inputs"
assert excinfo.value.params == {"max_inputs": MAX_PAYOUT_TX_INPUTS}
def test_build_signed_transaction_deducts_fee_from_amount_not_change():
signer = _key(1)
from_script = script.p2wpkh(signer.to_public())
my_address = from_script.address(network=PLM_MAINNET)
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
utxos = [Utxo("11" * 32, 0, 150_000_000)]
built = 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,
)
fee = estimate_vsize(1, 2)
assert built.fee_sats == fee
assert built.recipient_sats == 10_000_000 - fee
# change reflects the full amount_sats deducted from the sender, fee comes out
# of what the recipient gets, not out of the sender's remaining balance
assert built.change_sats == 150_000_000 - 10_000_000
assert built.spent_utxos == utxos
assert len(built.txid) == 64
from embit.transaction import Transaction
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vin[0].witness.items) == 2
assert len(parsed.vout) == 2
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=[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 == 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) == 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():
signer = _key(5)
from_script = script.p2wpkh(signer.to_public())
my_address = from_script.address(network=PLM_MAINNET)
to_address = script.p2wpkh(_key(6).to_public()).address(network=PLM_MAINNET)
small_amount = 100 # smaller than the ~141 sat fee at 1 sat/vB for 1-in-2-out
assert small_amount < estimate_vsize(1, 2)
utxos = [Utxo("33" * 32, 0, small_amount)]
with pytest.raises(InsufficientFundsError):
build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=utxos,
to_address=to_address,
amount_sats=small_amount,
change_address=my_address,
fee_rate_sat_vb=1,
)
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. 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)
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,
reduce_amount_to_keep_change=True,
)
tx = Transaction.parse(bytes.fromhex(built.raw_hex))
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
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,
)
# --- B-98 (BET-24): selection leaves a change output whenever the coins allow it ---
def _built(utxos, amount, reduce=False):
signer = _key(7)
from_script = script.p2wpkh(signer.to_public())
return build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=utxos,
to_address=script.p2wpkh(_key(8).to_public()).address(network=PLM_MAINNET),
amount_sats=amount,
change_address=from_script.address(network=PLM_MAINNET),
fee_rate_sat_vb=1,
reduce_amount_to_keep_change=reduce,
)
def test_a_bet_takes_a_second_coin_when_the_first_covers_it_exactly():
"""BET-24: 10 + 1 PLM and a 10 PLM bet — both coins, 1 PLM change, no refusal."""
utxos = [Utxo("aa" * 32, 0, 1_000_000_000), Utxo("bb" * 32, 0, 100_000_000)]
built = _built(utxos, 1_000_000_000)
assert built.change_sats == 100_000_000
assert len(Transaction.parse(bytes.fromhex(built.raw_hex)).vin) == 2
def test_a_withdrawal_uses_the_second_coin_instead_of_being_reduced():
"""BET-24 / WDR-09: the reduction only happens when no combination leaves change."""
utxos = [Utxo("aa" * 32, 0, 1_000_000_000), Utxo("bb" * 32, 0, 100_000_000)]
built = _built(utxos, 1_000_000_000, reduce=True)
assert built.change_sats == 100_000_000
assert built.recipient_sats == 1_000_000_000 - built.fee_sats
def test_with_no_coin_to_spare_the_old_outcome_stands():
"""BET-24: a lone coin equal to the bet is still refused / the withdrawal still reduced."""
from app.wallet.psbt_builder import DUST_LIMIT_SATS
utxos = [Utxo("aa" * 32, 0, 1_000_000_000)]
with pytest.raises(InsufficientFundsError) as excinfo:
_built(utxos, 1_000_000_000)
assert excinfo.value.code == "balance_leaves_no_change"
assert _built(utxos, 1_000_000_000, reduce=True).change_sats == DUST_LIMIT_SATS
def test_the_change_target_never_pushes_past_the_input_cap():
"""BET-24 / BET-08: capped at MAX_TX_INPUTS, a total that covers the amount is kept."""
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
selected, total = select_utxos(utxos, 100_000 * MAX_TX_INPUTS, min_change_sats=294)
assert len(selected) == MAX_TX_INPUTS and total == 100_000 * MAX_TX_INPUTS