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:
1 parent
473d541ab5
commit
daa85cf4f0
27 files changed
+684
-139
No files matched your search
@@ -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"
|
||||
Reference in new issue
Block a user