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