Files
plm-lottery/app/wallet/psbt_builder.py
T
davideandClaude Sonnet 5.5 daa85cf4f0 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>
2026-10-16 13:55:51 +02:00

354 lines
16 KiB
Python

from dataclasses import dataclass
from embit import script
from embit.bip32 import HDKey
from embit.finalizer import finalize_psbt
from embit.psbt import PSBT
from embit.transaction import Transaction, TransactionInput, TransactionOutput
# Standard P2WPKH size estimates (vbytes): 10.5-byte overhead (version+counts+locktime+
# segwit marker/flag), ~68 vbytes per input, ~31 vbytes per output. Used to size the fee
# before signing (fee only needs to be "minimized ~1 sat/vB", not maximally precise).
_TX_OVERHEAD_VBYTES = 11
_P2WPKH_INPUT_VBYTES = 68
_P2WPKH_OUTPUT_VBYTES = 31
# BIP125 opt-in RBF: any sequence < 0xfffffffe signals replaceability. Set on every
# input we create so a stuck tx can later be fee-bumped (tx/broadcast.py, stage 9).
RBF_SEQUENCE = 0xFFFFFFFD
# Below this, an output costs more to spend than it's worth and relay policy rejects
# the whole transaction as "dust" — so a small change amount must be left to the fee
# instead of being paid back to ourselves. 294 sat is the standard P2WPKH threshold
# (the output's own 31 vbytes plus the 67-vbyte input needed to spend it, at the
# 3000 sat/kvB dust relay fee). Creating such an output used to make the bet or
# withdrawal fail at broadcast with an opaque error (B-06).
DUST_LIMIT_SATS = 294
# Sanity ceiling on any transaction's fee rate — shared by RoundConfig.fee_rate_sat_vb's
# admin-facing bound (app/api/routes/admin.py, so the two can't drift apart, the same
# reason MIN_PASSWORD_LENGTH is shared in auth/security.py) and tx/broadcast.py's RBF
# bump escalation, which refuses to bump a pending_transaction past this rate (B-32) —
# without a ceiling, a stuck transaction's fee climbed by 1 sat/vB every bump forever,
# 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 *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
# 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
client so it can translate the failure (see app/api/errors.py); the message
itself stays English, and `params` carries the values it interpolates so the
translation can place them wherever its own grammar needs them."""
def __init__(
self,
message: str,
code: str = "insufficient_balance",
**params: int | str,
) -> None:
super().__init__(message)
self.code = code
self.params = params
# B-73 (DAT-63): the recipient is always the first output of a single-recipient transaction
# (the change, when there is one, follows it) — which is what a bet records as `bet_vout`.
RECIPIENT_VOUT = 0
@dataclass
class Utxo:
txid: str
vout: int
amount_sats: int
@dataclass
class BuiltTransaction:
raw_hex: str
txid: str
fee_sats: int
recipient_sats: int
change_sats: int
spent_utxos: list[Utxo]
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, 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_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.
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."""
# B-75: a UTXO below the dust limit is never offered, even if one is already on
# record: it costs more to spend than it is worth.
ordered = sorted(
(u for u in utxos if u.amount_sats >= DUST_LIMIT_SATS), key=lambda u: u.amount_sats, reverse=True
)
selected: list[Utxo] = []
total = 0
for utxo in ordered:
if len(selected) == max_inputs:
raise InsufficientFundsError(
f"balance too fragmented: more than {max_inputs} inputs would be needed",
code="too_many_inputs",
max_inputs=max_inputs,
)
selected.append(utxo)
total += utxo.amount_sats
if total >= target_sats:
return selected, total
raise InsufficientFundsError("not enough confirmed balance to cover amount")
def build_signed_transaction(
*,
signing_key: HDKey,
from_script: script.Script,
utxos: list[Utxo],
to_address: str,
amount_sats: int,
change_address: str,
fee_rate_sat_vb: int,
reduce_amount_to_keep_change: bool = False,
) -> BuiltTransaction:
"""Build, sign and finalize a single-recipient P2WPKH transaction with change
back to change_address.
`amount_sats` is deducted from the sender's balance in full: the recipient
receives `amount_sats - fee`, change = total_in - amount_sats. This matches the
spec everywhere a single-recipient tx is used (bet, withdrawal): "fee deducted
from the amount being moved", not paid on top by the sender.
B-62: the transaction always keeps a change output of at least DUST_LIMIT_SATS.
Change used to be folded into the fee whenever it came out below the dust limit,
which for an amount equal to the whole input total (the UI's "withdraw
everything" checkbox, or a bet from a balance exactly equal to the bet amount)
produced a single-output transaction — and `tx/broadcast.py:bump_fee` has nothing
to shrink there, so it raised RbfError every 30s until the reconciler abandoned
the row hours later. Adding inputs instead is no answer for this case in
particular: the transaction already spends every UTXO the sender has.
What happens when the change would be too small depends on who's asking, hence
`reduce_amount_to_keep_change`:
- withdrawals pass True — the amount moved is reduced just enough to leave a
dust-limit change output. The fee already comes out of the withdrawn amount by
design, so this is the same rule applied a little harder, and the caller
records what was actually sent (`Withdrawal.amount_sent_sats`).
- bets pass False (the default) and get an InsufficientFundsError instead: the
bet is a fixed price that cannot be quietly reduced, and "a user's balance must
never exactly equal the bet" is a documented invariant of the PLAY phase. The
player needs a little more than the bet amount, which is what the error says.
"""
selected, total_in = select_utxos(utxos, amount_sats)
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
change = total_in - amount_sats
if change < DUST_LIMIT_SATS:
if not reduce_amount_to_keep_change:
raise InsufficientFundsError(
f"the amount leaves no change output: {DUST_LIMIT_SATS - change} more sats are "
"needed for the transaction to stay fee-bumpable",
code="balance_leaves_no_change",
required_extra_sats=DUST_LIMIT_SATS - change,
)
amount_sats -= DUST_LIMIT_SATS - change
change = DUST_LIMIT_SATS
recipient_amount = amount_sats - fee
if recipient_amount <= 0:
raise InsufficientFundsError(
"amount too small to cover the network fee", code="amount_below_network_fee"
)
if recipient_amount < DUST_LIMIT_SATS:
raise InsufficientFundsError(
"amount too small to be sent (dust)", code="amount_below_dust_limit"
)
# TransactionInput.txid is natural/display byte order (as in tx_hash from Electrum);
# embit reverses it internally when serializing to wire format.
vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
vout = [TransactionOutput(recipient_amount, script.Script.from_address(to_address))]
if change > 0:
vout.append(TransactionOutput(change, script.Script.from_address(change_address)))
tx = Transaction(vin=vin, vout=vout)
psbt = PSBT(tx)
for i, utxo in enumerate(selected):
psbt.inputs[i].witness_utxo = TransactionOutput(utxo.amount_sats, from_script)
signed_count = psbt.sign_with(signing_key)
if signed_count != len(selected):
raise RuntimeError(f"expected {len(selected)} signatures, got {signed_count}")
final_tx = finalize_psbt(psbt)
if final_tx is None:
raise RuntimeError("failed to finalize PSBT")
raw = final_tx.serialize()
return BuiltTransaction(
raw_hex=raw.hex(),
txid=final_tx.txid().hex(),
fee_sats=fee,
recipient_sats=recipient_amount,
change_sats=change,
spent_utxos=selected,
)
@dataclass
class PayoutTransaction:
raw_hex: str
txid: str
fee_sats: int
winner_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]
def build_payout_transaction(
*,
signing_key: HDKey,
from_script: script.Script,
utxos: list[Utxo],
winner_address: str,
winner_share_sats: int,
fee_address: str,
commission_sats: int,
reserve_address: str,
fee_rate_sat_vb: int,
) -> PayoutTransaction:
"""Build, sign and finalize the round payout: pool -> winner + fee address + reserve.
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 + 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"
)
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 cover the bump reserve and still be paid out (dust)",
code="commission_below_dust_limit",
)
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(fee_address_amount, script.Script.from_address(fee_address)),
TransactionOutput(reserve, script.Script.from_address(reserve_address)),
]
tx = Transaction(vin=vin, vout=vout)
psbt = PSBT(tx)
for i, utxo in enumerate(selected):
psbt.inputs[i].witness_utxo = TransactionOutput(utxo.amount_sats, from_script)
signed_count = psbt.sign_with(signing_key)
if signed_count != len(selected):
raise RuntimeError(f"expected {len(selected)} signatures, got {signed_count}")
final_tx = finalize_psbt(psbt)
if final_tx is None:
raise RuntimeError("failed to finalize PSBT")
raw = final_tx.serialize()
return PayoutTransaction(
raw_hex=raw.hex(),
txid=final_tx.txid().hex(),
fee_sats=fee,
winner_sats=winner_amount,
commission_sats=fee_address_amount,
reserve_sats=reserve,
change_sats=excess,
spent_utxos=selected,
)