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

+3 -2
View File
@@ -25,7 +25,7 @@ from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
from app.tx.versions import record_version
from app.wallet.hd import derive_round_pool_key
from app.wallet.hd import derive_pool_address, derive_round_pool_key
from app.wallet.keystore import decrypt_round_seed, encrypt_round_seed
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
@@ -543,7 +543,7 @@ class RoundScheduler:
winner_share_sats=winner_sats,
fee_address=fee_address,
commission_sats=commission_share,
change_address=pool_address,
reserve_address=derive_pool_address(), # B-79 (RND-38): the bump reserve's destination
fee_rate_sat_vb=fee_rate,
)
except InsufficientFundsError as exc:
@@ -579,6 +579,7 @@ class RoundScheduler:
round_ = await session.get(Round, round_id)
round_.winner_amount_sats = built.winner_sats
round_.fee_amount_sats = built.commission_sats
round_.bump_reserve_sats = built.reserve_sats # B-79 (DAT-73, DAT-22)
round_.payout_txid = built.txid
pending = PendingTransaction(
kind="payout",
+39 -7
View File
@@ -11,6 +11,13 @@ from app.rounds.events import broadcaster
from app.rounds.draw import new_server_seed, server_seed_commitment
from app.wallet.hd import derive_pool_address
from app.wallet.keystore import encrypt_round_seed
from app.wallet.psbt_builder import (
DUST_LIMIT_SATS,
MAX_PARTICIPANTS_PER_ROUND,
MAX_TX_INPUTS,
estimate_vsize,
payout_bump_reserve,
)
logger = logging.getLogger(__name__)
@@ -81,19 +88,44 @@ def round_accepts_bets(round_: Round) -> bool:
return datetime.now(timezone.utc) < round_deadline(round_)
def rounds_can_open(config: RoundConfig) -> bool:
"""Whether the instance is configured well enough to run a round at all (B-66).
def payout_config_problem(bet_amount_sats: int, fee_rate_sat_vb: int) -> str | None:
"""Why a round could not be paid out under this bet amount and fee rate, or None (B-79,
RND-40). Checked for the two extremes — 1 participant (the smallest platform share) and
MAX_PARTICIPANTS_PER_ROUND (the largest bump reserve, which grows with the inputs):
the winner's 70% must cover the network fee of the payout and still be at least dust, and
the platform's 30% must cover the bump reserve and still leave the fee address at least
dust. The smallest bet that can reach the pool is the amount minus the fee of the biggest
transaction a user may build (MAX_TX_INPUTS inputs, 2 outputs), so that is what every
participant is assumed to have contributed."""
net_bet = bet_amount_sats - estimate_vsize(MAX_TX_INPUTS, 2) * fee_rate_sat_vb
for participants in (1, MAX_PARTICIPANTS_PER_ROUND):
pool = participants * net_bet
share = winner_share(pool)
if share - estimate_vsize(participants, 3) * fee_rate_sat_vb < DUST_LIMIT_SATS:
return "winner_share_below_network_fee"
if pool - share - payout_bump_reserve(participants) < DUST_LIMIT_SATS:
return "commission_below_dust_limit"
return None
Only fee_address today, and only because a round without one is unpayable: the
payout pays the 30% commission to it, so build_payout_transaction cannot even be
built. It has no column default for exactly this reason (rounds/config.py) — an
operator must set their own, and until they do there is nothing to guess.
def rounds_can_open(config: RoundConfig) -> bool:
"""Whether the instance is configured well enough to run a round at all (B-66, B-79).
Two prerequisites, both because a round without them is unpayable: a fee_address — the
payout pays the platform's share to it, so build_payout_transaction cannot even be built,
and it has no column default (rounds/config.py) because an operator must set their own —
and a bet amount and fee rate under which a round of 1 or of
MAX_PARTICIPANTS_PER_ROUND participants can cover the payout's fee, the bump reserve and
dust outputs (payout_config_problem, RND-40). PUT /admin/config refuses the same
combinations, so this only trips on a configuration that predates the check.
Anything else that would make a round unpayable belongs here too, next to it,
rather than being discovered at payout time. Deliberately not about *pausing*,
which is a decision an operator took (RoundConfig.paused) rather than a
prerequisite they haven't met yet."""
return bool(config.fee_address.strip())
if not config.fee_address.strip():
return False
return payout_config_problem(config.bet_amount_sats, config.fee_rate_sat_vb) is None
async def open_new_round_if_needed(session: AsyncSession) -> Round | None: