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

+19
View File
@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error
from app.api.timeutil import isoformat_utc
from app.audit.log import write_audit_log
from app.auth.security import hash_password_async
@@ -14,6 +15,7 @@ from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.rounds.pause import AUTOMATIC_REASONS, OPERATOR_REASON
from app.rounds.service import payout_config_problem
from app.wallet.address import is_valid_plm_address
from app.wallet.hd import derive_user_wif
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB
@@ -128,6 +130,21 @@ async def update_config(
changes[field] = {"from": previous, "to": value}
setattr(config, field, value)
# B-79 (RND-40): a bet amount or fee rate under which a round could not be paid out is
# refused before anything is written — the payout reads fee_rate_sat_vb at build time, so
# a rate raised mid-round could otherwise make the round in progress unpayable.
if "bet_amount_sats" in changes or "fee_rate_sat_vb" in changes:
problem = payout_config_problem(config.bet_amount_sats, config.fee_rate_sat_vb)
if problem is not None:
await session.rollback()
raise http_error(
422,
"config_not_payable",
"this bet amount and fee rate would make a round impossible to pay out "
f"({problem}); a larger bet amount or a lower fee rate is needed",
reason=problem,
)
if changes:
await write_audit_log(session, "config_updated", changes)
await session.commit()
@@ -269,6 +286,7 @@ class AdminRoundResponse(BaseModel):
pool_address: str | None # B-80
server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret
draw_height: int | None # B-73
bump_reserve_sats: int | None # B-79 (API-36)
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
@@ -299,6 +317,7 @@ async def list_rounds(
pool_address=r.pool_address or None,
server_seed_hash=r.server_seed_hash,
draw_height=r.draw_height,
bump_reserve_sats=r.bump_reserve_sats,
)
for r in rounds
]
+3
View File
@@ -110,6 +110,9 @@ class Round(Base):
pool_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
winner_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
fee_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
# B-79 (DAT-73): the value the payout's bump reserve output was created with (it is paid to
# the reserve address and is what an RBF bump shrinks, so this is never rewritten by a bump).
bump_reserve_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
# B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's
# 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with
+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:
+15 -2
View File
@@ -23,7 +23,7 @@ from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.tx.versions import record_version
from app.wallet.hd import derive_round_pool_key, derive_user_key
from app.wallet.hd import derive_pool_address, derive_round_pool_key, derive_user_key
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
@@ -102,6 +102,19 @@ def _find_change_output(tx: Transaction, change_address: str) -> int | None:
return None
def _find_reducible_output(tx: Transaction, kind: str, own_address: str) -> int | None:
"""The one output an RBF bump takes the extra fee from (TX-11): the sender's own change
for a bet or a withdrawal; for a payout the **bump reserve** output paid to the reserve
address (B-79, RND-38), falling back to a change output back to the pool for a payout
that was already in flight before the reserve existed. Counterparty outputs — the
winner's, the fee address's, an external recipient's — are never candidates."""
if kind == "payout":
reserve_index = _find_change_output(tx, derive_pool_address())
if reserve_index is not None:
return reserve_index
return _find_change_output(tx, own_address)
async def bump_fee(
session_factory: async_sessionmaker,
client: ElectrumClient,
@@ -179,7 +192,7 @@ async def bump_fee(
min_valid_delta = vsize * _INCREMENTAL_RELAY_FEE_RATE_SAT_VB
fee_delta = max(target_fee - old_fee, min_valid_delta)
change_index = _find_change_output(old_tx, own_address)
change_index = _find_reducible_output(old_tx, kind, own_address)
if change_index is None or old_tx.vout[change_index].value <= fee_delta:
raise RbfError(f"pending_transaction {pending_id}: no change output large enough to absorb a fee bump")
+48 -20
View File
@@ -64,6 +64,22 @@ MAX_PAYOUT_TX_INPUTS = 500
# earlier rounds left in the pool, so the gap is the headroom for those.
MAX_PARTICIPANTS_PER_ROUND = 400
# B-79 (RND-38, DAT-73): every payout carries a bump reserve output, taken from the
# platform's share and paid to the reserve address (m/84'/746'/0'/1/0). It is what an RBF
# bump reduces: the pool normally holds exactly the round's bet outputs and the payout
# spends them all, so without it the payout has no output to shrink, can never be bumped,
# and one stuck in a mempool (which the reconciler never abandons while a server knows it)
# kept the round in `paying_out` and the lottery stopped. 10 sat/vB is the rate the reserve
# is sized to absorb, on top of the rate the payout was built at.
PAYOUT_BUMP_RESERVE_SAT_VB = 10
def payout_bump_reserve(n_inputs: int) -> int:
"""The reserve output of a payout spending `n_inputs` coins (RND-38): enough to raise the
fee of its three-output transaction to `PAYOUT_BUMP_RESERVE_SAT_VB`, and never below the
dust limit (a smaller output would not be relayed)."""
return max(DUST_LIMIT_SATS, estimate_vsize(n_inputs, 3) * PAYOUT_BUMP_RESERVE_SAT_VB)
class InsufficientFundsError(Exception):
"""`code` is the machine-readable identifier the API layer forwards to the
@@ -248,8 +264,9 @@ class PayoutTransaction:
txid: str
fee_sats: int
winner_sats: int
commission_sats: int
change_sats: int
commission_sats: int # what the fee address receives: the platform share minus the reserve
reserve_sats: int # B-79: the bump reserve output (plus any excess input value, see below)
change_sats: int # inputs beyond winner share + commission, folded into the reserve output
spent_utxos: list[Utxo]
@@ -262,43 +279,53 @@ def build_payout_transaction(
winner_share_sats: int,
fee_address: str,
commission_sats: int,
change_address: str,
reserve_address: str,
fee_rate_sat_vb: int,
) -> PayoutTransaction:
"""Build, sign and finalize the round payout: pool -> winner + fee address,
with change back to the pool itself. Per spec, only the winner's share
absorbs the tx fee — the commission (fee_address) output is untouched.
"""Build, sign and finalize the round payout: pool -> winner + fee address + reserve.
As in build_signed_transaction, dust-sized pool change is left to the fee
rather than creating an unrelayable output (B-06).
B-79 (RND-21, RND-23, RND-38): three outputs, always. `commission_sats` is the platform's
whole share (30% plus the rounding remainder); the bump reserve (`payout_bump_reserve`)
is taken out of it and paid to `reserve_address`, the output an RBF bump will shrink
(TX-11), and the fee address receives the rest. Per spec, only the winner's share
absorbs the network fee — never the platform's.
Inputs beyond winner share + commission exist only for rounds opened before the
per-round pool address (the shared pool held other money): that excess is folded into
the reserve output if it is at least the dust limit, and left to the network fee
otherwise, as in build_signed_transaction (B-06).
Selection uses MAX_PAYOUT_TX_INPUTS, not the much stricter user-facing
MAX_TX_INPUTS (B-52) — the pool holds one UTXO per bet, so the user-protection
cap made every round past ~50 participants impossible to pay."""
target = winner_share_sats + commission_sats
selected, total_in = select_utxos(utxos, target, max_inputs=MAX_PAYOUT_TX_INPUTS)
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + fee address + reserve
winner_amount = winner_share_sats - fee
if winner_amount < DUST_LIMIT_SATS:
raise InsufficientFundsError(
"winner share too small to cover the network fee", code="winner_share_below_network_fee"
)
if commission_sats < DUST_LIMIT_SATS:
reserve = payout_bump_reserve(len(selected))
fee_address_amount = commission_sats - reserve
if fee_address_amount < DUST_LIMIT_SATS:
raise InsufficientFundsError(
"commission share too small to be paid out (dust)", code="commission_below_dust_limit"
"commission share too small to cover the bump reserve and still be paid out (dust)",
code="commission_below_dust_limit",
)
change = total_in - target
if change < DUST_LIMIT_SATS:
fee += change
change = 0
excess = total_in - target
if excess >= DUST_LIMIT_SATS:
reserve += excess
else:
fee += excess
excess = 0
vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
vout = [
TransactionOutput(winner_amount, script.Script.from_address(winner_address)),
TransactionOutput(commission_sats, script.Script.from_address(fee_address)),
TransactionOutput(fee_address_amount, script.Script.from_address(fee_address)),
TransactionOutput(reserve, script.Script.from_address(reserve_address)),
]
if change > 0:
vout.append(TransactionOutput(change, script.Script.from_address(change_address)))
tx = Transaction(vin=vin, vout=vout)
psbt = PSBT(tx)
@@ -319,7 +346,8 @@ def build_payout_transaction(
txid=final_tx.txid().hex(),
fee_sats=fee,
winner_sats=winner_amount,
commission_sats=commission_sats,
change_sats=change,
commission_sats=fee_address_amount,
reserve_sats=reserve,
change_sats=excess,
spent_utxos=selected,
)