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:
2026-08-03 16:21:33 +02:00
co-authored by Claude Opus 5
parent 99d7a1ee00
commit 025754c860
10 changed files with 311 additions and 28 deletions
+25 -5
View File
@@ -28,7 +28,7 @@ Severity is about consequence, not likelihood:
## Critical
### B-52 — a round with more than ~50 participants deadlocks the platform permanently
### B-52 — a round with more than ~50 participants deadlocks the platform permanently — **FIXED**
`app/wallet/psbt_builder.py:42` (`MAX_TX_INPUTS = 50`),
`app/rounds/scheduler.py:349-373`.
@@ -45,10 +45,30 @@ CLAUDE.md presents B-48's input cap purely as a fragmented *user* address proble
The pool case is structural rather than an edge case: participant count alone
causes it, with the default bet amount and no unusual deposit pattern.
Fix directions: consolidate the pool between rounds (a sweep tx from pool to pool),
or let a payout span more than one transaction, or — as an interim guard — cap
participants per round and reject bets past the cap with a translatable error.
Whatever the choice, it needs a regression test at n = MAX_TX_INPUTS + 1.
**Fixed** by moving the limit from where it was *discovered* to where it can still be
*enforced*:
- `select_utxos` takes the cap as a parameter. Bets and withdrawals keep
`MAX_TX_INPUTS = 50` (a user-protection limit: the fee comes out of the amount
they are moving); the payout uses the new `MAX_PAYOUT_TX_INPUTS = 500`, since the
pool's UTXO count is just the number of bets and the fee comes out of a 70% share
of the whole pool. 500 inputs is ~34 kvB, about a third of the 100 kvB relay
standardness budget; signing that many costs ~0.4 s of event loop, once per round,
in a background task.
- `place_bet` refuses the bet past `MAX_PARTICIPANTS_PER_ROUND = 400` with a new
`round_full` error (400, translated into all 7 languages), counting every
participant row rather than only the confirmed ones. The cap sits below the input
cap so the payout keeps headroom for pool change accumulated by earlier rounds.
The invariant is now "a round can always be paid out", enforced before any of the
401st player's money moves. A round already wedged with 51499 participants pays out
by itself on the next `_retry_payout_if_due` tick.
Not addressed, and deliberately so: periodic pool consolidation, which is what would
be needed to go beyond this order of magnitude (see the audit discussion — it needs a
new PendingTransaction kind, must not run mid-round, and would force the payout math
to tolerate a pool short of its exact target). Raising these two constants covers
anything up to ~1400 participants first.
### B-53 — a bet can pay into the pool and still be left out of the draw
+4 -2
View File
@@ -91,7 +91,9 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
| Min deposit | none | — |
| Min password length | 8 | `auth/security.py:MIN_PASSWORD_LENGTH` |
| Confirmations, every tx kind | **1** | hardcoded in `tx/confirmation.py` |
| Max inputs per tx | 50 (`MAX_TX_INPUTS`, B-48) — over it the build fails with `too_many_inputs`, it never spends more | hardcoded in `wallet/psbt_builder.py` |
| Max inputs per *user* tx (bet, withdrawal) | 50 (`MAX_TX_INPUTS`, B-48) — over it the build fails with `too_many_inputs`, it never spends more | hardcoded in `wallet/psbt_builder.py` |
| Max inputs per *payout* | 500 (`MAX_PAYOUT_TX_INPUTS`, B-52) — the pool holds one UTXO per bet, so reusing the user cap made any round past ~50 players unpayable | hardcoded in `wallet/psbt_builder.py` |
| Max participants per round | 400 (`MAX_PARTICIPANTS_PER_ROUND`, B-52) — the 401st bet is refused with `round_full` *before* any money moves, so "a round can always be paid out" is an invariant rather than something discovered at payout time | hardcoded in `wallet/psbt_builder.py`, enforced in `bets/service.py` |
`GET /rounds/current`'s `jackpot_sats` is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual `bet_amount_sats` (each already net of its own bet fee) rather than `count × current bet amount` — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11).
@@ -150,7 +152,7 @@ Diagrams: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifec
**DEP** — the listener subscribes to the user's scripthash; balance is credited after **1 confirmation**, with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
**PLAY** — fixed cost, **at most one active bet per user**. PSBT user-address → pool-address, always with a **change output back to the same user address** (a user's balance must never exactly equal the bet). Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
**PLAY** — fixed cost, **at most one active bet per user**, and **at most `MAX_PARTICIPANTS_PER_ROUND` (400) players per round** — past that the bet is refused with `round_full` and the player waits for the next round (B-52: the payout must spend one pool UTXO per bet, so a round is only ever allowed to grow to what a single payout tx can drain). PSBT user-address → pool-address, always with a **change output back to the same user address** (a user's balance must never exactly equal the bet). Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
**DRAW** — configurable timer (default 600s):
- *Bet cutoff is the round's own deadline* (`opened_at + round_duration_seconds`), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`.
+28 -2
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from embit import script
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import ApiError
@@ -13,7 +13,13 @@ from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
from app.wallet.balance import recompute_balance
from app.wallet.hd import derive_pool_address, derive_user_key
from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction
from app.wallet.psbt_builder import (
MAX_PARTICIPANTS_PER_ROUND,
BuiltTransaction,
InsufficientFundsError,
Utxo,
build_signed_transaction,
)
class BetError(ApiError):
@@ -37,6 +43,26 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
if already_playing is not None:
raise BetError("already_betting", "you already have an active bet in the current round")
# B-52: "this round can always be paid out" is an invariant, and this is where it
# gets enforced — before any of this user's money moves. The payout has to spend
# one pool UTXO per bet, so a round that grew past what a single payout
# transaction may spend was unpayable: it stayed "paying_out" retrying forever,
# and because no new round may open while one is active, the whole lottery
# stopped. Refusing the bet costs the player one round of waiting; accepting it
# cost everyone the platform. Counted over every participant row, not just the
# confirmed ones: a bet that later fails frees a slot, so counting them all is
# the conservative direction.
participant_count = await session.scalar(
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
)
if participant_count >= MAX_PARTICIPANTS_PER_ROUND:
raise BetError(
"round_full",
f"this round already has its maximum of {MAX_PARTICIPANTS_PER_ROUND} players, "
"wait for the next one",
max_participants=MAX_PARTICIPANTS_PER_ROUND,
)
bet_amount = config.bet_amount_sats
unspent = (
+6 -2
View File
@@ -365,8 +365,12 @@ class RoundScheduler:
fee_rate_sat_vb=fee_rate,
)
except InsufficientFundsError as exc:
# Includes the B-48 "too_many_inputs" case: the pool holds enough, but spread
# over more UTXOs than one transaction may spend, so /admin has to say which.
# Includes the "too_many_inputs" case: the pool holds enough, but spread over
# more UTXOs than one transaction may spend, so /admin has to say which. Since
# B-52 that means MAX_PAYOUT_TX_INPUTS, and participants are capped below it at
# bet time (bets/service.py), so reaching it now takes pool change accumulated
# over many rounds rather than one busy round — an operator consolidation job,
# not a dead end for the bets of the round in progress.
reason = "insufficient pool UTXOs" if exc.code == "insufficient_balance" else exc.code
logger.exception("round %s payout failed: %s", round_id, reason)
await self._log_payout_failure(round_id, winner_user_id, reason)
+7
View File
@@ -139,6 +139,7 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Not connected to the network, please try again shortly.',
'error.no_round_open': 'No round is open right now, please try again shortly.',
'error.round_closing': 'The current round is closing, please try again shortly.',
'error.round_full': 'This round has reached its maximum of {max_participants} players — wait for the next one, it opens shortly.',
'error.already_betting': 'You already have an active bet in the current round.',
'error.insufficient_balance': 'Insufficient balance.',
'error.balance_pending_confirmation': 'You have {pending_plm} PLM pending confirmation — it is not spendable yet.',
@@ -299,6 +300,7 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Nessuna connessione alla rete, riprova tra poco.',
'error.no_round_open': 'Nessun round aperto in questo momento, riprova tra poco.',
'error.round_closing': 'Il round corrente si sta chiudendo, riprova tra poco.',
'error.round_full': 'Questo round ha raggiunto il massimo di {max_participants} giocatori — aspetta il prossimo, si apre tra poco.',
'error.already_betting': 'Hai già una bet attiva nel round corrente.',
'error.insufficient_balance': 'Saldo insufficiente.',
'error.balance_pending_confirmation': 'Hai {pending_plm} PLM in attesa di conferma — non ancora disponibili per la spesa.',
@@ -459,6 +461,7 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Sin conexión con la red, inténtalo de nuevo en un momento.',
'error.no_round_open': 'No hay ninguna ronda abierta ahora mismo, inténtalo de nuevo en un momento.',
'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.',
'error.round_full': 'Esta ronda ha alcanzado su máximo de {max_participants} jugadores: espera la siguiente, se abre en breve.',
'error.already_betting': 'Ya tienes una apuesta activa en la ronda actual.',
'error.insufficient_balance': 'Saldo insuficiente.',
'error.balance_pending_confirmation': 'Tienes {pending_plm} PLM pendientes de confirmación — todavía no se pueden gastar.',
@@ -619,6 +622,7 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Pas de connexion au réseau, réessayez dans un instant.',
'error.no_round_open': "Aucun round n'est ouvert pour le moment, réessayez dans un instant.",
'error.round_closing': 'Le round en cours est en train de se fermer, réessayez dans un instant.',
'error.round_full': 'Ce tour a atteint son maximum de {max_participants} joueurs — attendez le prochain, il ouvre dans un instant.',
'error.already_betting': 'Vous avez déjà une mise active dans le round en cours.',
'error.insufficient_balance': 'Solde insuffisant.',
'error.balance_pending_confirmation': 'Vous avez {pending_plm} PLM en attente de confirmation — pas encore disponibles.',
@@ -779,6 +783,7 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Keine Verbindung zum Netzwerk, bitte versuche es gleich erneut.',
'error.no_round_open': 'Derzeit ist keine Runde offen, bitte versuche es gleich erneut.',
'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.',
'error.round_full': 'Diese Runde hat ihr Maximum von {max_participants} Spielern erreicht — warten Sie auf die nächste, sie beginnt in Kürze.',
'error.already_betting': 'Du hast bereits eine aktive Wette in der laufenden Runde.',
'error.insufficient_balance': 'Nicht genügend Guthaben.',
'error.balance_pending_confirmation': 'Sie haben {pending_plm} PLM, die noch auf Bestätigung warten — noch nicht verfügbar.',
@@ -939,6 +944,7 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Нет соединения с сетью, повторите попытку чуть позже.',
'error.no_round_open': 'Сейчас нет открытого раунда, повторите попытку чуть позже.',
'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.',
'error.round_full': 'В этом раунде достигнут максимум участников ({max_participants}) — дождитесь следующего, он начнётся совсем скоро.',
'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.',
'error.insufficient_balance': 'Недостаточно средств.',
'error.balance_pending_confirmation': 'У вас есть {pending_plm} PLM, ожидающих подтверждения — они пока недоступны для расходования.',
@@ -1099,6 +1105,7 @@ const TRANSLATIONS = {
'error.network_unavailable': '未连接到网络,请稍后重试。',
'error.no_round_open': '当前没有开放的回合,请稍后重试。',
'error.round_closing': '当前回合正在结束,请稍后重试。',
'error.round_full': '本轮已达到 {max_participants} 名玩家的上限 —— 请等待下一轮,很快就会开始。',
'error.already_betting': '你在当前回合已有一笔有效下注。',
'error.insufficient_balance': '余额不足。',
'error.balance_pending_confirmation': '您有 {pending_plm} PLM 待确认 —— 尚不可用于支出。',
+49 -14
View File
@@ -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:
+70 -2
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.bets.service import BetError, place_bet
@@ -11,7 +11,7 @@ from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, Roun
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed
from app.wallet.hd import derive_user_address
from app.wallet.psbt_builder import MAX_TX_INPUTS
from app.wallet.psbt_builder import MAX_PARTICIPANTS_PER_ROUND, MAX_TX_INPUTS
class FakeElectrumClient:
@@ -122,6 +122,74 @@ async def test_place_bet_reports_a_too_fragmented_balance_distinctly(session_fac
assert not client.broadcasted
async def _fill_round_with_participants(session_factory, round_id: int, count: int) -> None:
"""Participant rows only, no real bets: what the cap counts is rows, and building
`count` genuine transactions would just make the test slow without exercising
anything the other tests don't already cover."""
async with session_factory() as session:
for i in range(count):
session.add(
RoundParticipant(
round_id=round_id,
user_id=10_000 + i, # placeholder ids; the cap check never joins users
bet_amount_sats=1_000_000_000,
bet_txid=f"{i:064x}",
status="confirmed",
)
)
await session.commit()
async def test_place_bet_rejects_the_bet_past_the_participant_cap(session_factory): # B-52
"""The payout has to spend one pool UTXO per bet, so a round is only ever allowed
to grow to what a single payout transaction can drain. Enforced here, before the
player's money moves — not discovered at payout time, when the bets are already in
the pool and the round can no longer be paid at all."""
user_id = await _make_funded_user(session_factory, 30, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
round_id = round_.id
await _fill_round_with_participants(session_factory, round_id, MAX_PARTICIPANTS_PER_ROUND)
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
assert excinfo.value.code == "round_full"
assert excinfo.value.params == {"max_participants": MAX_PARTICIPANTS_PER_ROUND}
assert not client.broadcasted
# Refused cleanly: no participant row, and the user's UTXO is still spendable.
async with session_factory() as session:
assert await session.scalar(
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_id)
) == MAX_PARTICIPANTS_PER_ROUND
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
assert utxo.spent_txid is None
async def test_place_bet_still_accepts_the_last_slot_under_the_cap(session_factory): # B-52
user_id = await _make_funded_user(session_factory, 31, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
round_id = round_.id
await _fill_round_with_participants(session_factory, round_id, MAX_PARTICIPANTS_PER_ROUND - 1)
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, client, user)
assert participant.status == "broadcast"
assert client.broadcasted
async def test_place_bet_rejects_second_bet_same_round(session_factory):
user_id = await _make_funded_user(session_factory, 2, 3_000_000_000)
client = FakeElectrumClient()
+58 -1
View File
@@ -4,7 +4,15 @@ from embit.bip32 import HDKey
from embit.transaction import Transaction
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction, estimate_vsize
from app.wallet.psbt_builder import (
MAX_PARTICIPANTS_PER_ROUND,
MAX_PAYOUT_TX_INPUTS,
MAX_TX_INPUTS,
InsufficientFundsError,
Utxo,
build_payout_transaction,
estimate_vsize,
)
def _key(seed_byte: int) -> HDKey:
@@ -75,6 +83,55 @@ def test_payout_adds_change_output_when_pool_utxos_exceed_target():
assert len(parsed.vout) == 3
def test_payout_spends_more_utxos_than_a_user_transaction_may(): # B-52
"""The pool holds one UTXO per bet, so a round with more participants than
MAX_TX_INPUTS used to be impossible to pay out: 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 lottery stopped for good.
The payout gets its own, far higher cap for exactly this reason."""
pool_key = _key(40)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
winner_address = script.p2wpkh(_key(41).to_public()).address(network=PLM_MAINNET)
fee_address = script.p2wpkh(_key(42).to_public()).address(network=PLM_MAINNET)
# One 10 PLM bet per participant, one UTXO each, just past the user-facing cap.
participants = MAX_TX_INPUTS + 1
bet_sats = 1_000_000_000
pool_amount = bet_sats * participants
winner_share = pool_amount * 70 // 100
commission = pool_amount - winner_share
utxos = [Utxo(f"{i:064x}", 0, bet_sats) for i in range(participants)]
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
fee_rate_sat_vb=1,
)
assert len(built.spent_utxos) == participants # every bet had to be spent
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vin) == participants
assert built.fee_sats == estimate_vsize(participants, 3)
assert built.winner_sats == winner_share - built.fee_sats
assert built.commission_sats == commission # still untouched by the fee
def test_payout_at_the_participant_cap_stays_well_inside_relay_limits(): # B-52
"""MAX_PARTICIPANTS_PER_ROUND is only safe if the payout it implies is still a
standard transaction. A full round is one input per bet plus the pool's own
change, and relay policy refuses anything past 100 kvB."""
inputs_needed = MAX_PARTICIPANTS_PER_ROUND + 1 # + one accumulated pool change UTXO
assert inputs_needed <= MAX_PAYOUT_TX_INPUTS # headroom for pool change exists
assert estimate_vsize(MAX_PAYOUT_TX_INPUTS, 3) < 100_000
def test_payout_raises_when_winner_share_too_small():
pool_key = _key(30)
pool_script = script.p2wpkh(pool_key.to_public())
+28
View File
@@ -5,6 +5,7 @@ from embit.transaction import Transaction
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import (
MAX_PAYOUT_TX_INPUTS,
MAX_TX_INPUTS,
InsufficientFundsError,
Utxo,
@@ -53,6 +54,33 @@ def test_select_utxos_allows_exactly_the_input_cap():
assert total == 100_000 * MAX_TX_INPUTS
def test_select_utxos_honours_a_caller_supplied_cap(): # B-52
"""The cap is per-caller: MAX_TX_INPUTS protects a user from a fee eating into
their own bet/withdrawal, while the payout needs MAX_PAYOUT_TX_INPUTS to be able
to drain a pool holding one UTXO per bet at all."""
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(MAX_TX_INPUTS + 10)]
target = 100_000 * (MAX_TX_INPUTS + 10)
with pytest.raises(InsufficientFundsError):
select_utxos(utxos, target_sats=target) # default cap: too fragmented
selected, total = select_utxos(utxos, target_sats=target, max_inputs=MAX_PAYOUT_TX_INPUTS)
assert len(selected) == MAX_TX_INPUTS + 10
assert total == target
def test_select_utxos_still_caps_at_the_payout_limit(): # B-52
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(MAX_PAYOUT_TX_INPUTS + 5)]
with pytest.raises(InsufficientFundsError) as excinfo:
select_utxos(
utxos,
target_sats=100_000 * (MAX_PAYOUT_TX_INPUTS + 1),
max_inputs=MAX_PAYOUT_TX_INPUTS,
)
assert excinfo.value.code == "too_many_inputs"
assert excinfo.value.params == {"max_inputs": MAX_PAYOUT_TX_INPUTS}
def test_build_signed_transaction_deducts_fee_from_amount_not_change():
signer = _key(1)
from_script = script.p2wpkh(signer.to_public())
+36
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta, timezone
import pytest
from embit.transaction import Transaction
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
@@ -8,6 +9,7 @@ from app.config import settings
from app.db.base import Base
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User
from app.rounds.scheduler import RoundScheduler, _reserved_payout_outpoints
from app.wallet.psbt_builder import MAX_TX_INPUTS
class FakeListener:
@@ -155,6 +157,40 @@ async def test_trigger_payout_persists_before_broadcasting(payout_session_factor
assert "payout_sent" in events
async def test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_inputs(
payout_session_factory,
): # B-52
"""End-to-end shape of the deadlock this fixes: the pool holds one UTXO per bet,
so a round past MAX_TX_INPUTS participants could not be paid at all the build
failed with too_many_inputs, the round stayed "paying_out" retrying every 60s,
and no new round could ever open behind it. It must now broadcast normally."""
await _seed_paying_out_round(payout_session_factory)
participants = MAX_TX_INPUTS + 1
bet_sats = _POOL_AMOUNT_SATS // participants
entries = [
{"tx_hash": f"{i:064x}", "tx_pos": 0, "height": 10, "value": bet_sats}
for i in range(participants)
]
# The pool's total must cover the round's recorded pool_amount_sats, exactly as
# on-chain: integer division above leaves a remainder, so top the last one up.
entries[-1]["value"] += _POOL_AMOUNT_SATS - bet_sats * participants
client = FakePayoutClient(entries)
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._trigger_payout(1)
assert client.broadcasted
async with payout_session_factory() as session:
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.status == "pending"
assert len(Transaction.parse(bytes.fromhex(pending.raw_tx_hex)).vin) == participants
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert "payout_sent" in events
assert "payout_failed" not in events
async def test_trigger_payout_broadcast_failure_leaves_a_recoverable_row(payout_session_factory):
"""Before B-25, a broadcast rejection here left nothing behind — no payout_txid,
no PendingTransaction because everything was persisted only after the