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
+48
-20
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user