Files
plm-lottery/tests/unit/test_payout_builder.py
T

158 lines
6.2 KiB
Python
Raw Normal View History

2026-07-21 10:25:29 +02:00
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_PARTICIPANTS_PER_ROUND,
MAX_PAYOUT_TX_INPUTS,
MAX_TX_INPUTS,
InsufficientFundsError,
Utxo,
build_payout_transaction,
estimate_vsize,
)
2026-07-21 10:25:29 +02:00
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_payout_deducts_fee_only_from_winner_share():
pool_key = _key(10)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
winner_address = script.p2wpkh(_key(11).to_public()).address(network=PLM_MAINNET)
fee_address = script.p2wpkh(_key(12).to_public()).address(network=PLM_MAINNET)
pool_amount = 10_000_000_000 # 100 PLM pot
winner_share = pool_amount * 70 // 100
commission = pool_amount - winner_share
utxos = [Utxo("aa" * 32, 0, pool_amount)]
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
fee_rate_sat_vb=1,
)
fee = estimate_vsize(1, 3)
assert built.fee_sats == fee
assert built.winner_sats == winner_share - fee
assert built.commission_sats == commission # untouched by the fee
assert built.change_sats == pool_amount - (winner_share + commission)
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vout) == 2 # no change needed: winner_share + commission == pool_amount exactly
amounts = sorted(o.value for o in parsed.vout)
assert amounts == sorted([built.winner_sats, built.commission_sats])
def test_payout_adds_change_output_when_pool_utxos_exceed_target():
pool_key = _key(20)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
winner_address = script.p2wpkh(_key(21).to_public()).address(network=PLM_MAINNET)
fee_address = script.p2wpkh(_key(22).to_public()).address(network=PLM_MAINNET)
winner_share = 700_000_000
commission = 300_000_000
utxos = [Utxo("bb" * 32, 0, 2_000_000_000)] # more than winner_share+commission
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
fee_rate_sat_vb=1,
)
assert built.change_sats == 2_000_000_000 - (winner_share + commission)
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vout) == 3
def test_payout_spends_more_utxos_than_a_user_transaction_may(): # B-52
"""The pool holds one UTXO per bet, so a round with more participants than
MAX_TX_INPUTS used to be impossible to pay out: select_utxos raised
too_many_inputs, the round stayed "paying_out" retrying every 60s forever, and
since no new round may open while one is active, the lottery stopped for good.
The payout gets its own, far higher cap for exactly this reason."""
pool_key = _key(40)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
winner_address = script.p2wpkh(_key(41).to_public()).address(network=PLM_MAINNET)
fee_address = script.p2wpkh(_key(42).to_public()).address(network=PLM_MAINNET)
# One 10 PLM bet per participant, one UTXO each, just past the user-facing cap.
participants = MAX_TX_INPUTS + 1
bet_sats = 1_000_000_000
pool_amount = bet_sats * participants
winner_share = pool_amount * 70 // 100
commission = pool_amount - winner_share
utxos = [Utxo(f"{i:064x}", 0, bet_sats) for i in range(participants)]
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
fee_rate_sat_vb=1,
)
assert len(built.spent_utxos) == participants # every bet had to be spent
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vin) == participants
assert built.fee_sats == estimate_vsize(participants, 3)
assert built.winner_sats == winner_share - built.fee_sats
assert built.commission_sats == commission # still untouched by the fee
def test_payout_at_the_participant_cap_stays_well_inside_relay_limits(): # B-52
"""MAX_PARTICIPANTS_PER_ROUND is only safe if the payout it implies is still a
standard transaction. A full round is one input per bet plus the pool's own
change, and relay policy refuses anything past 100 kvB."""
inputs_needed = MAX_PARTICIPANTS_PER_ROUND + 1 # + one accumulated pool change UTXO
assert inputs_needed <= MAX_PAYOUT_TX_INPUTS # headroom for pool change exists
assert estimate_vsize(MAX_PAYOUT_TX_INPUTS, 3) < 100_000
2026-07-21 10:25:29 +02:00
def test_payout_raises_when_winner_share_too_small():
pool_key = _key(30)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
winner_address = script.p2wpkh(_key(31).to_public()).address(network=PLM_MAINNET)
fee_address = script.p2wpkh(_key(32).to_public()).address(network=PLM_MAINNET)
winner_share = 100 # smaller than the ~172 sat fee at 1 sat/vB for 1-in-3-out
commission = 50
assert winner_share < estimate_vsize(1, 3)
utxos = [Utxo("cc" * 32, 0, winner_share + commission)]
with pytest.raises(InsufficientFundsError):
build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
fee_rate_sat_vb=1,
)