Cap the number of inputs a transaction may spend (B-48)

select_utxos had no ceiling on input count, so an address fragmented into many
small deposits built an ever-larger transaction whose fee — deducted from the
amount being moved — eroded the bet's share of the pool or the withdrawn amount,
and past a few hundred inputs stopped being standard at all.

MAX_TX_INPUTS (50) now bounds the selection. Reaching the cap without covering
the target is reported as its own "too_many_inputs" code, distinct from having
no funds, with the cap carried in the error params for the 7 translations. The
payout path records the same distinction in its payout_failed audit reason.
This commit is contained in:
2026-07-27 23:30:06 +02:00
parent 6a90136b50
commit 4c80c1c5bf
9 changed files with 101 additions and 24 deletions
+5 -12
View File
@@ -1,11 +1,11 @@
# Known bugs # Known bugs
A second full-codebase audit on 2026-07-27 found **25 further issues** (4 critical, 6 high, A second full-codebase audit on 2026-07-27 found **25 further issues** (4 critical, 6 high,
7 medium, 8 low), listed below as B-48 … B-49. B-25 through B-47 are fixed (see "Previously 7 medium, 8 low), listed below as B-49. B-25 through B-48 are fixed (see "Previously
fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 2 are fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 1 is
Low/hygiene. The 139-test suite was green at the time of the audit, so none of these were caught Low/hygiene. The 139-test suite was green at the time of the audit, so none of these were caught
by existing coverage — every fix lands with a regression test (the twenty-three fixes so far by existing coverage — every fix lands with a regression test (the twenty-four fixes so far
brought the suite from 139 to 248). brought the suite from 139 to 251).
The recurring pattern across the open findings is worth stating once: the code is rigorous The recurring pattern across the open findings is worth stating once: the code is rigorous
about the failure modes that have actually been hit, and silent about the ones that have not. about the failure modes that have actually been hit, and silent about the ones that have not.
@@ -18,14 +18,6 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume
## Low / hygiene ## Low / hygiene
### B-48 — No cap on input count in `select_utxos`
A user with hundreds of small UTXOs builds a huge transaction whose fee — deducted from the bet
amount — materially erodes their contribution to the pool, and it can exceed standardness
limits.
**Fix:** cap the selected inputs (e.g. 50) and fail with a translatable error suggesting a
consolidation, or consolidate the address automatically when the count crosses a threshold.
### B-49 — Rollback paths do not publish an SSE update ### B-49 — Rollback paths do not publish an SSE update
`bets/service.py:_release_failed_bet` and `withdrawals/service.py:_release_failed_withdrawal` `bets/service.py:_release_failed_bet` and `withdrawals/service.py:_release_failed_withdrawal`
@@ -50,6 +42,7 @@ already does.
- **B-44** — README's Quick start documented a bare `uvicorn --reload` workflow, and `docs/running-the-server.md` still had a matching "Locale / venv" section, both contradicting CLAUDE.md's Docker-only policy - **B-44** — README's Quick start documented a bare `uvicorn --reload` workflow, and `docs/running-the-server.md` still had a matching "Locale / venv" section, both contradicting CLAUDE.md's Docker-only policy
- **B-45** — `/admin/rounds`/`/admin/audit-log`'s `limit` had no bounds (`-1` means "everything" on SQLite), and `/admin/pending-transactions` had no limit or status filter at all - **B-45** — `/admin/rounds`/`/admin/audit-log`'s `limit` had no bounds (`-1` means "everything" on SQLite), and `/admin/pending-transactions` had no limit or status filter at all
- **B-46** — `secrets.compare_digest` on a `str` raises `TypeError` on non-ASCII input, turning an invalid admin token with non-ASCII characters into a 500 instead of a 403 - **B-46** — `secrets.compare_digest` on a `str` raises `TypeError` on non-ASCII input, turning an invalid admin token with non-ASCII characters into a 500 instead of a 403
- **B-48** — `select_utxos` had no cap on input count, so a fragmented address built an ever-larger transaction whose fee (deducted from the amount being moved) ate into the bet or withdrawal, up to the point of being non-standard
- **B-47** — `raw_tx_hex` and `payload_json` were unbounded `String` columns (`VARCHAR` with no length) — fine on SQLite/PostgreSQL, rejected by backends like MySQL that require a length - **B-47** — `raw_tx_hex` and `payload_json` were unbounded `String` columns (`VARCHAR` with no length) — fine on SQLite/PostgreSQL, rejected by backends like MySQL that require a length
- **B-32** — an RBF bump could retry forever below BIP125's relay-mandated minimum fee delta, with no ceiling on the fee rate either - **B-32** — an RBF bump could retry forever below BIP125's relay-mandated minimum fee delta, with no ceiling on the fee rate either
- **B-33** — `POST /auth/login` had no rate limiting, so a password could be brute-forced against an enumerable username list - **B-33** — `POST /auth/login` had no rate limiting, so a password could be brute-forced against an enumerable username list
+5 -4
View File
@@ -8,11 +8,11 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status ## Project status
All 10 stages of the original build order are code-complete and unit-tested — 185 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. All 10 stages of the original build order are code-complete and unit-tested — 251 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only. Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
**Read [BUGS.md](BUGS.md) before trusting any behaviour here.** Two audits: 2026-07-26 found 24 bugs (5 critical), all fixed; 2026-07-27 found 25 more (B-25 … B-49), of which **2 are still open** — no Critical, High or Medium remains, only Low/hygiene: no cap on input count in `select_utxos` (B-48) and rollback paths not publishing an SSE update (B-49). BUGS.md is the live open list with a proposed fix per finding; "Known gaps" at the end of this file is for limitations accepted **by design** instead. Don't fix a BUGS.md item silently as a side effect of other work — each fix lands with its own regression test. **Read [BUGS.md](BUGS.md) before trusting any behaviour here.** Two audits: 2026-07-26 found 24 bugs (5 critical), all fixed; 2026-07-27 found 25 more (B-25 … B-49), of which **1 is still open** — no Critical, High or Medium remains, only Low/hygiene: rollback paths not publishing an SSE update (B-49). BUGS.md is the live open list with a proposed fix per finding; "Known gaps" at the end of this file is for limitations accepted **by design** instead. Don't fix a BUGS.md item silently as a side effect of other work — each fix lands with its own regression test.
Before writing code, read the "Architecture" section below in full plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) (the 5-phase flow) and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) (the round/draw lifecycle). Every node **and edge label** (conditions, retries, loops) is a behaviour that must be implemented as described. Regenerate the companion PDFs with `flowchart/render-pdf.sh <file>.mmd` after editing either. Before writing code, read the "Architecture" section below in full plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) (the 5-phase flow) and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) (the round/draw lifecycle). Every node **and edge label** (conditions, retries, loops) is a behaviour that must be implemented as described. Regenerate the companion PDFs with `flowchart/render-pdf.sh <file>.mmd` after editing either.
@@ -33,7 +33,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace) PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # all 185 tests python -m pytest # all 251 tests
python -m pytest tests/unit/test_hd.py # one file python -m pytest tests/unit/test_hd.py # one file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
``` ```
@@ -91,6 +91,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
| Min deposit | none | — | | Min deposit | none | — |
| Min password length | 8 | `auth/security.py:MIN_PASSWORD_LENGTH` | | Min password length | 8 | `auth/security.py:MIN_PASSWORD_LENGTH` |
| Confirmations, every tx kind | **1** | hardcoded in `tx/confirmation.py` | | 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` |
`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). `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).
@@ -233,7 +234,7 @@ Explicit design choices, not derivable from any single file — respect them:
## Known gaps / TODO ## Known gaps / TODO
Accepted **by design**. For actual bugs see [BUGS.md](BUGS.md) (2 open) — not duplicated here. Accepted **by design**. For actual bugs see [BUGS.md](BUGS.md) (1 open) — not duplicated here.
- **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_wait_for_next_block` after a crash. That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36). Restart-resumption itself remains the last prerequisite for running unattended. - **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_wait_for_next_block` after a crash. That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36). Restart-resumption itself remains the last prerequisite for running unattended.
- **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback — an exact-amount tx or too-small change raises `RbfError`. Not permanent, though: an unbumpable tx that never confirms is eventually abandoned and its UTXOs released. - **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback — an exact-amount tx or too-small change raises `RbfError`. Not permanent, though: an unbumpable tx that never confirms is eventually abandoned and its UTXOs released.
+1 -1
View File
@@ -62,7 +62,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
fee_rate_sat_vb=config.fee_rate_sat_vb, fee_rate_sat_vb=config.fee_rate_sat_vb,
) )
except InsufficientFundsError as exc: except InsufficientFundsError as exc:
raise BetError(exc.code, str(exc)) from exc raise BetError(exc.code, str(exc), **exc.params) from exc
# --- Phase 1: record the intent, *then* broadcast (B-08) -------------------- # --- Phase 1: record the intent, *then* broadcast (B-08) --------------------
# Broadcasting first meant a failure (or a crash) between the broadcast and the # Broadcasting first meant a failure (or a crash) between the broadcast and the
+6 -3
View File
@@ -364,9 +364,12 @@ class RoundScheduler:
change_address=pool_address, change_address=pool_address,
fee_rate_sat_vb=fee_rate, fee_rate_sat_vb=fee_rate,
) )
except InsufficientFundsError: except InsufficientFundsError as exc:
logger.exception("round %s payout failed: insufficient pool UTXOs", round_id) # Includes the B-48 "too_many_inputs" case: the pool holds enough, but spread
await self._log_payout_failure(round_id, winner_user_id, "insufficient pool UTXOs") # over more UTXOs than one transaction may spend, so /admin has to say which.
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)
return return
except Exception: except Exception:
# Anything else — a malformed fee_address (EmbitError) or similar. This # Anything else — a malformed fee_address (EmbitError) or similar. This
+7
View File
@@ -135,6 +135,7 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Enter an amount greater than zero.', 'error.invalid_amount': 'Enter an amount greater than zero.',
'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.', 'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.',
'error.amount_below_dust_limit': 'The amount is too small to be sent.', 'error.amount_below_dust_limit': 'The amount is too small to be sent.',
'error.too_many_inputs': 'Your balance is split across too many small deposits to be spent in a single transaction (max {max_inputs}). Please contact support to consolidate it.',
'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.', 'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.',
'error.internal_error': 'Unexpected server error. Please try again shortly.', 'error.internal_error': 'Unexpected server error. Please try again shortly.',
'error.guide_unavailable': 'The guide is not available right now.', 'error.guide_unavailable': 'The guide is not available right now.',
@@ -274,6 +275,7 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Inserisci un importo maggiore di zero.', 'error.invalid_amount': 'Inserisci un importo maggiore di zero.',
'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.', 'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.',
'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.", 'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.",
'error.too_many_inputs': 'Il tuo saldo è suddiviso in troppi piccoli depositi per essere speso in una sola transazione (max {max_inputs}). Contatta l\'assistenza per consolidarlo.',
'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.', 'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.',
'error.internal_error': 'Errore inatteso del server. Riprova tra poco.', 'error.internal_error': 'Errore inatteso del server. Riprova tra poco.',
'error.guide_unavailable': 'La guida non è disponibile in questo momento.', 'error.guide_unavailable': 'La guida non è disponibile in questo momento.',
@@ -413,6 +415,7 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Introduce un importe mayor que cero.', 'error.invalid_amount': 'Introduce un importe mayor que cero.',
'error.broadcast_failed': 'La red rechazó la transacción. Inténtalo de nuevo en un momento.', 'error.broadcast_failed': 'La red rechazó la transacción. Inténtalo de nuevo en un momento.',
'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.', 'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.',
'error.too_many_inputs': 'Tu saldo está repartido en demasiados depósitos pequeños para gastarse en una sola transacción (máx. {max_inputs}). Contacta con soporte para consolidarlo.',
'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.', 'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.',
'error.internal_error': 'Error inesperado del servidor. Inténtalo de nuevo en un momento.', 'error.internal_error': 'Error inesperado del servidor. Inténtalo de nuevo en un momento.',
'error.guide_unavailable': 'La guía no está disponible en este momento.', 'error.guide_unavailable': 'La guía no está disponible en este momento.',
@@ -552,6 +555,7 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Saisissez un montant supérieur à zéro.', 'error.invalid_amount': 'Saisissez un montant supérieur à zéro.',
'error.broadcast_failed': 'Le réseau a refusé la transaction. Veuillez réessayer dans un instant.', 'error.broadcast_failed': 'Le réseau a refusé la transaction. Veuillez réessayer dans un instant.',
'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.", 'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.",
'error.too_many_inputs': 'Votre solde est réparti sur trop de petits dépôts pour être dépensé en une seule transaction (max {max_inputs}). Contactez le support pour le consolider.',
'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.", 'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.",
'error.internal_error': 'Erreur inattendue du serveur. Veuillez réessayer dans un instant.', 'error.internal_error': 'Erreur inattendue du serveur. Veuillez réessayer dans un instant.',
'error.guide_unavailable': "Le guide n'est pas disponible pour le moment.", 'error.guide_unavailable': "Le guide n'est pas disponible pour le moment.",
@@ -691,6 +695,7 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Gib einen Betrag größer als null ein.', 'error.invalid_amount': 'Gib einen Betrag größer als null ein.',
'error.broadcast_failed': 'Das Netzwerk hat die Transaktion abgelehnt. Bitte versuche es in Kürze erneut.', 'error.broadcast_failed': 'Das Netzwerk hat die Transaktion abgelehnt. Bitte versuche es in Kürze erneut.',
'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.', 'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.',
'error.too_many_inputs': 'Ihr Guthaben ist auf zu viele kleine Einzahlungen verteilt, um in einer einzigen Transaktion ausgegeben zu werden (max. {max_inputs}). Bitte wenden Sie sich an den Support, um es zusammenzufassen.',
'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.', 'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.',
'error.internal_error': 'Unerwarteter Serverfehler. Bitte versuche es in Kürze erneut.', 'error.internal_error': 'Unerwarteter Serverfehler. Bitte versuche es in Kürze erneut.',
'error.guide_unavailable': 'Die Anleitung ist derzeit nicht verfügbar.', 'error.guide_unavailable': 'Die Anleitung ist derzeit nicht verfügbar.',
@@ -830,6 +835,7 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Введите сумму больше нуля.', 'error.invalid_amount': 'Введите сумму больше нуля.',
'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.', 'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.',
'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.', 'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.',
'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.',
'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.', 'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.',
'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.', 'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.',
'error.guide_unavailable': 'Руководство сейчас недоступно.', 'error.guide_unavailable': 'Руководство сейчас недоступно.',
@@ -969,6 +975,7 @@ const TRANSLATIONS = {
'error.invalid_amount': '请输入大于零的金额。', 'error.invalid_amount': '请输入大于零的金额。',
'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。', 'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。',
'error.amount_below_dust_limit': '金额过小,无法发送。', 'error.amount_below_dust_limit': '金额过小,无法发送。',
'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。',
'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。', 'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。',
'error.internal_error': '服务器发生意外错误,请稍后重试。', 'error.internal_error': '服务器发生意外错误,请稍后重试。',
'error.guide_unavailable': '指南当前不可用。', 'error.guide_unavailable': '指南当前不可用。',
+29 -3
View File
@@ -33,15 +33,30 @@ DUST_LIMIT_SATS = 294
# eating further and further into the sender's change with no limit. # eating further and further into the sender's change with no limit.
MAX_FEE_RATE_SAT_VB = 10_000 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.
MAX_TX_INPUTS = 50
class InsufficientFundsError(Exception): class InsufficientFundsError(Exception):
"""`code` is the machine-readable identifier the API layer forwards to the """`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 client so it can translate the failure (see app/api/errors.py); the message
itself stays English.""" 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") -> None: def __init__(
self,
message: str,
code: str = "insufficient_balance",
**params: int | str,
) -> None:
super().__init__(message) super().__init__(message)
self.code = code self.code = code
self.params = params
@dataclass @dataclass
@@ -68,11 +83,22 @@ def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
def select_utxos(utxos: list[Utxo], target_sats: int) -> tuple[list[Utxo], int]: def select_utxos(utxos: list[Utxo], target_sats: int) -> tuple[list[Utxo], int]:
"""Greedily select UTXOs (largest first, to minimize input count) covering """Greedily select UTXOs (largest first, to minimize input count) covering
target_sats — the amount deducted from the sender's balance. The fee is paid 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.""" 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
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."""
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True) ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
selected: list[Utxo] = [] selected: list[Utxo] = []
total = 0 total = 0
for utxo in ordered: for utxo in ordered:
if len(selected) == MAX_TX_INPUTS:
raise InsufficientFundsError(
f"balance too fragmented: more than {MAX_TX_INPUTS} inputs would be needed",
code="too_many_inputs",
max_inputs=MAX_TX_INPUTS,
)
selected.append(utxo) selected.append(utxo)
total += utxo.amount_sats total += utxo.amount_sats
if total >= target_sats: if total >= target_sats:
+1 -1
View File
@@ -89,7 +89,7 @@ async def request_withdrawal(
fee_rate_sat_vb=config.fee_rate_sat_vb, fee_rate_sat_vb=config.fee_rate_sat_vb,
) )
except InsufficientFundsError as exc: except InsufficientFundsError as exc:
raise WithdrawalError(exc.code, str(exc)) from exc raise WithdrawalError(exc.code, str(exc), **exc.params) from exc
# Persist the intent before broadcasting, and only promote the rows once the # Persist the intent before broadcasting, and only promote the rows once the
# network has accepted the tx — same two-phase shape as place_bet (B-08). # network has accepted the tx — same two-phase shape as place_bet (B-08).
+30
View File
@@ -10,6 +10,7 @@ from app.db.base import Base
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent
from app.rounds.service import open_new_round_if_needed from app.rounds.service import open_new_round_if_needed
from app.wallet.hd import derive_user_address from app.wallet.hd import derive_user_address
from app.wallet.psbt_builder import MAX_TX_INPUTS
class FakeElectrumClient: class FakeElectrumClient:
@@ -91,6 +92,35 @@ async def test_place_bet_rejects_insufficient_balance(session_factory):
await place_bet(session, client, user) await place_bet(session, client, user)
async def test_place_bet_reports_a_too_fragmented_balance_distinctly(session_factory): # B-48
# 100 x 0.15 PLM = 15 PLM, plenty for a 10 PLM bet, but the 50 largest inputs
# only add up to 7.5 PLM — so the build must fail with its own code, not with
# the "you have no funds" one, and must carry the cap for the translation.
user_id = await _make_funded_user(session_factory, 20, 15_000_000)
async with session_factory() as session:
for i in range(99):
session.add(
UtxoEvent(
user_id=user_id,
txid=f"{i:064x}",
vout=0,
amount_sats=15_000_000,
confirmed_height=100,
)
)
await session.commit()
client = FakeElectrumClient()
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 == "too_many_inputs"
assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS}
assert not client.broadcasted
async def test_place_bet_rejects_second_bet_same_round(session_factory): 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) user_id = await _make_funded_user(session_factory, 2, 3_000_000_000)
client = FakeElectrumClient() client = FakeElectrumClient()
+17
View File
@@ -5,6 +5,7 @@ from embit.transaction import Transaction
from app.wallet.plm_network import PLM_MAINNET from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import ( from app.wallet.psbt_builder import (
MAX_TX_INPUTS,
InsufficientFundsError, InsufficientFundsError,
Utxo, Utxo,
build_signed_transaction, build_signed_transaction,
@@ -36,6 +37,22 @@ def test_select_utxos_raises_when_insufficient():
select_utxos(utxos, target_sats=10_000_000) select_utxos(utxos, target_sats=10_000_000)
def test_select_utxos_never_exceeds_the_input_cap(): # B-48
# 200 dust-ish UTXOs that together cover the target, but only past the cap.
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
with pytest.raises(InsufficientFundsError) as excinfo:
select_utxos(utxos, target_sats=100_000 * MAX_TX_INPUTS + 1)
assert excinfo.value.code == "too_many_inputs"
assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS}
def test_select_utxos_allows_exactly_the_input_cap():
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
selected, total = select_utxos(utxos, target_sats=100_000 * MAX_TX_INPUTS)
assert len(selected) == MAX_TX_INPUTS
assert total == 100_000 * MAX_TX_INPUTS
def test_build_signed_transaction_deducts_fee_from_amount_not_change(): def test_build_signed_transaction_deducts_fee_from_amount_not_change():
signer = _key(1) signer = _key(1)
from_script = script.p2wpkh(signer.to_public()) from_script = script.p2wpkh(signer.to_public())