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
|
2026-08-03 16:21:33 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 16:21:33 +02:00
|
|
|
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,
|
|
|
|
|
)
|