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
@@ -165,6 +165,40 @@ async def test_trigger_payout_persists_before_broadcasting(payout_session_factor
|
||||
assert "payout_sent" in events
|
||||
|
||||
|
||||
async def test_trigger_payout_always_carries_a_bump_reserve_to_the_reserve_address(payout_session_factory):
|
||||
"""B-79 (RND-23, RND-24, RND-38, DAT-22, DAT-73): the pool holds exactly the round's bets
|
||||
and the payout spends them all, yet the transaction has three outputs — winner, fee
|
||||
address, reserve at m/84'/746'/0'/1/0 — and the round records the reserve before the
|
||||
broadcast. Nothing is created or lost to the satoshi."""
|
||||
from app.wallet.psbt_builder import estimate_vsize, payout_bump_reserve
|
||||
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
entries = [{"tx_hash": "34" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS}] # no excess
|
||||
client = FakePayoutClient(entries)
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
async with payout_session_factory() as session:
|
||||
round_ = await session.get(Round, 1)
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex))
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
|
||||
addresses = [o.script_pubkey.address(network=PLM_MAINNET) for o in tx.vout]
|
||||
assert addresses == [_WINNER_ADDRESS, _FEE_ADDRESS, derive_pool_address()] # reserve = 1/0
|
||||
reserve = payout_bump_reserve(1)
|
||||
network_fee = estimate_vsize(1, 3)
|
||||
winner_share = _POOL_AMOUNT_SATS * 70 // 100
|
||||
assert round_.bump_reserve_sats == reserve == tx.vout[2].value
|
||||
assert round_.winner_amount_sats == winner_share - network_fee == tx.vout[0].value
|
||||
assert round_.fee_amount_sats == _POOL_AMOUNT_SATS - winner_share - reserve == tx.vout[1].value
|
||||
assert (
|
||||
round_.winner_amount_sats + round_.fee_amount_sats + round_.bump_reserve_sats + network_fee
|
||||
== _POOL_AMOUNT_SATS
|
||||
)
|
||||
|
||||
|
||||
async def test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_inputs(
|
||||
payout_session_factory,
|
||||
): # B-52
|
||||
|
||||
Reference in new issue
Block a user