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
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user