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>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-16 13:55:51 +02:00
1 parent 473d541ab5
commit daa85cf4f0
27 files changed
+684 -139

No files matched your search

+133 -12
View File
@@ -5,6 +5,7 @@ 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,
@@ -12,6 +13,7 @@ from app.wallet.psbt_builder import (
Utxo,
build_payout_transaction,
estimate_vsize,
payout_bump_reserve,
)
@@ -40,23 +42,35 @@ def test_payout_deducts_fee_only_from_winner_share():
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
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.commission_sats == commission # untouched by the fee
assert built.change_sats == pool_amount - (winner_share + commission)
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) == 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])
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_adds_change_output_when_pool_utxos_exceed_target():
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)
@@ -74,13 +88,40 @@ def test_payout_adds_change_output_when_pool_utxos_exceed_target():
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
reserve_address=pool_address,
fee_rate_sat_vb=1,
)
assert built.change_sats == 2_000_000_000 - (winner_share + commission)
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
@@ -111,7 +152,7 @@ def test_payout_spends_more_utxos_than_a_user_transaction_may(): # B-52
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
reserve_address=pool_address,
fee_rate_sat_vb=1,
)
@@ -120,7 +161,8 @@ def test_payout_spends_more_utxos_than_a_user_transaction_may(): # B-52
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
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
@@ -152,6 +194,85 @@ def test_payout_raises_when_winner_share_too_small():
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
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"