2026-07-21 10:25:29 +02:00
|
|
|
import pytest
|
|
|
|
|
from embit import script
|
|
|
|
|
from embit.bip32 import HDKey
|
2026-07-27 00:31:41 +02:00
|
|
|
from embit.transaction import Transaction
|
2026-07-21 10:25:29 +02:00
|
|
|
|
|
|
|
|
from app.wallet.plm_network import PLM_MAINNET
|
|
|
|
|
from app.wallet.psbt_builder import (
|
|
|
|
|
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_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_omits_change_output_when_exact_amount():
|
|
|
|
|
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
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
assert built.change_sats == 0
|
|
|
|
|
|
|
|
|
|
from embit.transaction import Transaction
|
|
|
|
|
|
|
|
|
|
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
|
|
|
|
|
assert len(parsed.vout) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
2026-07-27 00:31:41 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|