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_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, )