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

+82 -3
View File
@@ -236,9 +236,88 @@ async def test_a_round_in_progress_survives_the_fee_address_being_cleared(sessio
def test_rounds_can_open_ignores_a_whitespace_only_fee_address(): # B-66
from app.rounds.service import rounds_can_open
assert rounds_can_open(RoundConfig(fee_address=_FEE_ADDRESS)) is True
assert rounds_can_open(RoundConfig(fee_address="")) is False
assert rounds_can_open(RoundConfig(fee_address=" ")) is False
# B-79: the payability check also reads the amounts, which are column defaults only once
# a row is flushed — so a bare object spells them out.
amounts = {"bet_amount_sats": 1_000_000_000, "fee_rate_sat_vb": 1}
assert rounds_can_open(RoundConfig(fee_address=_FEE_ADDRESS, **amounts)) is True
assert rounds_can_open(RoundConfig(fee_address="", **amounts)) is False
assert rounds_can_open(RoundConfig(fee_address=" ", **amounts)) is False
# --- B-79 (RND-40): a configuration under which a round cannot be paid opens no round ---
def test_payout_config_problem_at_both_extremes():
"""The default config is payable; the boundary bet amounts are found by the same formula
as the spec: net = bet - fee(50 inputs, 2 outputs) at the rate, checked for 1 participant
and for MAX_PARTICIPANTS_PER_ROUND."""
from app.rounds.service import payout_config_problem, winner_share
from app.wallet.psbt_builder import (
DUST_LIMIT_SATS,
MAX_PARTICIPANTS_PER_ROUND,
MAX_TX_INPUTS,
estimate_vsize,
payout_bump_reserve,
)
assert payout_config_problem(1_000_000_000, 1) is None
def feasible(bet: int, rate: int, n: int) -> bool:
pool = n * (bet - estimate_vsize(MAX_TX_INPUTS, 2) * rate)
share = winner_share(pool)
return (
share - estimate_vsize(n, 3) * rate >= DUST_LIMIT_SATS
and pool - share - payout_bump_reserve(n) >= DUST_LIMIT_SATS
)
# binary-search the smallest viable bet at 1 sat/vB for each extreme, independently of
# the function under test, then check it is exactly where payout_config_problem flips
def smallest(n: int, rate: int) -> int:
lo, hi = 1, 10_000_000
while lo < hi:
mid = (lo + hi) // 2
lo, hi = (lo, mid) if feasible(mid, rate, n) else (mid + 1, hi)
return lo
for rate in (1, 10):
edge = max(smallest(1, rate), smallest(MAX_PARTICIPANTS_PER_ROUND, rate))
assert payout_config_problem(edge, rate) is None
assert payout_config_problem(edge - 1, rate) is not None
# each extreme can be the one that fails: one participant is bounded by the platform share
# (reserve + dust), the maximum by the reserve that grows with the inputs
assert smallest(1, 1) != smallest(MAX_PARTICIPANTS_PER_ROUND, 1)
assert payout_config_problem(1, 1) is not None # a bet that does not even pay its own fee
async def test_no_round_opens_while_the_config_could_not_pay_one(session_factory):
async with session_factory() as session:
config = (await session.scalars(select(RoundConfig))).one()
config.bet_amount_sats = 5_000 # fee address set, but the platform's 30% cannot cover the reserve
await session.commit()
async with session_factory() as session:
assert await open_new_round_if_needed(session) is None
async with session_factory() as session:
assert (await session.scalars(select(Round))).all() == []
async with session_factory() as session:
(await session.scalars(select(RoundConfig))).one().bet_amount_sats = 1_000_000_000
await session.commit()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
assert round_ is not None and round_.status == "open"
async def test_a_round_in_progress_survives_an_unpayable_config(session_factory):
"""Same scope as every other prerequisite: it only stops the *next* round."""
async with session_factory() as session:
session.add(Round(status="open"))
(await session.scalars(select(RoundConfig))).one().fee_rate_sat_vb = 5_000
await session.commit()
async with session_factory() as session:
returned = await open_new_round_if_needed(session)
assert returned is not None and returned.status == "open"
# --- B-61: a round runs by the timing it opened with, not by the live config ------