Files
plm-lottery/tests/unit/test_payout_builder.py
T
davideandClaude Sonnet 5.5 daa85cf4f0 Give every payout a bump reserve output and refuse configs that cannot pay a round (B-79, RND-21, RND-23, RND-24, RND-27, RND-38, RND-40, DAT-73, API-31, API-36, TX-11, ADR-17, ADR-29)
The pool normally holds exactly the round's bet outputs and the payout spent them all for
exactly the pool amount, so it had no change output: bump_fee could never accelerate it,
and a payout stuck in a mempool (never abandoned while a server knows it) kept the round in
`paying_out` and the whole lottery stopped.

- build_payout_transaction always has three outputs: winner, fee address, and a bump reserve
  of max(DUST_LIMIT_SATS, estimate_vsize(inputs, 3) * 10 sat/vB) paid to the reserve address
  m/84'/746'/0'/1/0. The reserve comes out of the platform's 30%, the network fee still out
  of the winner's 70%. Input value beyond winner share + commission (only rounds opened
  before the per-round pool address) is folded into the reserve output if >= dust, left to
  the network fee otherwise. `change_address` is replaced by `reserve_address`.
- `rounds.bump_reserve_sats` (Alembic e5a91c3d7b26, plain add_column) is written with the
  payout intent before the broadcast; /admin/rounds returns it.
- bump_fee takes the increase from the reserve output for a payout
  (_find_reducible_output; a payout already in flight with a change to the pool still falls
  back to that), never from the winner or fee-address outputs.
- payout_config_problem / rounds_can_open (RND-40): a bet amount and fee rate under which a
  round of 1 or of MAX_PARTICIPANTS_PER_ROUND participants could not cover the network fee,
  the reserve and dust outputs opens no round (lottery_configured=false); PUT /admin/config
  refuses such a bet amount or fee rate with 422 config_not_payable, judged on the resulting
  values, since the payout reads the fee rate live.

Ambiguity resolved: the reserve output and any excess input are one output (RND-23 says one
output to the reserve address), and bump_reserve_sats records that output's initial value.
DAT-22's "abandon clears all amounts" stays with B-96.

Tests changed because the spec changed: the four test_payout_builder tests asserted the old
two/three-output shape with change to the pool and the fee address receiving the whole 30%;
the whitespace fee_address test now spells out the amounts rounds_can_open reads. New: reserve
sizing, payout with 1 and with 400 participants (inputs = outputs + fee to the satoshi),
exact commission/reserve/dust boundary, excess folded or left to the fee, a real payout bumped
out of its reserve with the other outputs byte-identical and bumped five times, the old
reserve-less shape still raising RbfError, the scheduler output addresses and recorded
amounts, payability boundaries found independently by bisection, no round / unconfigured flag
for an unpayable config, a round in progress surviving it, PUT /admin/config refusals and the
migration column. Mutation-checked: dropping the reserve lookup in bump_fee and the payability
check in rounds_can_open each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:55:51 +02:00

279 lines
12 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 (
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"