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
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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