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

+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,
)