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 ( DUST_LIMIT_SATS, MAX_PARTICIPANTS_PER_ROUND, MAX_PAYOUT_TX_INPUTS, MAX_TX_INPUTS, InsufficientFundsError, Utxo, build_payout_transaction, estimate_vsize, payout_bump_reserve, ) 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, reserve_address=pool_address, fee_rate_sat_vb=1, ) fee = estimate_vsize(1, 3) reserve = payout_bump_reserve(1) # B-79 (RND-38): 172 vB * 10 sat/vB assert reserve == 1_720 assert built.fee_sats == fee assert built.winner_sats == winner_share - fee assert built.reserve_sats == reserve assert built.commission_sats == commission - reserve # the reserve comes out of the platform's share assert built.change_sats == 0 parsed = Transaction.parse(bytes.fromhex(built.raw_hex)) assert len(parsed.vout) == 3 # winner, fee address, reserve — always, B-79 assert [o.value for o in parsed.vout] == [built.winner_sats, built.commission_sats, built.reserve_sats] assert [o.script_pubkey.address(network=PLM_MAINNET) for o in parsed.vout] == [ winner_address, fee_address, pool_address, # stands in for the reserve address here ] # nothing is created or lost: the inputs are exactly the outputs plus the network fee assert sum(o.value for o in parsed.vout) + built.fee_sats == pool_amount def test_payout_folds_excess_input_value_into_the_reserve_output(): """Only a round opened before the per-round pool address can have inputs beyond winner share + commission (the shared pool held other money). The excess goes to the reserve address, in the same single reserve output (RND-23), not into a fourth output.""" 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, reserve_address=pool_address, fee_rate_sat_vb=1, ) excess = 2_000_000_000 - (winner_share + commission) assert built.change_sats == excess assert built.reserve_sats == payout_bump_reserve(1) + excess assert built.commission_sats == commission - payout_bump_reserve(1) parsed = Transaction.parse(bytes.fromhex(built.raw_hex)) assert len(parsed.vout) == 3 assert sum(o.value for o in parsed.vout) + built.fee_sats == 2_000_000_000 def test_payout_leaves_a_dust_sized_excess_to_the_network_fee(): pool_key = _key(23) pool_script = script.p2wpkh(pool_key.to_public()) pool_address = pool_script.address(network=PLM_MAINNET) winner_address = script.p2wpkh(_key(24).to_public()).address(network=PLM_MAINNET) fee_address = script.p2wpkh(_key(25).to_public()).address(network=PLM_MAINNET) excess = DUST_LIMIT_SATS - 1 built = build_payout_transaction( signing_key=pool_key, from_script=pool_script, utxos=[Utxo("bc" * 32, 0, 1_000_000_000 + excess)], winner_address=winner_address, winner_share_sats=700_000_000, fee_address=fee_address, commission_sats=300_000_000, reserve_address=pool_address, fee_rate_sat_vb=1, ) assert built.change_sats == 0 assert built.reserve_sats == payout_bump_reserve(1) assert built.fee_sats == estimate_vsize(1, 3) + excess 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, reserve_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.reserve_sats == payout_bump_reserve(participants) assert built.commission_sats == commission - built.reserve_sats # still untouched by the network 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, reserve_address=pool_address, fee_rate_sat_vb=1, ) def _addresses(seed: int): pool_key = _key(seed) pool_script = script.p2wpkh(pool_key.to_public()) return ( pool_key, pool_script, script.p2wpkh(_key(seed + 1).to_public()).address(network=PLM_MAINNET), script.p2wpkh(_key(seed + 2).to_public()).address(network=PLM_MAINNET), script.p2wpkh(_key(seed + 3).to_public()).address(network=PLM_MAINNET), ) def test_the_reserve_is_sized_on_the_inputs_and_never_below_dust(): # B-79, RND-38 assert payout_bump_reserve(1) == estimate_vsize(1, 3) * 10 assert payout_bump_reserve(MAX_PARTICIPANTS_PER_ROUND) == estimate_vsize(MAX_PARTICIPANTS_PER_ROUND, 3) * 10 assert payout_bump_reserve(MAX_PARTICIPANTS_PER_ROUND) > payout_bump_reserve(1) assert payout_bump_reserve(0) == max(DUST_LIMIT_SATS, estimate_vsize(0, 3) * 10) def test_payout_at_the_participant_cap_pays_the_reserve_out_of_the_platform_share(): # B-79, RND-38, RND-40 """400 bets of the smallest net amount the default config allows: the inputs are exactly the outputs plus the network fee, the winner's share carries the network fee, and the platform's 30% carries the (much larger) reserve.""" pool_key, pool_script, winner_address, fee_address, reserve_address = _addresses(60) bet = 1_000_000_000 - estimate_vsize(MAX_TX_INPUTS, 2) utxos = [Utxo(f"{i:064x}", 0, bet) for i in range(MAX_PARTICIPANTS_PER_ROUND)] pool = bet * MAX_PARTICIPANTS_PER_ROUND winner_share = pool * 70 // 100 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=pool - winner_share, reserve_address=reserve_address, fee_rate_sat_vb=1, ) n = MAX_PARTICIPANTS_PER_ROUND assert built.reserve_sats == estimate_vsize(n, 3) * 10 == 273_040 assert built.fee_sats == estimate_vsize(n, 3) assert built.winner_sats == winner_share - built.fee_sats assert built.commission_sats == pool - winner_share - built.reserve_sats parsed = Transaction.parse(bytes.fromhex(built.raw_hex)) assert len(parsed.vin) == n and len(parsed.vout) == 3 assert sum(o.value for o in parsed.vout) + built.fee_sats == pool assert all(o.value >= DUST_LIMIT_SATS for o in parsed.vout) def test_payout_with_one_participant_at_the_smallest_viable_pool(): # B-79, RND-40 """The edge RND-40 guards: with one bet the platform's share is the smallest, and it has to cover the reserve and still leave the fee address at least dust. The exact boundary builds; one satoshi less is refused with commission_below_dust_limit.""" pool_key, pool_script, winner_address, fee_address, reserve_address = _addresses(70) reserve = payout_bump_reserve(1) commission = reserve + DUST_LIMIT_SATS # the fee address gets exactly the dust limit winner_share = 10_000 def build(commission_sats: int): return build_payout_transaction( signing_key=pool_key, from_script=pool_script, utxos=[Utxo("ad" * 32, 0, winner_share + commission_sats)], winner_address=winner_address, winner_share_sats=winner_share, fee_address=fee_address, commission_sats=commission_sats, reserve_address=reserve_address, fee_rate_sat_vb=1, ) built = build(commission) assert built.commission_sats == DUST_LIMIT_SATS and built.reserve_sats == reserve with pytest.raises(InsufficientFundsError) as refused: build(commission - 1) assert refused.value.code == "commission_below_dust_limit"