Cap participants per round and give the payout its own input limit (B-52)
The payout has to spend one pool UTXO per bet, so reusing MAX_TX_INPUTS (50) for it made any round past ~50 players unpayable: select_utxos raised too_many_inputs, the round stayed "paying_out" retrying every 60s forever, and since no new round may open while one is active, the whole lottery stopped with the pool stuck. The cap was being enforced on the payout side, i.e. discovered once the money was already committed and there was no way back. Two halves: - select_utxos takes the cap as a parameter. Bets and withdrawals keep MAX_TX_INPUTS = 50, which protects a user from a fee that eats into the amount they are moving; the payout uses MAX_PAYOUT_TX_INPUTS = 500, where that argument doesn't apply — 400 inputs at 1 sat/vB cost ~0.00027 PLM out of the winner's 70% share. What actually bounds it is relay policy: 500 inputs is ~34 kvB against the 100 kvB standardness limit, and signing that many measures ~0.4s, once per round, inside a background task. - place_bet refuses the 401st bet with a new round_full error (translated into all 7 languages), so "a round can always be paid out" is an invariant checked before any money moves. MAX_PARTICIPANTS_PER_ROUND sits below the input cap to leave the payout headroom for pool change from earlier rounds, and counts every participant row rather than only confirmed ones, since a failed bet frees a slot. A round already wedged past the old cap now pays out on the next retry tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+49
-14
@@ -33,14 +33,37 @@ DUST_LIMIT_SATS = 294
|
||||
# eating further and further into the sender's change with no limit.
|
||||
MAX_FEE_RATE_SAT_VB = 10_000
|
||||
|
||||
# Ceiling on how many UTXOs one transaction may spend (B-48). Every extra input costs
|
||||
# ~68 vbytes of fee, and that fee comes out of the amount being moved — so an address
|
||||
# fragmented into hundreds of small deposits would silently erode its own bet (shrinking
|
||||
# the user's share of the pool) or withdrawal, and past a few hundred inputs the tx also
|
||||
# stops being standard and gets refused at broadcast. Failing the build with a
|
||||
# translatable error is the honest outcome; consolidating the address is the way out.
|
||||
# Ceiling on how many UTXOs one *user* transaction (bet, withdrawal) may spend (B-48).
|
||||
# Every extra input costs ~68 vbytes of fee, and that fee comes out of the amount being
|
||||
# moved — so an address fragmented into hundreds of small deposits would silently erode
|
||||
# its own bet (shrinking the user's share of the pool) or withdrawal. Failing the build
|
||||
# with a translatable error is the honest outcome; consolidating the address is the way
|
||||
# out. This is a *user-protection* limit, which is why the payout gets its own, far
|
||||
# higher one below.
|
||||
MAX_TX_INPUTS = 50
|
||||
|
||||
# Ceiling on the payout's inputs (B-52). The payout is not a user spending their own
|
||||
# fragmented balance: it drains the pool, whose UTXO count is simply the number of bets
|
||||
# in the round, and its fee comes out of a 70% share of that whole pool. So the erosion
|
||||
# argument behind MAX_TX_INPUTS doesn't apply here — 400 inputs at 1 sat/vB cost ~27_300
|
||||
# sat, i.e. ~0.00027 PLM out of the winner's share — and reusing that limit was what made
|
||||
# any round past ~50 participants unpayable: select_utxos raised too_many_inputs, the
|
||||
# round stayed "paying_out" retrying forever, and since no new round may open while one
|
||||
# is active, the whole lottery stopped with the pool stuck (B-52).
|
||||
#
|
||||
# What actually bounds this is relay policy: a non-standard transaction is refused at
|
||||
# broadcast past 100 kvB, which at ~68 vbytes per input is ~1470 inputs. 500 stays at
|
||||
# roughly a third of that budget, and signing that many inputs costs ~0.4s of event loop
|
||||
# (measured), once per round, inside a background task.
|
||||
MAX_PAYOUT_TX_INPUTS = 500
|
||||
|
||||
# The most participants one round may hold (B-52). Enforced where the money is not yet
|
||||
# committed — app/bets/service.py refuses the bet — instead of being discovered at payout
|
||||
# time, when the bets are already in the pool and there is no way back. Deliberately below
|
||||
# MAX_PAYOUT_TX_INPUTS: the payout also has to be able to spend whatever change UTXOs
|
||||
# earlier rounds left in the pool, so the gap is the headroom for those.
|
||||
MAX_PARTICIPANTS_PER_ROUND = 400
|
||||
|
||||
|
||||
class InsufficientFundsError(Exception):
|
||||
"""`code` is the machine-readable identifier the API layer forwards to the
|
||||
@@ -80,24 +103,32 @@ def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
|
||||
return _TX_OVERHEAD_VBYTES + n_inputs * _P2WPKH_INPUT_VBYTES + n_outputs * _P2WPKH_OUTPUT_VBYTES
|
||||
|
||||
|
||||
def select_utxos(utxos: list[Utxo], target_sats: int) -> tuple[list[Utxo], int]:
|
||||
def select_utxos(
|
||||
utxos: list[Utxo], target_sats: int, max_inputs: int = MAX_TX_INPUTS
|
||||
) -> tuple[list[Utxo], int]:
|
||||
"""Greedily select UTXOs (largest first, to minimize input count) covering
|
||||
target_sats — the amount deducted from the sender's balance. The fee is paid
|
||||
out of target_sats (see build_signed_transaction), not added on top of it.
|
||||
|
||||
At most MAX_TX_INPUTS are ever selected (B-48): if the largest MAX_TX_INPUTS
|
||||
At most `max_inputs` are ever selected (B-48): if the largest `max_inputs`
|
||||
UTXOs don't cover the target, the balance is there but too fragmented to spend
|
||||
in one transaction, which is a different failure from having no funds at all
|
||||
and gets its own code."""
|
||||
and gets its own code.
|
||||
|
||||
The cap is a parameter, not the constant it used to be, because the two callers
|
||||
want different ones (B-52): MAX_TX_INPUTS protects a user from a fee that would
|
||||
eat into their own bet/withdrawal, while the payout drains a pool holding one
|
||||
UTXO per bet and needs MAX_PAYOUT_TX_INPUTS to be able to pay a full round at
|
||||
all."""
|
||||
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
|
||||
selected: list[Utxo] = []
|
||||
total = 0
|
||||
for utxo in ordered:
|
||||
if len(selected) == MAX_TX_INPUTS:
|
||||
if len(selected) == max_inputs:
|
||||
raise InsufficientFundsError(
|
||||
f"balance too fragmented: more than {MAX_TX_INPUTS} inputs would be needed",
|
||||
f"balance too fragmented: more than {max_inputs} inputs would be needed",
|
||||
code="too_many_inputs",
|
||||
max_inputs=MAX_TX_INPUTS,
|
||||
max_inputs=max_inputs,
|
||||
)
|
||||
selected.append(utxo)
|
||||
total += utxo.amount_sats
|
||||
@@ -203,9 +234,13 @@ def build_payout_transaction(
|
||||
absorbs the tx fee — the commission (fee_address) output is untouched.
|
||||
|
||||
As in build_signed_transaction, dust-sized pool change is left to the fee
|
||||
rather than creating an unrelayable output (B-06)."""
|
||||
rather than creating an unrelayable output (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)
|
||||
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
|
||||
winner_amount = winner_share_sats - fee
|
||||
if winner_amount < DUST_LIMIT_SATS:
|
||||
|
||||
Reference in New Issue
Block a user