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