The payout has to spend one pool UTXO per bet, so reusing MAX_TX_INPUTS (50) for it made any round past ~50 players unpayable: 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 whole lottery stopped with the pool stuck. The cap was being enforced on the payout side, i.e. discovered once the money was already committed and there was no way back. Two halves: - select_utxos takes the cap as a parameter. Bets and withdrawals keep MAX_TX_INPUTS = 50, which protects a user from a fee that eats into the amount they are moving; the payout uses MAX_PAYOUT_TX_INPUTS = 500, where that argument doesn't apply — 400 inputs at 1 sat/vB cost ~0.00027 PLM out of the winner's 70% share. What actually bounds it is relay policy: 500 inputs is ~34 kvB against the 100 kvB standardness limit, and signing that many measures ~0.4s, once per round, inside a background task. - place_bet refuses the 401st bet with a new round_full error (translated into all 7 languages), so "a round can always be paid out" is an invariant checked before any money moves. MAX_PARTICIPANTS_PER_ROUND sits below the input cap to leave the payout headroom for pool change from earlier rounds, and counts every participant row rather than only confirmed ones, since a failed bet frees a slot. A round already wedged past the old cap now pays out on the next retry tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
158 lines
6.2 KiB
Python
158 lines
6.2 KiB
Python
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,
|
|
)
|