diff --git a/BUGS.md b/BUGS.md index 581e07e..3dc2a50 100644 --- a/BUGS.md +++ b/BUGS.md @@ -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 51–499 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 diff --git a/CLAUDE.md b/CLAUDE.md index 6c5e4dc..3dd0819 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/app/bets/service.py b/app/bets/service.py index 37854fa..ee2b664 100644 --- a/app/bets/service.py +++ b/app/bets/service.py @@ -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 = ( diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index f4953b6..13306b4 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -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) diff --git a/app/static/i18n.js b/app/static/i18n.js index 5d7d173..fd01911 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -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 待确认 —— 尚不可用于支出。', diff --git a/app/wallet/psbt_builder.py b/app/wallet/psbt_builder.py index 26f89f7..a93ade8 100644 --- a/app/wallet/psbt_builder.py +++ b/app/wallet/psbt_builder.py @@ -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: diff --git a/tests/unit/test_bets.py b/tests/unit/test_bets.py index a76efc2..24fcf23 100644 --- a/tests/unit/test_bets.py +++ b/tests/unit/test_bets.py @@ -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() diff --git a/tests/unit/test_payout_builder.py b/tests/unit/test_payout_builder.py index 6d2dcae..5f94712 100644 --- a/tests/unit/test_payout_builder.py +++ b/tests/unit/test_payout_builder.py @@ -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()) diff --git a/tests/unit/test_psbt_builder.py b/tests/unit/test_psbt_builder.py index 4f8eb6e..5407394 100644 --- a/tests/unit/test_psbt_builder.py +++ b/tests/unit/test_psbt_builder.py @@ -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()) diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 79990b7..8ff6fa6 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -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