4 Commits
Author SHA1 Message Date
davide 5cfe2d6f95 Merge audit-2026-07-27: fix all 25 findings of the second audit
B-25 … B-49, each in its own commit with its own regression test — the suite
went from 139 to 253 tests. Also on this branch: Docker + Caddy security
headers, the API docs gate, admin endpoint limits, and the SSE gaps.
2026-07-27 23:37:12 +02:00
davide 0d2fef6502 Delete BUGS.md now that both audits' findings are closed
B-01 … B-49 are all fixed, so the file held no open work — only a history that
git already keeps. CLAUDE.md now explains how to resolve the B-nn markers left
throughout the code against that history, and repeats the caveat the empty list
does not carry on its own: no open findings is not the same as no bugs.

The one remaining reference, in an already-applied migration's docstring, is
left as the historical record it is.
2026-07-27 23:37:02 +02:00
davide e7f844b11f Publish an SSE update from the bet/withdrawal rollback paths (B-49)
_release_failed_bet and _release_failed_withdrawal restored the balance, freed
the reserved UTXOs and (for a bet) removed the participant without calling
broadcaster.publish(), so every dashboard kept showing the phantom bet and the
reduced balance until its next poll — while the success path and the
reconciler's own abandon path both published.

The two regression tests pre-open the round before subscribing: place_bet opens
one itself, and that publish() would otherwise satisfy the assertion whether or
not the rollback published anything.
2026-07-27 23:35:10 +02:00
davide 4c80c1c5bf 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.
2026-07-27 23:30:06 +02:00
11 changed files with 158 additions and 89 deletions
-75
View File
@@ -1,75 +0,0 @@
# Known bugs
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
fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 2 are
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
brought the suite from 139 to 248).
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.
The payout phase is now fully recoverable; the "drawing" phase (waiting on a block) is now
observable (B-36) but still has no equivalent resume-after-restart — see "Known gaps / TODO"
in [CLAUDE.md](CLAUDE.md), which is also where other by-design limitations (single-shared-token
admin auth, single-process assumptions, no user-facing history, etc.) are documented.
---
## 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
`bets/service.py:_release_failed_bet` and `withdrawals/service.py:_release_failed_withdrawal`
restore the balance without calling `broadcaster.publish()`, so dashboards only find out on
their next poll.
**Fix:** one `broadcaster.publish()` at the end of each, as every other state-changing path
already does.
---
## Previously fixed
- **B-25** — the payout had no two-phase write, unlike bets and withdrawals
- **B-26** — a payout failure or a process restart could wedge a round in `paying_out` forever
- **B-27** — an RBF bump reset the reconciler's own abandon clock, so a repeatedly-bumped tx was never abandoned
- **B-28** — a hostile Electrum server (or a MITM) could single-handedly pick the round's winner
- **B-29** — a UTXO absent from one server's `listunspent` was marked spent immediately, irreversibly, on a single unauthenticated reply
- **B-30** — a lost scripthash subscription meant a user's deposits were never credited, with no periodic safety net
- **B-31** — resubscribing on reconnect ran serially before anything else started, freezing the chain tip (and so an in-flight draw) for the whole sweep
- **B-42** — Swagger/ReDoc/the raw OpenAPI JSON enumerated the entire API surface, admin endpoints included, to anyone who requested them; now off by default and gated behind `ENABLE_API_DOCS`
- **B-43** — the Caddyfile sent no CSP, no `X-Frame-Options`/`frame-ancestors`, and no HSTS, on a page whose JWT lives in `localStorage`
- **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-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-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-33** — `POST /auth/login` had no rate limiting, so a password could be brute-forced against an enumerable username list
- **B-34** — password change/reset didn't invalidate already-issued JWTs, so a stolen token survived a change meant to lock it out
- **B-35** — API timestamps round-tripped as naive datetimes, so the frontend parsed them as local time instead of UTC
- **B-36** — a stalled draw wait had no timeout, no log, and no audit trail, so a frozen round showed nothing in `/admin`
- **B-37** — a withdrawal covered by unconfirmed change answered "insufficient balance" instead of distinguishing it from actually having no funds
- **B-38** — the SSE subscriber cap was global, so one client opening enough connections degraded every other user to polling
- **B-39** — SQLite ran without WAL or a `busy_timeout`, so a writer could block every reader and a second writer failed immediately instead of waiting
- **B-40** — `bump_fee` held a DB session open across N slow network calls, and computed a prevout's value from a server-reported float instead of an exact integer
- **B-41** — confirmation/reconciliation depended on a verbose `blockchain.transaction.get` reply many Electrum servers reject, and abandonment relied on fragile substring-matching of an error message
See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the
B-28/B-29/B-30/B-31/B-32/B-33/B-34/B-35/B-36/B-37/B-38/B-39/B-40/B-41/B-42/B-43/B-44/B-45/B-46/B-47 fixes). Suite grew from 139 to 248 tests over the twenty-three.
A full-codebase audit on 2026-07-26 (commit `d4e0974`) found 24 bugs across every Python
module under `app/`, both static frontends, and the Docker/Caddy deployment — 5 critical,
7 high, 7 medium, 5 low. All 24 were fixed and verified against the current code on
2026-07-27; the fixes are covered by the regression suite (grew from 79 to 139 tests) and
five of them were additionally confirmed against a real mainnet deployment (see git history
between `fb734bb` (documenting the findings) and `845ba98` (recording the audit outcome) for
the fix-by-fix breakdown — each commit message names the bugs it closes and where their
tests live).
+6 -5
View File
@@ -8,11 +8,11 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## 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 — 253 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.
**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.
Two full-codebase audits 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are **all fixed** as of 2026-07-27, each with its own regression test. They were tracked in a `BUGS.md` that was deleted once the list emptied, so the ~276 `B-nn` markers left in comments across the code are pointers into git history (`git log --all --grep 'B-nn'` finds the commit that fixed one, and `git show f1a1145:BUGS.md`-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (`tests/integration/` is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted **by design** instead. A new finding gets the next B-nn, in its own commit 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.
@@ -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/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # all 185 tests
python -m pytest # all 253 tests
python -m pytest tests/unit/test_hd.py # one file
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 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` |
`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).
@@ -180,7 +181,7 @@ At 120s blocks that's ~46 min worst case (last bet confirms right at the dead
`GET /rounds/stream` is **additive to** the polling loops in the two SPAs, not a replacement — a blocked or dropped stream just degrades to the old behaviour. No payload, no auth: it's a "something changed, go refetch" ping, with all personalization (e.g. `user_played`) staying in the authenticated REST endpoints. The generator re-checks `request.is_disconnected()` every 5s and sends a keep-alive comment every 20s, so neither a client that vanished without a clean close nor a proxy idle timeout breaks it silently.
`rounds/events.py`'s `RoundEventBroadcaster` (singleton `broadcaster`) is in-process pub/sub, one `asyncio.Queue(maxsize=1)` per client so redundant notifications coalesce. `publish()` is called on: a round opening (`rounds/service.py`), every status transition (`scheduler.py`), a bet or withdrawal broadcast, any pending tx confirming (`tx/confirmation.py`), a deposit credited (`deposits/service.py`), and a new tip arriving (`electrum/listener.py` — exactly what the drawing phase waits on). Rollback paths are the known exception (B-49).
`rounds/events.py`'s `RoundEventBroadcaster` (singleton `broadcaster`) is in-process pub/sub, one `asyncio.Queue(maxsize=1)` per client so redundant notifications coalesce. `publish()` is called on: a round opening (`rounds/service.py`), every status transition (`scheduler.py`), a bet or withdrawal broadcast, any pending tx confirming (`tx/confirmation.py`), a deposit credited (`deposits/service.py`), and a new tip arriving (`electrum/listener.py` — exactly what the drawing phase waits on). The rollback paths (`_release_failed_bet`, `_release_failed_withdrawal`, the reconciler's abandon) publish too — a rollback moves as much state as the success path, so it must ping the dashboards the same way (B-49).
Deliberate scope limits, not oversights: **single-process only** (fine for one uvicorn process; a multi-worker deployment needs e.g. Redis pub/sub — don't add it speculatively); **generic broadcast, not per-user** (everyone refetches on every event; acceptable at ~100 concurrent users, and a targeted channel would need auth on the stream plus server-side knowledge of who each event affects); `MAX_SUBSCRIBERS` (500) is defensive only — past it the endpoint returns 503 and `EventSource` falls back to polling, which being global and unauthenticated makes the cap itself a cheap DoS of the realtime feature (B-38).
@@ -233,7 +234,7 @@ Explicit design choices, not derivable from any single file — respect them:
## Known gaps / TODO
Accepted **by design**. For actual bugs see [BUGS.md](BUGS.md) (2 open) — not duplicated here.
Accepted **by design** — distinct from the audit findings above (all fixed), which are 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.
- **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.
+6 -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,
)
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) --------------------
# Broadcasting first meant a failure (or a crash) between the broadcast and the
@@ -144,6 +144,11 @@ async def _release_failed_bet(
user_id=user_id,
)
await session.commit()
# The rollback moved as much state as the successful path did — the balance is
# back, the participant is gone, so participant_count and jackpot shrank again.
# Without this the dashboards kept showing the phantom bet until their next poll
# (B-49); the reconciler's own abandon path has always published here.
broadcaster.publish()
def _pending_transaction(
+1 -1
View File
@@ -41,7 +41,7 @@ def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient:
@asynccontextmanager
async def lifespan(app: FastAPI):
# Refuses to serve rather than starting up half-configured — see B-15 in BUGS.md.
# Refuses to serve rather than starting up half-configured (B-15).
validate_runtime_secrets()
endpoints = parse_endpoints(
+6 -3
View File
@@ -364,9 +364,12 @@ class RoundScheduler:
change_address=pool_address,
fee_rate_sat_vb=fee_rate,
)
except InsufficientFundsError:
logger.exception("round %s payout failed: insufficient pool UTXOs", round_id)
await self._log_payout_failure(round_id, winner_user_id, "insufficient pool UTXOs")
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.
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
except Exception:
# 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.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.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.internal_error': 'Unexpected server error. Please try again shortly.',
'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.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.',
'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.internal_error': 'Errore inatteso del server. Riprova tra poco.',
'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.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.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.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.',
@@ -552,6 +555,7 @@ const TRANSLATIONS = {
'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.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.internal_error': 'Erreur inattendue du serveur. Veuillez réessayer dans un instant.',
'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.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.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.internal_error': 'Unerwarteter Serverfehler. Bitte versuche es in Kürze erneut.',
'error.guide_unavailable': 'Die Anleitung ist derzeit nicht verfügbar.',
@@ -830,6 +835,7 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Введите сумму больше нуля.',
'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.',
'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.',
'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.',
'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.',
'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.',
'error.guide_unavailable': 'Руководство сейчас недоступно.',
@@ -969,6 +975,7 @@ const TRANSLATIONS = {
'error.invalid_amount': '请输入大于零的金额。',
'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。',
'error.amount_below_dust_limit': '金额过小,无法发送。',
'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。',
'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。',
'error.internal_error': '服务器发生意外错误,请稍后重试。',
'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.
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):
"""`code` is the machine-readable identifier the API layer forwards to the
client so it can translate the failure (see app/api/errors.py); the message
itself stays English."""
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)
self.code = code
self.params = params
@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]:
"""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."""
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)
selected: list[Utxo] = []
total = 0
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)
total += utxo.amount_sats
if total >= target_sats:
+2 -1
View File
@@ -89,7 +89,7 @@ async def request_withdrawal(
fee_rate_sat_vb=config.fee_rate_sat_vb,
)
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
# network has accepted the tx — same two-phase shape as place_bet (B-08).
@@ -173,3 +173,4 @@ async def _release_failed_withdrawal(
user_id=user_id,
)
await session.commit()
broadcaster.publish() # the reserved UTXOs are spendable again — refetch the balance (B-49)
+58
View File
@@ -8,8 +8,10 @@ from app.bets.service import BetError, place_bet
from app.config import settings
from app.db.base import Base
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent
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
class FakeElectrumClient:
@@ -91,6 +93,35 @@ async def test_place_bet_rejects_insufficient_balance(session_factory):
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):
user_id = await _make_funded_user(session_factory, 2, 3_000_000_000)
client = FakeElectrumClient()
@@ -166,6 +197,33 @@ async def test_failed_broadcast_leaves_nothing_behind(session_factory):
assert "bet_placed" not in events
async def test_failed_broadcast_publishes_an_sse_update(session_factory): # B-49
"""The rollback moves as much state as the successful path does, so it must ping
the dashboards the same way otherwise the phantom bet stays on screen until the
next poll."""
user_id = await _make_funded_user(session_factory, 21, 3_000_000_000)
async with session_factory() as session:
# Open the round up front: place_bet would otherwise open it itself, and that
# publish() would satisfy the assertion below whether or not the rollback ever
# published one of its own.
await open_new_round_if_needed(session)
await session.commit()
queue = broadcaster.subscribe()
try:
while not queue.empty():
queue.get_nowait()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="refused"):
await place_bet(session, RejectingElectrumClient(), user)
assert not queue.empty()
finally:
broadcaster.unsubscribe(queue)
async def test_failed_broadcast_reports_the_broadcast_failed_code(session_factory):
user_id = await _make_funded_user(session_factory, 5, 3_000_000_000)
+17
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_TX_INPUTS,
InsufficientFundsError,
Utxo,
build_signed_transaction,
@@ -36,6 +37,22 @@ def test_select_utxos_raises_when_insufficient():
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():
signer = _key(1)
from_script = script.p2wpkh(signer.to_public())
+26
View File
@@ -6,6 +6,7 @@ from app.bets.service import place_bet
from app.config import settings
from app.db.base import Base
from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal
from app.rounds.events import broadcaster
from app.wallet.hd import derive_user_address
from app.withdrawals.service import WithdrawalError, request_withdrawal
@@ -171,6 +172,31 @@ async def test_withdrawal_to_own_address_is_rejected(session_factory):
assert (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().spent_txid is None
async def test_failed_broadcast_publishes_an_sse_update(session_factory): # B-49
"""The released UTXOs are spendable again and the balance changed back, so the
rollback must nudge the dashboard to refetch instead of leaving it stale until
its next poll."""
user_id = await _make_funded_user(session_factory, 10, 3_000_000_000)
class RejectingClient:
async def broadcast(self, raw_tx_hex: str) -> str:
raise RuntimeError("min relay fee not met")
queue = broadcaster.subscribe()
try:
while not queue.empty():
queue.get_nowait()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError, match="refused"):
await request_withdrawal(session, RejectingClient(), user, derive_user_address(98), 1_000_000_000)
assert not queue.empty()
finally:
broadcaster.unsubscribe(queue)
async def test_failed_broadcast_marks_the_withdrawal_failed_and_frees_the_coins(session_factory):
"""B-07/B-08: the Withdrawal row is kept (unlike a bet) so the user can see the
instruction didn't go through, but the coins must come back."""