From 43d2321e0f90dfc5e53d9000549efe70ced34dd4 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 09:02:34 +0200 Subject: [PATCH 01/29] Record the 25 open findings of the 2026-07-27 audit A second full-codebase pass over app/, both static frontends and the Docker/Caddy deployment found 25 issues (4 critical, 6 high, 7 medium, 8 low), none of which the 139-test suite catches. All are open. Each entry carries file:line references, why it is a problem, and a Proposed fix paragraph with the concrete approach rather than a bare "fix this". The 24 findings of the 2026-07-26 audit move to a "Previously fixed" section, unchanged. The four critical ones: B-25 the payout broadcasts before recording anything, so a crash in that window leaves an on-chain payout with no DB trace and a manual retry would double-pay the winner B-26 any transient failure at payout time (no Electrum client, insufficient pool UTXOs) returns silently and wedges the round in paying_out forever, with nothing in the audit log B-27 every RBF bump rewrites broadcast_at, which is the same field the reconciler's 6-hour abandon deadline is measured from, so a repeatedly-bumped tx is never abandoned B-28 headers are accepted with no PoW or prev-hash validation over a TLS connection with certificate verification disabled, and that header is the draw's only source of entropy Co-Authored-By: Claude Opus 5 (1M context) --- BUGS.md | 1183 ++++++++++++++----------------------------------------- 1 file changed, 305 insertions(+), 878 deletions(-) diff --git a/BUGS.md b/BUGS.md index 2b7a53e..765073d 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,966 +1,393 @@ -# Known bugs and required fixes +# Known bugs -Full-codebase audit performed on 2026-07-26 against commit `d4e0974` (branch `main`), -covering every Python module under `app/`, both static frontends, and the Docker/Caddy -deployment. The test suite was green at the time of the audit (79 passed), so **none of -the findings below are caught by the existing tests** — every one of them needs a -regression test alongside its fix. +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-25 … B-49. **All of them are open.** The 139-test suite +is green, so none of these are caught by existing coverage — every fix should land with a +regression test. -> **Status: 23 of the 24 findings are fully fixed on `main`** as of 2026-07-27, and the -> 24th ([B-16](#b-16)) is fixed as far as the crash goes — shipping the user guide in the -> image was deliberately deferred. Every entry below keeps its original description — the bug, why it -> mattered, and how it was meant to be fixed — and carries a **Fixed:** note recording -> what was actually done and where the regression test lives. The suite grew from 79 to -> 137 tests. Two entries were fixed differently from the plan (B-15 validates at startup -> rather than in a Pydantic validator, B-09 gained a bounded retry); both say so and why. -> The fixes were then run in the real Docker deployment — see -> [Runtime verification](#runtime-verification-2026-07-27) for what that actually -> confirmed and, more importantly, what it did **not**. +The recurring pattern across B-25, B-26, B-27, B-29 and B-36 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. Outgoing transactions reconcile; deposits do not. Bets and withdrawals write +their intent before broadcasting; the payout does not. Broadcast failures are audit-logged; +*pre*-broadcast failures (no client, insufficient pool funds) are not. -Findings are ordered by severity. Each entry is self-contained and follows the same shape: -symptom, root cause with `file:line` references, a `*Proposed fix:*` block, and a `*Test:*` -block naming the regression test to add (a few are marked manual where an automated test -would be testing the Docker image layout or the browser, not the code). Items already listed -under "Known gaps / TODO" in `CLAUDE.md` are cross-referenced rather than repeated, except -where this audit found the gap to be worse than documented. +**Single highest priority: make `paying_out` a recoverable, idempotent state** (record before +broadcast + startup resume + retry). That closes B-25, B-26 and half of the already-known +"scheduler doesn't resume" gap — i.e. every way the lottery currently stops and cannot +restart on its own. -## Summary - -| ID | Severity | Area | One-line | -|----|----------|------|----------| -| [B-01](#b-01) | Critical | Electrum client/listener | A dropped connection hangs the whole server permanently — no reconnect, no request timeout, no keepalive | -| [B-02](#b-02) | Critical | RBF / bets | A fee bump on a bet tx orphans `RoundParticipant.bet_txid`, wedging the round in `closing` forever | -| [B-03](#b-03) | Critical | Confirmation poller | One unresolvable txid aborts confirmation detection for every other pending tx | -| [B-04](#b-04) | Critical | Balance / UTXO state | No reconciliation path: a dropped tx freezes the spent UTXOs (and the user's funds) forever | -| [B-05](#b-05) | Critical | Admin config / payout | `fee_address` and numeric params are unvalidated; a bad value either wedges the payout or sends the commission to an unspendable script | -| [B-06](#b-06) | High | PSBT builder | Change outputs below the dust limit are created, making the transaction unrelayable | -| [B-07](#b-07) | High | Bets / withdrawals | A broadcast rejection surfaces as HTTP 500, bypassing the structured error contract | -| [B-08](#b-08) | High | Bets | Broadcast happens before the bet is persisted: a commit failure loses the money with no record | -| [B-09](#b-09) | High | Rounds | Race allows two simultaneously-`open` rounds, which permanently blocks all future rounds | -| [B-10](#b-10) | High | Admin / audit | `PUT /admin/config` writes no audit-log entry | -| [B-11](#b-11) | High | Rounds API | The displayed jackpot is not the amount the winner receives | -| [B-12](#b-12) | High | Auth | Registration enforces no password or username validation, unlike change-password | -| [B-13](#b-13) | Medium | Auth | `verify_password` turns a malformed stored hash into a 500 instead of a 401 | -| [B-14](#b-14) | Medium | Admin auth | Admin token compared with `!=` instead of a constant-time comparison | -| [B-15](#b-15) | Medium | Config | Empty `JWT_SECRET` / `XPRV_ENCRYPTION_KEY` are not rejected at startup | -| [B-16](#b-16) | Medium | Deployment | `GET /guida` is broken in Docker: `docs/` is never copied into the image (crash fixed; shipping the guide deferred — see entry) | -| [B-17](#b-17) | Medium | RBF | `_find_change_output` can shrink the recipient output instead of the change | -| [B-18](#b-18) | Medium | Scheduler | DB session held open across Electrum network calls during payout | -| [B-19](#b-19) | Medium | Electrum listener | `tip_height` is assigned without a monotonicity check (reorg) | -| [B-20](#b-20) | Low | RBF | `PendingTransaction.replaced_by_txid` is never written — dead column shown in the admin UI | -| [B-21](#b-21) | Low | Confirmation poller | Dead local `pending_ids`, plus attribute access on detached ORM objects | -| [B-22](#b-22) | Low | Frontend | Amounts rendered by raw division — floating-point artefacts visible to users | -| [B-23](#b-23) | Low | Frontend | Concurrent `withLoading` on the same button can leave it stuck on its loading label | -| [B-24](#b-24) | Low | API contract | The global exception handler answers with a bare-string `detail` | - ---- - -## Runtime verification (2026-07-27) - -The fixed code was built and run via `docker compose up -d --build` against mainnet. This -section separates what the running system **demonstrated** from what is still only covered -by unit tests — the distinction matters, because a green suite is not a working deployment. - -**Caveat on the image that produced this evidence:** it was built while the `Dockerfile` -still carried `COPY docs ./docs`, which was subsequently reverted (see [B-16](#b-16)). The -running container therefore does *not* match the current Dockerfile: `/guida` answers 200 -there, and will answer 404 after the next rebuild. Everything else below is unaffected. - -### Confirmed at runtime - -| Fix | Evidence from the live system | -|---|---| -| [B-15](#b-15) | The app serves traffic, so `validate_runtime_secrets()` passed against the real `.env` — and it is now on the startup path of every deploy. | -| [B-01](#b-01) (partly) | `app.main: Electrum endpoints (in rotation order): santantonio.sytes.net:50002` followed by `app.electrum.listener: Electrum connected to santantonio.sytes.net:50002` — the restructured session setup (`asyncio.wait` over the consumers, `wait_closed()` and the keepalive) connects and stays up against a real server. Only one endpoint is configured, so rotation itself is still only unit-tested. | -| [B-09](#b-09) | `ix_rounds_single_active` exists in the deployed DB and rounds keep opening and closing normally (196 rounds, one per ~98s cycle) — the index and the conflict-retry path do not interfere with normal round creation. | -| [B-04](#b-04) (schema) | The `failure_reason` column is present in the deployed DB; migration `8a1c4e7b2d90` applied cleanly to the live database. | -| [B-04](#b-04) (heuristic) | The one genuinely unknown piece — whether `_tx_exists_on_chain` recognizes a missing tx on *this* server — was tested directly: for a bogus txid the server answers `No such mempool or blockchain transaction`, which the substring check matches, so the function correctly returns `False`. The conservative direction is preserved: any message it does *not* recognize re-raises and leaves the row untouched. | -| [B-16](#b-16) | Confirmed as a bug that had really been firing, not a theoretical one: `logs/app.log` holds two pre-fix `ERROR [app.main] Unhandled error on GET /guida` entries (17:40 and 19:42 on 2026-07-26) ending in `RuntimeError: File at path docs/guida-utente.md does not exist.` | -| No regressions | Zero `ERROR`/`WARNING` lines since the restart on the fixed code. The only errors in the entire log predate it (the two `/guida` failures above, plus two unrelated `asyncio` entries from 2026-07-22). | - -### Still unverified outside the test suite - -Nothing has spent money since the restart: `pending_transactions` holds only rows that were -already `confirmed` beforehand, `withdrawals` is empty, and no `attempt_count` is above 1. -So these fixes have unit coverage and **no runtime evidence at all**: - -- [B-08](#b-08)/[B-07](#b-07) — the two-phase write and the rollback-on-refusal need a real - bet, and a genuinely rejected broadcast, to be seen working. -- [B-02](#b-02)/[B-20](#b-20) — needs an actual RBF bump (`rbf_timeout_seconds` must elapse - with the tx unconfirmed). -- [B-03](#b-03)/[B-04](#b-04) (behaviour) — needs a transaction that really goes missing. - The reconciler has been ticking every 120s with nothing to do, which is silent by design. -- [B-06](#b-06) — needs a UTXO set that produces sub-dust change. -- [B-17](#b-17) — needs a withdrawal attempt; the withdrawal path as a whole has still never - been exercised against a live broadcast, which was already true before this audit. -- [B-22](#b-22)/[B-23](#b-23) — browser-side, verifiable only by using the UI. - -### What remains open (not part of the 24) - -Unchanged by this work, and still the reason this isn't unattended-safe: - -- **Scheduler doesn't resume mid-flight rounds after a restart** — a round left in - `closing`/`drawing`/`paying_out` when the process dies stays stuck. Transaction-level - state now self-heals ([B-04](#b-04)); *round*-level state does not. -- **No automatic payout retry** — a failed payout is audit-logged (`payout_failed`, added by - [B-05](#b-05)) and visible in `/admin`, but acting on it is manual. -- **RBF still handles only the single-change-output case** — the difference is that failure - is no longer permanent: an unbumpable tx is eventually abandoned and its coins released. -- **`/guida` is not served in Docker** by current deliberate decision — the guide is being - reworked; re-adding `COPY docs ./docs` is all it will take. -- **No rate limiting anywhere**, **admin auth is one shared token** with no per-operator - identity, and **single-process assumptions** remain in the SSE broadcaster and the - per-user locks. -- **No automated integration tests against a live Electrum connection** — the verification - in this section was done by hand. +For limitations that are accepted by design rather than bugs (single-shared-token admin auth, +single-process assumptions, no user-facing history, etc.), see "Known gaps / TODO" in +[CLAUDE.md](CLAUDE.md). --- ## Critical -### B-01 +### B-25 — The payout has no two-phase write, unlike bets and withdrawals -**A dropped Electrum connection hangs the entire server permanently** +`rounds/scheduler.py:214` broadcasts, and only afterwards (`:229-251`) writes `payout_txid` +and the `PendingTransaction`. A crash in that window — and `docker-compose.yml` sets +`restart: unless-stopped`, so a crash means an automatic restart — leaves a payout on-chain +with **no record at all**: the round is stuck in `paying_out`, the reconciler has nothing to +resolve, and a manual retry would pay the winner a second time (pool UTXOs are not tracked in +`utxo_events`, so nothing reserves them). -*Files:* `app/electrum/client.py:31-63`, `app/electrum/client.py:85-99`, `app/electrum/listener.py:58-77` +This is exactly what B-08 fixed for `place_bet`/`request_withdrawal`; the same fix was never +applied to the path that moves the most money. -Three independent defects compose into a single absorbing failure state: +**Proposed fix.** Mirror the bet path: write `Round.payout_txid` plus a +`PendingTransaction(kind="payout", status="building")` and commit *before* +`client.broadcast()`, then promote to `"pending"` after. Teach +`reconcile.py:_promote`/`_abandon` to handle a `building` payout (promote if the tx is on +chain, otherwise clear `payout_txid` and leave the round for the retry routine). Since pool +UTXOs are invisible to `utxo_events`, `_abandon` cannot release them — so the payout builder +must additionally refuse to spend an outpoint already referenced by a non-terminal payout +`PendingTransaction`, which is what makes a retry safe against double-paying. -1. **The read loop's death never propagates.** `connect()` spawns `_read_loop` as a - detached task. When the socket closes, `readline()` returns `b""`, the loop breaks and - the task completes — but `_run_once` is blocked on - `asyncio.gather(self._consume_headers(...), self._consume_scripthash(...))`, and both - consumers are awaiting `asyncio.Queue.get()` on queues nobody will ever fill again. - The `gather` never returns and never raises, so the `except Exception` + - backoff-reconnect logic in `ElectrumListener.run()` is never reached. -2. **`self.client` stays non-`None`.** Every consumer of the client - (`RoundScheduler`, `ConfirmationPoller`, `RbfBumper`, `place_bet`, - `request_withdrawal`, and the `listener.client is None` guards in the bet/withdrawal - routes) therefore keeps treating a dead connection as live. -3. **`request()` has no timeout.** It registers a future in `self._pending`, writes to a - closed writer (`drain()` frequently does not raise on a half-closed socket) and then - `await future` — which nothing will ever resolve, because `_read_loop` is gone. Note - that `_read_loop`'s `finally` only fails the futures that existed at the moment it - died; every future created afterwards hangs forever. +### B-26 — A transient failure at payout time wedges the lottery permanently -There is also **no keepalive** (`server.ping`) anywhere. Electrum servers routinely drop -idle connections after ~10 minutes, so on a low-traffic instance this is close to -guaranteed rather than hypothetical. +`rounds/scheduler.py:166-169`: if `listener.client is None` when `_trigger_payout` starts, it +returns. `_trigger_payout` is called exactly once, from `_close_and_draw`, and `_tick` +ignores any round not in `open`/`closing` (`:58`). The round stays in `paying_out`, no new +round can open, and — unlike the `except Exception` branch — nothing is written to +`audit_log`, so `/admin` shows a stalled state with no explanation. -*Observable consequences:* deposits stop being credited; the tip stops advancing, so a -round in `drawing` waits in `_wait_for_next_block` forever; `ConfirmationPoller` blocks -on its first `get_transaction` and stops polling entirely; a `POST /bets` request hangs -indefinitely **while holding that user's `UserLocks` entry**, so every later bet or -withdrawal from the same user deadlocks behind it. +The payout runs immediately after a ~2-minute wait on a block, so an Electrum drop in that +window is entirely plausible. Same shape at `:215-217`: `InsufficientFundsError` returns +without calling `_log_payout_failure`. -*Reproduction:* start the app, kill the TCP connection out from under it -(`ss -K dst `, or block the port with a firewall rule). No reconnect log -line is emitted, `/rounds/current` keeps reporting the last known `chain_tip_height`, and -a bet request never returns. +CLAUDE.md lists "payout retry" as an accepted gap, but treats it as an operational +inconvenience; in practice it is a single point of failure that stops the whole platform. -*Proposed fix:* -- Wrap every `request()` in `asyncio.wait_for(...)` with an explicit timeout (10-15s) and - pop the future from `self._pending` on timeout. -- Add a keepalive task issuing `server.ping` every ~60s; a failed ping tears the - connection down. -- Make the read loop's termination observable: either include `self._read_task` in - `_run_once`'s `gather`, or set an `asyncio.Event` in `_read_loop`'s `finally` that - `_run_once` awaits alongside the consumers. On teardown, set `self.client = None` - *before* awaiting the reconnect backoff. +**Proposed fix.** (a) Call `_log_payout_failure` on *every* early return, with a reason in the +payload, so the operator sees it. (b) Make `_tick` handle `paying_out`: if the round has no +non-terminal payout `PendingTransaction`, re-run `_trigger_payout`. That turns every early +return into a retry rather than a dead end, and — combined with B-25's idempotency guard — +also covers the process-restart case. -*Test:* a unit test with a fake stream that closes mid-session must observe -`listener.client is None` and a reconnect attempt; a test that `request()` raises -`ElectrumError`/`TimeoutError` rather than hanging when no response arrives. +### B-27 — Every RBF bump resets the reconciler's abandon clock, so it never fires -**Fixed.** `app/electrum/client.py` now bounds every `request()` with -`asyncio.wait_for` (`_REQUEST_TIMEOUT_SECONDS = 15`), pops the orphaned future and tears -the connection down on timeout; `_read_loop`'s `finally` sets a `_closed` event, exposed -as `wait_closed()`; and `ping()` was added. `app/electrum/listener.py:_run_once` races the -two notification consumers against `wait_closed()` and a 60s keepalive with -`asyncio.wait(FIRST_COMPLETED)`, so a drop ends the session and `run()` reconnects. -`self.client` is cleared before the reconnect, so callers stop using a dead client. -Beyond the original finding, the listener now **rotates over a list of servers** -(`ELECTRUM_FALLBACK_SERVERS`, `parse_endpoints`): one dead server costs a single attempt -instead of an outage, and the backoff only sleeps once every server has had a turn. -Tests: `tests/unit/test_electrum_client.py` (timeout, `wait_closed`, pending-request -failure, endpoint parsing), `tests/unit/test_electrum_listener.py` (rotation, backoff -reset, no-endpoints case). +`tx/broadcast.py:122` sets `pending.broadcast_at = now` on each bump, but +`tx/reconcile.py:133` computes the 6-hour abandon deadline **from that same field**. -### B-02 +With the default `rbf_timeout_seconds = 900`, a transaction that is successfully bumped every +15 minutes but never mined resets the counter long before it can reach 6 hours: it is **never +abandoned**, its UTXOs never return to the user, and if it is a bet the round stays in +`closing` indefinitely (`scheduler.py:90-91`). `reconcile.py` exists precisely to prevent +this, and the bumper disarms it. -**An RBF fee bump on a bet permanently wedges the round** +**Proposed fix.** Split the field: keep `broadcast_at` as the *first* broadcast (never +rewritten — it is what `_is_due` must use) and add `last_broadcast_at`, updated by +`bump_fee` and used by `should_bump`. Alembic migration backfilling `last_broadcast_at = +broadcast_at`. -*Files:* `app/tx/broadcast.py:116-121`, `app/bets/confirmation.py:10-16`, -`app/rounds/scheduler.py:77-86`, `app/rounds/scheduler.py:92-98` +### B-28 — A hostile Electrum server (or a MITM) can choose the winner -`bump_fee` rebuilds the transaction, which changes its txid, and updates -`PendingTransaction.current_txid` accordingly. Nothing updates the domain row's copy of -that txid: `RoundParticipant.bet_txid` is written exactly once, at -`app/bets/service.py:80`, and never again (verified by grepping every write to -`bet_txid`). +`electrum/listener.py:167-186` accepts any header whose `height >= tip_height`: no +proof-of-work check, no linkage to the previous block hash. That header is the **sole source +of entropy for the draw** (`scheduler.py:129`). -When the bumped transaction confirms, `_on_bet_confirmed` looks the participant up by -`RoundParticipant.bet_txid == pending.current_txid`, finds nothing, and returns silently. -Consequences, in order: +In parallel, `electrum/client.py:104-106` sets `check_hostname = False` and +`verify_mode = CERT_NONE`. The comment justifies this with "the protocol's trust model is +server consensus" — but there is no consensus here: one server at a time, rotated over a list +of arbitrary third parties. So a hostile server, or anyone able to MITM a connection that +validates no certificate, can fabricate a header and thereby decide who wins every round. -- the participant stays `status == "broadcast"` forever; -- `RoundScheduler._tick` counts participants in `"broadcast"` before allowing the round to - close, so the round stays in `closing` **indefinitely**; -- since a new round cannot open while one is active (`get_active_round`), the entire - lottery stops; -- even if the round were forced closed, `_close_and_draw` only selects - `status == "confirmed"` participants, so that player would have paid the bet without - entering the draw. - -The same staleness affects `Withdrawal.txid`. It is less severe there, because -`_on_withdrawal_confirmed` keys off `pending.withdrawal_id`, so the status does update — -but the txid stored and shown to the user is the replaced one. - -*Reproduction:* place a bet, let `rbf_timeout_seconds` elapse without confirmation so -`RbfBumper` bumps it, then let the bumped tx confirm. `round_participants.status` remains -`broadcast`; the round never leaves `closing`. - -*Proposed fix:* make the bump update the domain row too. Either have `bump_fee` write -through to `RoundParticipant.bet_txid` / `Withdrawal.txid` (it already knows `kind`, -`user_id`, `round_id`, `withdrawal_id`), or — cleaner — stop keying confirmation handlers -on the txid at all: `_on_bet_confirmed` should resolve the participant via -`pending.round_id` + `pending.user_id`, which are immutable. Also set -`replaced_by_txid` (see [B-20](#b-20)) so the chain of replacements is auditable. - -*Test:* bump a pending bet, then run the confirmation handler with the new txid and assert -the participant flips to `confirmed`. - -**Fixed.** Both confirmation handlers now resolve their domain row by immutable -ids — `_on_bet_confirmed` by `(round_id, user_id)`, `_on_payout_confirmed` by `round_id` — -with the old txid lookup kept only as a fallback for pre-existing rows. Independently, -`bump_fee` calls a new `_retarget_txid_references()` that updates -`RoundParticipant.bet_txid`, `Withdrawal.txid`, `Round.payout_txid` **and** -`UtxoEvent.spent_txid` to the new txid, so no stored txid is left pointing at a -transaction that no longer exists. Tests: -`test_confirmation.py::test_bet_confirms_after_an_rbf_bump_changed_the_txid` (plus the -payout equivalent), `test_broadcast.py::test_bump_fee_retargets_every_stored_txid`. - -### B-03 - -**One unresolvable txid stops confirmation detection for everything else** - -*File:* `app/tx/confirmation.py:35-37` - -```python -for pending_id, txid, kind in [...]: - tx = await client.get_transaction(txid, verbose=True) # not guarded -``` - -If a txid is no longer knowable by the server — dropped from the mempool, replaced by an -RBF bump whose old entry is still `pending`, or simply an Electrum server that answers -with an error — `ElectrumError` propagates out of `poll_once` and **aborts the loop before -the remaining pending transactions are checked**. `ConfirmationPoller.run` logs it and -retries every 10s, failing at the same row every time. - -Because the poller is the single mechanism that confirms bets, payouts and withdrawals, -one stuck row means: no bet ever confirms again (so no round ever closes — see -[B-02](#b-02)), no payout ever completes, no withdrawal ever settles. Recovery requires -manual DB surgery. - -*Proposed fix:* wrap the per-transaction lookup in `try/except Exception`, log at -`warning` with the txid, and `continue`. Optionally track consecutive failures per row so -a permanently-unknown tx can be escalated to an operator (which is also the hook -[B-04](#b-04) needs). - -*Test:* `poll_once` with two pending rows where the first raises must still confirm the -second and return `1`. - -**Fixed.** The per-transaction lookup in `poll_once` is wrapped in -`try/except Exception`: it logs the txid at warning level and continues, leaving the -judgement about a permanently-unknown tx to the reconciler ([B-04](#b-04)). Test: -`test_confirmation.py::test_one_unresolvable_txid_does_not_block_the_others`, which -asserts the healthy row still confirms and the unknown one is left `pending` rather than -abandoned here. - -### B-04 - -**No reconciliation: a dropped transaction freezes the spent UTXOs forever** - -*Files:* `app/bets/service.py:69-73`, `app/withdrawals/service.py:65-68`, -`app/wallet/balance.py:9-18` - -`spent_txid` is set optimistically at broadcast time and is **never cleared anywhere in -the codebase**. `recompute_balance` only sums UTXOs with `spent_txid IS NULL`, so those -coins are permanently excluded from the user's balance. - -If the transaction never confirms and eventually disappears from the mempool — which -`CLAUDE.md` already acknowledges is a realistic outcome, since the RBF path raises -`RbfError` and gives up whenever there is no change output large enough to absorb the -bump — the UTXOs remain perfectly spendable on-chain while the database considers them -gone. The user's funds are silently lost from their point of view. - -More broadly, nothing in the system ever writes a terminal failure state: there is no -`status = "failed"` on `PendingTransaction`, `Withdrawal` or `RoundParticipant`, and no -startup routine that re-scans in-flight transactions against the chain. Combined with -`restart: unless-stopped` and the already-documented scheduler-resume gap, the system has -several states it can only be pulled out of by hand. - -*Proposed fix:* add a reconciliation task (and run it once at startup) that, for every -`PendingTransaction` in `pending` older than some threshold, asks the chain whether the -tx exists. If it is gone: -- mark the row `failed`, and set `replaced_by_txid`/a failure reason; -- clear `spent_txid` on the UTXOs it consumed (they are identifiable by parsing - `raw_tx_hex`'s inputs); -- call `recompute_balance`; -- roll the domain row back (delete the `RoundParticipant`, mark the `Withdrawal` - `failed`) and audit-log the event. - -*Test:* given a pending bet whose tx is unknown to the chain, the reconciler must restore -the user's balance and remove the participant. - -**Fixed** by a new component, `app/tx/reconcile.py` -(`PendingTransactionReconciler`, started from the lifespan and running every 120s, -including once at startup). Per non-terminal `PendingTransaction` it asks the chain -whether the tx exists: a `building` row whose tx is there gets promoted, a row whose tx is -gone is marked `failed` with a `failure_reason` (new column), its inputs released -(`spent_txid` cleared, only where it still matches this row's txid), the balance -recomputed, and the domain row rolled back — participant deleted, withdrawal marked -`failed`, payout txid cleared. Grace periods differ by state (120s for `building`, 6h for -`pending`) and a transport failure never abandons anything. Tests: -`tests/unit/test_reconcile.py` (6 cases, including the "broken connection must not release -coins" one). - -### B-05 - -**`fee_address` and the numeric config params are unvalidated** - -*File:* `app/api/routes/admin.py:70-80`, consumed at `app/rounds/scheduler.py:162-198` - -`PUT /admin/config` assigns whatever it is given straight onto the `RoundConfig` row. Two -distinct failure modes: - -1. **Malformed `fee_address`** → `build_payout_transaction` calls - `script.Script.from_address(fee_address)`, which raises `EmbitError`. `_trigger_payout` - only catches `InsufficientFundsError`, so the exception escapes through - `_close_and_draw` up to the `except Exception` in `RoundScheduler.run()`. The round is - left in `paying_out` with no retry (the payout-retry gap in `CLAUDE.md`), i.e. wedged. -2. **Well-formed but foreign `fee_address`** (e.g. a Bitcoin `bc1...`) → this is *worse*, - because it parses fine into a valid witness program. The payout is built, signed and - broadcast, and the 30 % commission of every round lands on a script nobody holds the - key for. This is exactly the failure mode `app/wallet/address.py` was written to - prevent for user withdrawals; the check was never applied to the operator-supplied - address. - -Numeric fields are equally unguarded: `round_duration_seconds = 0` (round expires the -instant it opens), `fee_rate_sat_vb = 0` (fee-less transactions are never relayed, so -every bet/payout hangs and the whole pipeline stalls), negative `bet_amount_sats`, -`draw_animation_seconds` far larger than the round itself. - -*Proposed fix:* validate in `RoundConfigUpdate` (Pydantic `field_validator` / -`Field(gt=0)`): -- `fee_address` must pass `is_valid_plm_address`; -- `bet_amount_sats`, `round_duration_seconds`, `fee_rate_sat_vb`, `rbf_timeout_seconds` - strictly positive, with sane upper bounds; -- `round_cooldown_seconds`, `draw_animation_seconds` non-negative. - -Independently, `_trigger_payout` should catch `Exception` around the build/broadcast, -audit-log the failure, and leave the round in a state a retry routine can pick up. - -*Test:* `PUT /admin/config` with a `bc1...` fee address and with `fee_rate_sat_vb = 0` -must both return 422/400 and leave the stored config untouched. - -**Fixed.** `RoundConfigUpdate` now validates: `fee_address` must pass -`is_valid_plm_address`, and every numeric field carries bounds (`fee_rate_sat_vb >= 1`, -`round_duration_seconds >= 30`, etc.). Separately `_trigger_payout` catches `Exception` -around the build/broadcast, logs it and writes a `payout_failed` audit entry so a stuck -round is visible in `/admin` rather than only in the log file. Automatic payout retry -remains an open pre-existing gap (documented in CLAUDE.md), unchanged by this fix. Test: -`test_admin.py::test_config_rejects_unusable_values`, parameterized over the `bc1…` -address, a broken checksum, `fee_rate_sat_vb=0`, `round_duration_seconds=0` and more. +**Proposed fix, in order of value.** (1) Validate headers before accepting them: check the +PoW against the claimed target and that `prev_block` matches the current tip; reject anything +that fails. (2) Do not trust one server for the draw — fetch the header for +`draw_block_height` from *several* endpoints in the rotation and require agreement before +using it as the seed. (3) Pin certificates (or verify hostnames) for the configured servers +rather than disabling verification wholesale. Longer term this is the argument for replacing +the v1 draw algorithm — CLAUDE.md already calls it a replaceable component — with a scheme +that does not depend on a single unauthenticated data source. --- ## High -### B-06 +### B-29 — `detect_external_spends` is irreversible and trusts a single response -**Dust change outputs are created** +`deposits/service.py:87` marks `spent_txid = "external-spend"` for any UTXO missing from the +current `listunspent`. There is **no path to undo it**: `credit_confirmed_utxos` skips +`(txid, vout)` keys that already exist, regardless of their spent status (`:19-31`). -*File:* `app/wallet/psbt_builder.py:98-99` and `:166-167` +One incomplete `listunspent` — a rotated-to server that is broken or behind, an empty reply on +error, or a reorg — permanently and silently zeroes a user's balance, recoverable only by +editing the database. Crediting is idempotent and conservative; debiting is neither, and it +acts on a single reply from a single unauthenticated server. -Both builders use `if change > 0:` as the sole condition for adding the change output. Any -leftover below the P2WPKH dust threshold (~294 sat at the standard 3000 sat/kvB dust -relay fee) produces a transaction that relaying nodes reject outright as `dust`. The -resulting `ElectrumError` is unhandled (see [B-07](#b-07)), so the user gets an opaque -HTTP 500 and cannot bet or withdraw at all until their UTXO set happens to change. +**Proposed fix.** Treat a missing outpoint as *evidence*, not proof. Require the same UTXO to +be absent across N consecutive refreshes (or confirm the spend by looking up the outpoint's +spending tx) before marking it, and skip the whole pass when `listunspent` returns empty for +an address the DB believes is funded. Make the mark reversible: re-crediting should clear +`spent_txid` when the sentinel value is present and the outpoint reappears as unspent. -*Proposed fix:* introduce a `DUST_LIMIT_SATS` constant; add the change output only when -`change >= DUST_LIMIT_SATS`, otherwise leave the remainder to the fee (and reflect that in -the returned `fee_sats`/`change_sats` so the accounting stays honest). Note the fee -estimate at `psbt_builder.py:86` already assumes two outputs unconditionally, so dropping -change does not underpay. +### B-30 — No deposit-side reconciler: one missed subscription means deposits are never credited -*Test:* build a transaction whose change lands at 100 sat and assert a single-output tx -with the remainder folded into the fee. +`electrum/listener.py:61-67` (`address_for_new_user`) fires `asyncio.create_task(...)` without +retaining the reference and without handling exceptions. If `self.client` becomes `None` +between the check and the task running, the `assert` at `:163` raises inside an orphan task +and the exception is swallowed. -**Fixed.** `DUST_LIMIT_SATS = 294` added to `app/wallet/psbt_builder.py`; both -builders fold sub-dust change into the fee instead of creating the output, and refuse a -sub-dust recipient/winner/commission amount with a dedicated error code. Tests in -`test_psbt_builder.py`: dust change folded into the fee (asserting nothing vanishes — -inputs still equal outputs plus fee), change exactly at the limit still paid back, and a -dust-sized recipient refused. +What makes this serious is what happens next: deposits are credited **exclusively** by +scripthash notifications. There is no periodic routine reconciling balances against the chain +(the reconciler only covers outgoing transactions). On a healthy keepalive'd connection there +are no reconnects, so a lost subscription is never recovered and that user **never sees their +deposits**, indefinitely. -### B-07 +**Proposed fix.** Two parts. (a) Make the subscription reliable: retain the task, log its +exceptions, and retry with backoff instead of relying on a reconnect. (b) Add the missing +safety net — a periodic sweep (say every few minutes, similar in shape to +`PendingTransactionReconciler`) that re-runs `_refresh_user` for users whose scripthash is not +in `_scripthash_to_user`, or simply round-robins over all users so a missed notification is +always eventually caught. -**Broadcast failures surface as HTTP 500** +### B-31 — Reconnect costs O(users) sequential round-trips and stalls the draw -*Files:* `app/bets/service.py:67`, `app/withdrawals/service.py:63` +In `_run_once` the order is: subscribe headers → `_subscribe_all_users()` → *then* start the +consumer tasks (`electrum/listener.py:118-134`). `_subscribe_all_users` iterates users +**sequentially**, and each iteration is a subscribe plus a `listunspent` plus a DB write +(`:154-165`). -`await client.broadcast(built.raw_hex)` is not guarded. Every node-side rejection — -fee below the relay minimum, dust output ([B-06](#b-06)), mempool conflict, non-standard -tx — raises `ElectrumError`, which the global handler in `app/main.py:74-77` turns into -`{"detail": "internal server error"}`. That bypasses the whole error contract documented -in `app/api/errors.py`: no `code`, nothing translatable, no actionable message. +At 5.000 users that is 10.000 serialized round-trips (15s timeout each). Throughout, +`_consume_headers` is not running, so `tip_height` is frozen and `_wait_for_next_block` makes +no progress: **a reconnect stalls an in-flight draw** for the entire resubscribe. And since +registration has no rate limiting, the user count is attacker-controlled. -*Proposed fix:* wrap the broadcast, and raise -`BetError("broadcast_failed", ...)` / `WithdrawalError("broadcast_failed", ...)` carrying -the node's message in `params`. Add `error.broadcast_failed` to all 7 languages in -`app/static/i18n.js` (per the i18n contract in `CLAUDE.md`). Answer 502/503 rather than -400, since the failure is not the client's fault. +**Proposed fix.** Start the consumer tasks (headers especially) *before* resubscribing, so tip +updates keep flowing during the sweep. Batch the resubscribe with bounded concurrency +(e.g. `asyncio.Semaphore(20)` over `asyncio.gather`) instead of a serial loop, and decouple +the `listunspent` refresh from the subscribe so the initial refresh can proceed in the +background. -*Test:* `place_bet` / `request_withdrawal` with a client stub whose `broadcast` raises must -raise `BetError`/`WithdrawalError` with code `broadcast_failed` — and must leave no -`spent_txid`, no participant and no pending row behind. +### B-32 — `bump_fee` can loop forever on rebroadcasts the node always rejects -**Fixed.** Both services wrap the broadcast and raise -`BetError`/`WithdrawalError("broadcast_failed", …)`; the routes answer **502** rather than -400, since the network refused it, not the caller. `error.broadcast_failed` was added to -all 7 languages in `i18n.js` (along with `amount_below_dust_limit`, -`withdrawal_to_own_address`, `internal_error`, `guide_unavailable`) — key parity verified, -123 keys per language. Tests: -`test_bets.py::test_failed_broadcast_reports_the_broadcast_failed_code` and the withdrawal -equivalent. +`tx/broadcast.py:87-88` forces `fee_delta = 1` when `fee_delta <= 0`. A **one-satoshi** total +fee increase violates BIP125 rule 4 (a replacement must pay at least the incremental relay fee +times its own size), so the node rejects it. `bump_fee` raises before updating `pending`, so +`fee_rate_sat_vb` never advances and the next tick **retries with identical parameters, every +30 seconds, forever**. -### B-08 +This triggers whenever the real fee exceeds the estimate — i.e. whenever dust change was +absorbed into the fee, which is an explicitly supported path +(`wallet/psbt_builder.py:105-107`). -**Bets are broadcast before they are persisted** +Related, same function: `new_fee_rate = pending.fee_rate_sat_vb + 1` on every bump, with **no +ceiling**. A transaction stuck for a day reaches ~96 sat/vB, eating the user's change, and it +ignores the `le=10_000` bound the admin panel enforces on the config field. -*File:* `app/bets/service.py:67-94` +**Proposed fix.** Compute the delta from the actual replacement vsize +(`fee_delta = max(new_fee - old_fee, ceil(vsize * incremental_relay_rate))`) so the bump is +always relay-valid. Cap `new_fee_rate` at the configured maximum and raise `RbfError` once +reached, so the transaction falls through to the reconciler (which needs B-27 fixed to +actually act on it) rather than being retried indefinitely. -The order today is: broadcast → mutate `spent_txid` → insert `RoundParticipant` + -`PendingTransaction` → `commit()`. If the commit fails, or the process dies in that -window, the transaction is already irreversibly on the chain: the money has moved to the -pool address, but **no record of the bet exists** — no participant (so no entry in the -draw), no pending row (so no RBF, no confirmation tracking), and the UTXOs are not even -marked spent, so the next bet attempt will try to double-spend them. +### B-33 — No brute-force protection on a custodial wallet -The same shape exists in `request_withdrawal`, where the `Withdrawal` row is flushed -before broadcast but only committed after. +`POST /auth/login` (`auth/routes.py:83`) has no rate limiting, no lockout, no delay and no +CAPTCHA, and the password minimum is 8 characters. Argon2 slows a single attempt but not a +patient distributed attack against an enumerable username list — and `409 username_taken` on +registration is a perfect enumeration oracle. -*Proposed fix:* persist the intent first. Insert the `PendingTransaction` (and the -`RoundParticipant`) in a `building`/`unbroadcast` state, commit, then broadcast, then flip -to `broadcast` in a second commit. A crash between the two leaves a row the -reconciliation task from [B-04](#b-04) can resolve against the chain in either direction. +"No rate limiting" is listed as a generic known gap; on a system where guessing a password +means **withdrawing someone's funds**, it deserves to be tracked separately and treated as a +blocker. -*Test:* with a client stub that broadcasts successfully but a session whose second commit -raises, the `PendingTransaction` must still exist afterwards (in its pre-broadcast state) -rather than the bet vanishing entirely. +**Proposed fix.** Per-username *and* per-IP throttling with exponential backoff on failed +logins (`slowapi`, or a small DB-backed counter — but note the in-process caveat if workers +are ever scaled). Return an identical response for unknown-user and wrong-password. Rate-limit +registration too, which also bounds B-31's attacker-controlled user count. -**Fixed.** Both `place_bet` and `request_withdrawal` are now two-phase: write -the rows in a `building` state and commit, *then* broadcast, then promote to -`broadcast`/`pending` in a second commit. A failed broadcast runs a release helper that -frees the reserved UTXOs, restores the balance, removes the participant (or marks the -withdrawal `failed`) and audit-logs it. A crash between the phases leaves a `building` row -for the reconciler ([B-04](#b-04)) to resolve either way. Tests: -`test_bets.py::test_bet_is_persisted_before_it_is_broadcast` — which probes committed state -from an independent session *during* the broadcast, and which caught a real mistake in the -first draft of this fix (the `_pending_transaction` helper still hardcoded -`status="pending"`, so rows were born already-broadcast and would have got the reconciler's -6-hour grace instead of 120s) — plus the two rollback tests. +### B-34 — Password change and admin reset do not invalidate existing sessions -### B-09 +Neither `/users/me/change-password` nor `/admin/users/{id}/reset-password` invalidates +already-issued JWTs (24h default lifetime, no revocation, no `token_version` on the user). The +admin reset exists precisely for the "account compromised" case and **does not evict the +attacker**. -**Two rounds can be opened concurrently, blocking every future round** - -*File:* `app/rounds/service.py:44-60` - -`open_new_round_if_needed` performs a read (`get_active_round`) followed by an insert with -no lock and no database-level uniqueness guarantee, and it is called both from -`RoundScheduler._tick` and from **every** `place_bet`. Two concurrent callers can both -observe "no active round" and both insert a row with `status="open"`. - -`get_active_round` then quietly hides the problem — `select(...).order_by(Round.id.desc())` -+ `scalar()` returns only the newest — while the older round stays `open` forever, never -ticked, never closed. Since `get_active_round` matches on status, no further round can -ever open once the newer one closes: the lottery stops for good. - -*Proposed fix:* the simplest robust option is to make the `RoundScheduler` the only writer -and have `place_bet` merely read the active round (rejecting the bet if there is none — -the scheduler opens one within 5s anyway). Additionally add a DB guard: a partial unique -index over `status IN ('open','closing','drawing','paying_out')`, or an advisory/serialized -transaction around the read-then-insert. `get_active_round` should also log loudly if it -ever sees more than one row. - -*Test:* two concurrent `open_new_round_if_needed` calls on separate sessions must yield -exactly one round. - -**Fixed** at the database level: `ix_rounds_single_active`, a unique index over -the constant expression `(1)` restricted to the active statuses (model + -migration `8a1c4e7b2d90`, which first closes any pre-existing duplicates, keeping the -newest — verified against a DB seeded with two active rounds). `open_new_round_if_needed` -catches the resulting `IntegrityError`, rolls back and returns the winner's round. -**Deviation from the plan:** rather than making the scheduler the only writer (which would -have meant the first bet after a cooldown couldn't open a round), it keeps both callers and -adds a bounded retry — a conflict where nothing is active yet means the winner simply -hadn't committed, and a bet must not fail on that timing. `get_active_round` also logs -loudly if it ever sees more than one active round. Tests in `test_rounds_service.py`: the -race-recovery path (forced deterministically by making the first look miss the existing -round — real concurrency on a shared in-memory SQLite connection isn't isolated enough to -test this honestly), the DB refusing a second active round, and closed rounds coexisting -with an active one. - -### B-10 - -**`PUT /admin/config` writes no audit-log entry** - -*File:* `app/api/routes/admin.py:70-80` - -`/admin/pause` and `/admin/resume` both call `write_audit_log`; the config update — which -can change `fee_address` (where 30 % of every pool goes), `bet_amount_sats` and the fee -rate — writes nothing. This contradicts `CLAUDE.md`, which states that `audit_log` -records *what* changed (only the *who* is documented as missing). - -Secondary issue: `paused` is part of `_CONFIG_FIELDS`, so it can be toggled through -`PUT /config`, silently bypassing the deliberately audit-logged `pause`/`resume` -endpoints. - -*Proposed fix:* write a `config_updated` audit entry containing the changed fields as -`{field: {"from": old, "to": new}}` — computed before assignment. Remove `paused` from -`_CONFIG_FIELDS` (keep it in the response model) so the pause switch has exactly one -audited path. - -*Test:* `PUT /admin/config` changing `fee_address` must produce exactly one -`config_updated` audit row carrying both the old and the new value; `paused` must be -rejected (or ignored) on that endpoint. - -**Fixed.** `PUT /admin/config` computes a before/after diff and writes a -`config_updated` audit entry (`{field: {"from": …, "to": …}}`), skipping no-op updates. -`paused` was removed from `_CONFIG_FIELDS` so the maintenance switch has exactly one -audited path (`/admin/pause`, `/admin/resume`); it remains in the response model. Tests: -`test_admin.py::test_config_update_is_audit_logged`, the no-op case, and -`test_pause_cannot_be_toggled_through_the_config_endpoint`. - -### B-11 - -**The displayed jackpot is not what the winner receives** - -*File:* `app/api/routes/rounds.py:118-144` - -```python -pool_amount_sats = participant_count * config.bet_amount_sats -jackpot_sats = pool_amount_sats * 70 // 100 -``` - -Three independent divergences from the amount actually paid out: - -1. The real pool is `sum(p.bet_amount_sats)`, and each participant's `bet_amount_sats` is - `recipient_sats` — the bet amount **minus that bet's network fee** - (`psbt_builder.build_signed_transaction`). -2. The payout deducts its own network fee from the winner's 70 % share - (`build_payout_transaction`), so the winner receives strictly less than 70 % of the - pool. -3. `participant_count` counts every `RoundParticipant` row, including bets still in - `broadcast` that may never confirm — and those are excluded from the draw and the pool - by `_close_and_draw`. - -Plus: it multiplies by the *current* `bet_amount_sats`, which an operator can change -mid-round, retroactively changing the advertised jackpot of a round already in progress. - -The in-code comment explicitly promises "what's displayed should match what the winner -actually receives", which is the opposite of the behaviour. - -*Proposed fix:* compute the pool from the participants' stored `bet_amount_sats` -(optionally restricted to `confirmed`), derive the 70 % share from that, and either -subtract an estimated payout fee or relabel the field as an estimate in the UI. Keep -`participant_count` as-is for display but consider exposing `confirmed_count` separately. - -*Test:* with two confirmed participants whose stored `bet_amount_sats` is below the -configured `bet_amount_sats` (fee already deducted), `GET /rounds/current` must report a -`jackpot_sats` derived from the stored amounts, and must not change when -`RoundConfig.bet_amount_sats` is edited mid-round. - -**Fixed.** `GET /rounds/current` now sums the participants' stored -`bet_amount_sats` (each already net of its own bet's fee) instead of multiplying -`participant_count` by the current configured amount. The remaining, unavoidable -imprecision — the payout tx's own fee, deducted from the winner's share and unknowable -until the payout is built — is documented in the code rather than silently promised away. -Test: `test_rounds_route.py::test_jackpot_comes_from_the_participants_actual_bets`, which -also asserts that editing `bet_amount_sats` mid-round no longer moves a running round's -jackpot. - -### B-12 - -**Registration has no password or username validation** - -*File:* `app/auth/routes.py:18-56` - -`POST /users/me/change-password` enforces `_MIN_PASSWORD_LENGTH = 8` -(`app/api/routes/users.py:15`), and the frontend re-checks it. `POST /auth/register` -enforces nothing: an empty username and a one-character password are both accepted, and -there is no `minlength` on the registration inputs in `app/static/index.html` either. On a -custodial system holding real funds, this is the wrong default. - -Secondary defect in the same handler: the `_MAX_REGISTER_RETRIES` loop catches *any* -`IntegrityError`. A username collision that slips past the pre-check (concurrent -registration) is therefore retried five times and finally reported as -`derivation_index_conflict`, which is misleading for both the user and the operator. - -*Proposed fix:* validate `username` (length, allowed character set) and `password` -(shared minimum-length constant, reused by `users.py`) in `RegisterRequest`; mirror the -constraints in the HTML form. Inspect the `IntegrityError` and re-raise `username_taken` -when it is the username constraint that failed. - -*Test:* `POST /auth/register` with an empty username and with a 3-character password must -both be rejected (422/400) and create no user; a registration racing an existing username -must answer `username_taken`, not `derivation_index_conflict`. - -**Fixed.** `MIN_PASSWORD_LENGTH` moved to `app/auth/security.py` and is now -shared by registration and the password change, so the two can't drift. `RegisterRequest` -constrains username (3–32 chars, `[A-Za-z0-9_.-]`) and password; the HTML form mirrors it -with `minlength`/`pattern`/`required`, and `register()` pre-checks the length so the -failure is immediate and translated. The `IntegrityError` handler now distinguishes a -username collision (answers `username_taken`) from a derivation-index collision (retries). -Tests: `test_users.py::test_register_rejects_weak_credentials` (parameterized) and the -positive case. +**Proposed fix.** Add a `token_version` (or `password_changed_at`) column on `User`, embed it +in the JWT claims, and reject any token whose value is stale in +`auth/dependencies.py:get_current_user`. Bump it on both endpoints. --- ## Medium -### B-13 +### B-35 — Every API timestamp is naive, so the frontend renders it in the wrong timezone -**A malformed stored hash becomes a 500 instead of a 401** +Verified empirically: the `DateTime` columns carry no timezone, so SQLite returns naive +datetimes and `.isoformat()` produces `2026-07-27T06:56:47.489110` — **no `Z`**. JavaScript's +`new Date()` parses that as **local time**, so every date in `/admin` (rounds, pending +transactions, audit log, via `fmtDate` in `app/static/admin.js:50`) and `created_at` in +`/users/me` display two hours off in Italy. -*File:* `app/auth/security.py:16-20` +The codebase knows about this — `api/routes/rounds.py` calls `.replace(tzinfo=timezone.utc)` +on `opened_at` explicitly — but the fix was never applied systematically. -`verify_password` catches only `VerifyMismatchError`. Argon2 raises `InvalidHashError` for -a hash it cannot parse and `VerificationError` for other verification failures, both of -which escape as an unhandled 500. +**Proposed fix.** Make the columns `DateTime(timezone=True)` (Alembic migration) so the value +round-trips as aware, rather than patching each call site. Until then, at minimum a shared +serialization helper that stamps UTC, used by every `.isoformat()` in the API layer. -*Proposed fix:* catch `argon2.exceptions.VerificationError` (the superclass of -`VerifyMismatchError`) plus `InvalidHashError` and return `False`, so an unusable stored -hash reads as "wrong password" rather than as a server fault. +### B-36 — `_wait_for_next_block` waits forever, with no timeout and no visibility -*Test:* `verify_password("x", "not-a-hash")` returns `False`; `POST /auth/login` against a -user row with a corrupted `password_hash` answers 401. +`rounds/scheduler.py:158-161` loops until a higher block arrives. No timeout, no log, no audit +entry. If the connection dies in a way that stops the tip advancing, the round sits in +`drawing` indefinitely and **the admin panel shows nothing at all** — just a frozen state with +no explanation. -**Fixed.** `verify_password` catches `VerificationError` (the superclass of -`VerifyMismatchError`) and `InvalidHashError` separately, returning `False` in both cases -and logging the unparseable-hash case as an error, since that one is a data problem worth -noticing. Tests: `test_security.py::test_verify_password_returns_false_for_an_unparseable_hash`, -plus one confirming a genuinely wrong password is still rejected. +**Proposed fix.** Log progress periodically while waiting, and past a threshold (a few +multiples of the 120s block time) write a `draw_stalled` audit entry so it surfaces in +`/admin`. Surface the wait in `GET /rounds/current` too (it already returns +`chain_tip_height`; `draw_waiting_since` would make the stall self-evident to users). -### B-14 +### B-37 — Displayed balance and spendable balance diverge, and the error does not explain it -**Admin token compared non-constant-time** +After a bet the change is unconfirmed, so `cached_balance_sats` ≈ 0 while the UI shows +`pending_balance_sats` (the real figure). A withdrawal attempted right after validates against +**confirmed** UTXOs (`withdrawals/service.py:54-60`) and answers `insufficient_balance`. -*File:* `app/api/routes/admin.py:20-22` +The user sees "1.000 PLM" on screen and is told they have no funds. The mechanism is a +documented design decision, but the error does not distinguish "you don't have the money" from +"your money is waiting to confirm" — two very different situations for whoever reads it. -`x_admin_token != settings.admin_token` is a short-circuiting comparison. This token gates -the private-key export endpoint, so it deserves a constant-time comparison. +**Proposed fix.** A distinct error code (e.g. `balance_pending_confirmation`) raised when the +requested amount is covered by `pending_balance_sats` but not by the confirmed balance, +carrying the pending amount in `params`, plus its `error.*` entry in all 7 languages. The +withdrawal form should also cap/hint the max against the confirmed balance rather than the +displayed one. -*Proposed fix:* `secrets.compare_digest(x_admin_token, settings.admin_token)`, keeping the -existing "empty configured token means always deny" short-circuit *before* it (compare_digest -on two empty strings returns `True`). +### B-38 — The 500-subscriber SSE cap is a zero-cost DoS of the realtime feature -*Test:* the existing admin auth tests still pass, plus one asserting an empty -`ADMIN_TOKEN` setting denies an empty `X-Admin-Token` header. +`GET /rounds/stream` requires no authentication and each connection takes a slot on a +**global** counter (`rounds/events.py:33-38`). Anyone opening 500 connections degrades every +real user to polling. The comment describes it as a defensive cap; it is in fact the vector, +not the defence. -**Fixed.** `require_admin` uses `secrets.compare_digest`, with the -empty-configured-token check kept *ahead* of it — `compare_digest("", "")` returns `True`, -so the original order would have opened the admin panel on any instance without an -`ADMIN_TOKEN`. Covered by the existing admin auth tests. +**Proposed fix.** Cap per client IP (and, once available, per authenticated user) rather than +globally, and evict the oldest idle subscriber instead of refusing new ones. The reverse proxy +is the right place for the connection-count limit — Caddy can enforce it before the request +reaches the app. -### B-15 +### B-39 — SQLite with no WAL, no `busy_timeout`, and five concurrent writer tasks -**Empty secrets are not rejected at startup** +`db/base.py:6` calls `create_async_engine(settings.database_url)` with no `connect_args`, and +there is no `PRAGMA` anywhere in the repo (verified by grep). Without `journal_mode=WAL` +readers block writers, and the concurrent writers are five background tasks plus every HTTP +handler. `database is locked` under load is realistic, and nothing handles it. -*File:* `app/config.py:13-18` +**Proposed fix.** Set `journal_mode=WAL`, `synchronous=NORMAL` and a `busy_timeout` of a few +seconds on connect (a `connect` event listener on the engine, applied only for the SQLite +dialect), and retry `OperationalError: database is locked` in the background loops. Longer +term this is an argument for PostgreSQL, which the single-process constraints in CLAUDE.md +also point at. -`jwt_secret` and `xprv_encryption_key` both default to `""`. With an empty `JWT_SECRET`, -PyJWT raises `InvalidKeyError: HMAC key must not be empty` on every login and -registration — a 500 with no hint about the real cause (verified locally). With an empty -`XPRV_ENCRYPTION_KEY`, Fernet fails on the first key derivation instead. Either way the -container starts up healthy and only fails once a user touches the broken path. +### B-40 — `bump_fee` holds a DB session open across N network calls -*Proposed fix:* a Pydantic `field_validator` (or `model_validator`) on `Settings` rejecting -empty `jwt_secret`/`xprv_encryption_key`, with a minimum length on `jwt_secret` (32 bytes, -per the `InsecureKeyLengthWarning` PyJWT already emits in the test suite). The process must -refuse to boot instead of half-working. Note the tests currently rely on a short secret, so -they need updating alongside. +`tx/broadcast.py:80` issues one `get_transaction` **per input** (up to 15s each) and then a +`broadcast`, all with the session open. This is precisely the pattern B-18 removed from +`_trigger_payout` via its three-phase structure; it survives here. -*Test:* constructing `Settings(jwt_secret="")` raises `ValidationError`. +Side note in the same function: `_prevout_amount` does `round(value_coins * 100_000_000)` on a +float from the server — acceptable at these magnitudes, but it is floating-point money +arithmetic in a codebase that is otherwise strictly integer-satoshi. -**Fixed**, but **not as planned.** A Pydantic `field_validator` on `Settings` -would have run at import time in every module that reads config — including the whole test -suite, which has no `.env` — so a fresh clone would have failed at collection. Instead -`validate_runtime_secrets()` (in `app/config.py`) is called from the app's lifespan: the -server still refuses to serve half-configured, without coupling imports to a gitignored -file. It reports all problems at once and treats an empty `ADMIN_TOKEN` as non-fatal -(`require_admin` already denies everything, so the effect is a locked panel, not an open -one). Tests: `tests/unit/test_config.py` (6 cases, using `_env_file=None` so a developer's -real `.env` can't influence the result). +**Proposed fix.** Restructure into the same three phases: read what is needed and close the +session, do the chain work, then reopen to persist. For the float: prefer the raw (non-verbose) +transaction and parse the output value as an integer with `embit`, which is what +`reconcile.py:_release_inputs` already does for inputs. -### B-16 +### B-41 — All confirmation logic depends on `verbose=True`, which is not universally supported -**`GET /guida` is broken in Docker** +`poll_once`, `reconcile._tx_exists_on_chain` and `bump_fee` all call +`blockchain.transaction.get(txid, True)`. Several Electrum server implementations and versions +reject the verbose flag ("verbose transactions are currently unsupported"). Falling back onto +such a server means **no confirmations, no reconciliation, no bumps** — and the code would read +that as a transport error and stay silent. -*Files:* `Dockerfile:38-42`, `app/main.py:98-103` +Related: `reconcile.py:83` decides whether to **abandon a transaction** by substring-matching +the error text (`"missing"`, `"not found"`, `"no such"`, `"unknown"`). It works against +ElectrumX; it is fragile as the basis for a decision that releases funds. -The image copies `pyproject.toml`, `app/`, `migrations/`, `alembic.ini` and `scripts/`. -`docs/` is never copied (it is not in `.dockerignore` — it is simply not `COPY`-ed), so -`FileResponse("docs/guida-utente.md")` raises inside the container and the help link in -the navbar returns a 500 in every real deployment. - -*Proposed fix:* add `COPY docs ./docs` to the `Dockerfile` (treating the guide as a shipped -asset), or move the guide under `app/static/` and serve it from there. Whichever is chosen, -`GET /guida` should degrade to a 404 with a clear message rather than an unhandled -exception when the file is absent. - -*Test:* not unit-testable as-is (it depends on the image layout) — verify with -`docker compose up -d --build && curl -k https://localhost/guida`, and add that check to -`docs/running-the-server.md`'s smoke list. - -**Partially fixed, by explicit decision.** The crash is gone: `GET /guida` checks -the file exists and answers a structured 404 (`guide_unavailable`, translated in all 7 -languages) with an error logged, instead of raising and returning a 500. - -Shipping `docs/` in the image (`COPY docs ./docs`) was written and then **reverted at the -owner's request** — the user guide is going to be reworked first, so there is no point -baking the current one into the image. Net effect today: in Docker, `/guida` answers 404 -rather than serving the guide, and the navbar link leads nowhere useful. That is a known, -accepted state, not an oversight; adding the `COPY` line is all it takes once the guide is -ready. Verification once it is: `docker compose up -d --build && curl -k https://localhost/guida`. - -### B-17 - -**`_find_change_output` can shrink the recipient output** - -*File:* `app/tx/broadcast.py:59-63` - -It returns the *first* output whose address matches the sender's own address. Nothing -prevents a withdrawal to the user's own address (`request_withdrawal` never compares -`external_address` with `user.address`), in which case output 0 is the recipient and -output 1 is the change — and a fee bump would reduce the recipient output. The same case -makes `compute_pending_balance` (`app/wallet/balance.py:55-59`) count both outputs, so the -displayed pending balance double-counts the withdrawn amount. - -*Proposed fix:* reject a withdrawal whose destination is the user's own address (a -distinct error code — it is a user mistake, not a system limit), and/or identify the -change output by index rather than by address, recording it on `PendingTransaction` at -build time. - -*Test:* `request_withdrawal` to `user.address` is rejected; and given a hand-built tx with -two outputs to the same address, the bump reduces the *change* one (the last), leaving the -recipient amount untouched. - -**Fixed.** `request_withdrawal` rejects a destination equal to the user's own -deposit address with its own error code — it was a no-op that cost a fee, and it was the -only way for the recipient and change outputs to be indistinguishable by address (which -would have made a fee bump shrink the recipient output, and made -`compute_pending_balance` count the amount twice). Test: -`test_withdrawals.py::test_withdrawal_to_own_address_is_rejected`, which also asserts no -UTXO was touched. - -### B-18 - -**DB session held open across Electrum network calls during payout** - -*File:* `app/rounds/scheduler.py:162-220` - -`_trigger_payout` opens a session and, inside it, awaits `client.listunspent(...)` and -`client.broadcast(...)` before committing. On SQLite that holds the write lock for the -duration of two network round-trips (unbounded, per [B-01](#b-01)); on Postgres it becomes -a long-running transaction. - -*Proposed fix:* restructure into three phases — read config/winner and close the session → -do the network work (`listunspent`, build, `broadcast`) → reopen a session to persist the -result and the `PendingTransaction`. This also makes the [B-05](#b-05) error handling -easier to place, since the failure-prone part is no longer inside a transaction. - -*Test:* the existing payout tests must still pass; add one asserting no session is open -while the stub client's `broadcast` is being awaited (e.g. by having the stub attempt a -write through a second session). - -**Fixed.** `_trigger_payout` is now explicitly three phases — read (session -closed), build+broadcast, persist — so no session is held across a network call. The -restructuring is also what made [B-05](#b-05)'s error handling easy to place, since the -failure-prone part is no longer inside a transaction. Covered by the existing scheduler -tests. - -### B-19 - -**`tip_height` is assigned without a monotonicity check** - -*File:* `app/electrum/listener.py:92-101` - -`self.tip_height = header["height"]` accepts a lower height on a reorg. Since -`_wait_for_next_block` compares `self._listener.tip_height <= tip_at_close`, a regression -silently extends the wait by a block. - -*Proposed fix:* full reorg handling is out of scope for v1 by explicit design decision, but -the assignment should be guarded: keep `max(self.tip_height, header["height"])` and log a -warning when a header arrives with a lower height, so the condition is at least visible in -the logs when it happens. Do not update `tip_header_hex` from a header that loses this -comparison, or height and hash would describe different blocks. - -*Test:* feeding the consumer a header at height N then N-1 leaves `tip_height == N` and the -hash unchanged. - -**Fixed.** Header handling moved into `_apply_header()`, which refuses a height -below the current tip, logs a warning when that happens, and applies height and hex -together — a losing header's hex must not be stored, since that hex is the draw's entropy -source and a mismatched pair would be worse than a stale one. Test: -`test_electrum_listener.py::test_tip_never_moves_backwards`. - -## Low / hygiene - -### B-20 - -**`replaced_by_txid` is never written** - -*Files:* `app/db/models.py:119`, `app/tx/broadcast.py:116-120`, -`app/api/routes/admin.py:261` - -`bump_fee` overwrites `current_txid` in place and never records what the old txid was. The -column exists, is selected, and is rendered in the admin "Transazioni pendenti" table, -where it therefore always shows `—`. - -*Proposed fix:* set it in `bump_fee` (naturally part of the [B-02](#b-02) fix) so the -replacement chain is auditable. Note the column semantics are the reverse of what the name -suggests for an in-place update — it will hold the *previous* txid, so either rename it -(`previous_txid`, via a migration) or document the direction on the model. - -*Test:* after a bump, the row's `replaced_by_txid` holds the pre-bump txid and -`current_txid` the new one. - -**Fixed.** `bump_fee` records the pre-bump txid in `replaced_by_txid`. The -model now documents that the column points *backwards* despite its name (renaming it would -need a migration and would churn the admin UI; the direction is stated on the field -instead). Test: covered by -`test_broadcast.py::test_bump_fee_retargets_every_stored_txid`. - -### B-21 - -**Dead code and detached-object access in the confirmation poller** - -*File:* `app/tx/confirmation.py:29-35` - -`pending_ids` (line 32) is computed and never used. Line 35 rebuilds the same tuples from -ORM objects whose session has already been closed; it works today only because -`expire_on_commit=False` and no commit intervened, so flipping that engine setting would -break it silently. - -*Proposed fix:* drop `pending_ids` and select the plain columns -(`select(PendingTransaction.id, PendingTransaction.current_txid, PendingTransaction.kind)`) -instead of hydrating entities, so nothing outlives the session. - -*Test:* covered by the existing `tests/unit/test_confirmation.py` — it must stay green with -`expire_on_commit=True` forced on the test session factory. - -**Fixed.** `poll_once` selects plain columns -(`select(PendingTransaction.id, .current_txid, .kind)`) instead of hydrating entities, so -nothing outlives the session, and the unused `pending_ids` local is gone. Covered by the -existing (and new) `test_confirmation.py` cases. - -### B-22 - -**Amounts rendered by raw division** - -*Files:* `app/static/app.js` (multiple: `:295`, `:424`, `:660`, `:673`, `:680`), -`app/static/admin.js` (table cells) - -Every amount is displayed as `sats / SATS_PER_PLM` with no formatting, so values like -`0.7000000000000001` are reachable in the balance, the jackpot and the admin tables. - -*Proposed fix:* one `formatPlm(sats)` helper (fixed decimals, locale-aware grouping via -`Intl.NumberFormat` with the language already resolved by `i18n.js`), used by every display -site. Amounts sent *to* the server must keep going through `Math.round(x * SATS_PER_PLM)` — -the formatter is for display only. - -*Test:* manual — with a 0.7 PLM jackpot and a 12345678.9 PLM balance, no artefacts and no -locale mismatch against the selected language. - -**Fixed.** `formatPlm(sats)` added to `app.js` (and `fmtPlm` to `admin.js`), -using `Intl.NumberFormat` with the already-resolved language; every display site routes -through it. Input fields deliberately keep the raw value — a grouped, localized string -would break `parseFloat` — and amounts sent to the server still go through -`Math.round(x * SATS_PER_PLM)`. Verification is manual (browser rendering). - -### B-23 - -**Concurrent `withLoading` on the same button** - -*File:* `app/static/app.js:20-31` - -`withLoading` snapshots `button.innerHTML` and restores it in `finally`. `refreshMe()` is -invoked from the SSE handler, the poll chain, `placeBet`, `withdraw` and `showDashboard`, -all sharing `#refresh-btn`. Two overlapping calls make the second snapshot the *loading* -label, which it then restores permanently — leaving the button stuck on -"Aggiornamento…". - -*Proposed fix:* keep the in-flight promise on the element itself (e.g. a -`button._loadingPromise` / `WeakMap`); a second call either awaits the existing one or -returns immediately, so only the outermost call restores the markup. Also worth -de-duplicating the SSE burst: `onRoundServerEvent` fires three fetches per event, and the -broadcaster is generic, so every client reacts to every event. - -*Test:* manual — trigger a bet while an SSE-driven `refreshMe()` is in flight and confirm -the refresh button returns to its icon+label state. - -**Fixed.** `withLoading` keeps the in-flight promise in a `WeakMap` keyed by the -button: a nested call awaits the existing one and runs its own work without touching the -markup, so only the outermost call restores it. Verification is manual (browser -interaction). - -### B-24 - -**The global exception handler breaks the error contract** - -*File:* `app/main.py:74-77` - -It answers `{"detail": "internal server error"}` — a bare string, while -`app/api/errors.py` documents `detail` as `{"code", "message", "params"}`. The frontend -tolerates a string (`apiErrorMessage` handles that case), but the contract should be -uniform. - -*Proposed fix:* return `ApiError("internal_error", "internal server error").as_detail()` and -add `error.internal_error` to all 7 languages in `app/static/i18n.js`. Keep the response -body free of exception details — the traceback belongs in `logs/app.log` only. - -*Test:* an endpoint stubbed to raise answers 500 with `detail.code == "internal_error"` and -no exception text in the body. - -**Fixed.** The catch-all handler returns -`ApiError("internal_error", "internal server error").as_detail()`, and -`error.internal_error` was added to all 7 languages. Test: -`test_rounds_route.py::test_unhandled_errors_use_the_structured_detail_shape`, which also -asserts the exception text does not leak into the response body. +**Proposed fix.** Use `blockchain.transaction.get_merkle` (or the scripthash history) for +confirmation and existence checks — both are portable and give the confirming height directly. +Probe verbose support once at connect time and record it on the client, so an unsupported +server is detected loudly at session start rather than silently mid-operation. --- -## Cross-cutting observation +## Low / hygiene -[B-01](#b-01) through [B-04](#b-04) share one root cause: **the code treats a broadcast as -final and the network connection as never failing.** There is no reconciliation between -the database's view of the world and the chain's — no re-scan at startup, no terminal -`failed` state, no request timeouts, no "unstick" routine. Combined with the already -documented scheduler-resume gap and `restart: unless-stopped` in `docker-compose.yml`, -the system has several absorbing states from which only manual database edits recover. +### B-42 — `/docs` exposed in production -Fixing that class of problem is more valuable than any individual item above. Suggested -order of work, by expected time-to-first-occurrence in production: +FastAPI mounts Swagger by default, so the entire API surface — `/admin` included — is publicly +enumerable. The README advertises it. +**Fix:** `docs_url=None, redoc_url=None, openapi_url=None` in production (env-gated), or place +them behind `require_admin`. -1. [B-01](#b-01) — will happen within hours of deployment. -2. [B-03](#b-03) — one bad row is enough, and it is silent. -3. [B-02](#b-02) — happens on the first bump the RBF loop actually performs. -4. [B-06](#b-06) — depends only on the user's UTXO shape. -5. [B-05](#b-05) — happens on the first operator typo, and is unrecoverable in the - foreign-address variant. -6. [B-04](#b-04) + [B-08](#b-08) — the reconciliation layer both of them need. -7. Everything else. +### B-43 — No HTTP security headers + +The [Caddyfile](Caddyfile) sets no CSP, no `X-Frame-Options`/`frame-ancestors`, and no HSTS +(Caddy does not add it on its own). The JWT lives in `localStorage`, so any XSS exfiltrates +it, and the page is iframeable. +**Fix:** a `header` block in the Caddyfile with `Strict-Transport-Security`, +`X-Content-Type-Options: nosniff`, `Referrer-Policy` and a CSP tight enough for two static +pages with no external assets (`default-src 'self'`). + +### B-44 — README and CLAUDE.md contradict each other + +The README says to run `uvicorn --reload` directly and +`docker compose run --rm app python scripts/generate_master_key.py`; CLAUDE.md says explicitly +that neither is supported. Whoever opens the repo reads the README first. +**Fix:** align the README's Quick start with the Docker-only workflow documented in +CLAUDE.md and `docs/setup.md`. + +### B-45 — Unvalidated and unpaginated admin list endpoints + +`limit: int = 50` on `/admin/rounds` and `/admin/audit-log` has no bounds (`-1` means +"everything" on SQLite), and `/admin/pending-transactions` has no limit at all — it grows +without end. +**Fix:** `Query(default=50, ge=1, le=500)` on both, and the same treatment plus a status filter +on the pending-transaction list. + +### B-46 — `secrets.compare_digest` on a `str` raises on non-ASCII input + +`api/routes/admin.py:27` raises `TypeError` — a 500 instead of a 403 — when the header contains +non-ASCII characters. +**Fix:** compare the UTF-8 encoded bytes of both sides. + +### B-47 — Unbounded `String` columns for large text + +`raw_tx_hex` (`db/models.py:146`) and `payload_json` (`:178`) should be `Text`. It works on +SQLite and PostgreSQL and breaks elsewhere. +**Fix:** switch both to `Text` in a migration. + +### 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 + +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). From f13f6850b7449676701183597542acb2bbd72604 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 09:19:53 +0200 Subject: [PATCH 02/29] Persist the payout before broadcasting it (B-25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _trigger_payout used to broadcast the payout transaction and only afterwards write payout_txid and its PendingTransaction. A crash in that window (docker-compose.yml auto-restarts on crash) left a payout on-chain with zero record: the round stuck in paying_out, nothing for the reconciler to resolve, and a manual retry that would have paid the winner a second time. This mirrors B-08, which already fixed the same gap for place_bet/request_withdrawal. _trigger_payout now has four phases: read, build (network read only, no write), persist the intent as a PendingTransaction(kind="payout", status="building") and commit, then broadcast and promote to "pending". A rejected broadcast now leaves that "building" row for tx/reconcile.py to resolve — its existing building/pending handling already covers a payout kind correctly, including clearing payout_txid on abandonment, so reconcile.py needed no changes. Since pool UTXOs aren't tracked in utxo_events and so can never be reserved/released the way a user's own UTXOs are, two guards go along with the two-phase write: _trigger_payout now refuses to build a second payout for a round that already has a non-terminal PendingTransaction, and the payout builder excludes any UTXO already referenced by any non-terminal payout transaction (_reserved_payout_outpoints) so a stale payout from an earlier round the reconciler hasn't abandoned yet can't be double-spent by a fresh attempt. This makes a payout retry safe; making one happen automatically is B-26, still open. BUGS.md moves B-25 to "Previously fixed" with the fix description; the suite grows from 139 to 143 tests. Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 82 +++++++++------ app/rounds/scheduler.py | 118 +++++++++++++++++---- tests/unit/test_scheduler.py | 199 ++++++++++++++++++++++++++++++++++- 3 files changed, 342 insertions(+), 57 deletions(-) diff --git a/BUGS.md b/BUGS.md index 765073d..119ebbf 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,20 +1,21 @@ # 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-25 … B-49. **All of them are open.** The 139-test suite -is green, so none of these are caught by existing coverage — every fix should land with a -regression test. +7 medium, 8 low), listed below as B-25 … B-49. B-25 is fixed as of 2026-07-27; the other 24 +are open. 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 (B-25's four tests brought the +suite to 143). -The recurring pattern across B-25, B-26, B-27, B-29 and B-36 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. Outgoing transactions reconcile; deposits do not. Bets and withdrawals write -their intent before broadcasting; the payout does not. Broadcast failures are audit-logged; -*pre*-broadcast failures (no client, insufficient pool funds) are not. +The recurring pattern across B-26, B-27, B-29 and B-36 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. Outgoing transactions reconcile; deposits do not. Broadcast failures are +audit-logged; *pre*-broadcast failures (no client, insufficient pool funds) are not. -**Single highest priority: make `paying_out` a recoverable, idempotent state** (record before -broadcast + startup resume + retry). That closes B-25, B-26 and half of the already-known -"scheduler doesn't resume" gap — i.e. every way the lottery currently stops and cannot -restart on its own. +**Highest remaining priority: make `paying_out` fully recoverable, not just idempotent.** +B-25 made a payout retry *safe* (persisted before broadcast, guarded against double-spend); +B-26 is what would make a retry actually *happen* automatically. Together with the +already-known "scheduler doesn't resume" gap, that's every way the lottery currently stops +and cannot restart on its own. For limitations that are accepted by design rather than bugs (single-shared-token admin auth, single-process assumptions, no user-facing history, etc.), see "Known gaps / TODO" in @@ -24,27 +25,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD ## Critical -### B-25 — The payout has no two-phase write, unlike bets and withdrawals - -`rounds/scheduler.py:214` broadcasts, and only afterwards (`:229-251`) writes `payout_txid` -and the `PendingTransaction`. A crash in that window — and `docker-compose.yml` sets -`restart: unless-stopped`, so a crash means an automatic restart — leaves a payout on-chain -with **no record at all**: the round is stuck in `paying_out`, the reconciler has nothing to -resolve, and a manual retry would pay the winner a second time (pool UTXOs are not tracked in -`utxo_events`, so nothing reserves them). - -This is exactly what B-08 fixed for `place_bet`/`request_withdrawal`; the same fix was never -applied to the path that moves the most money. - -**Proposed fix.** Mirror the bet path: write `Round.payout_txid` plus a -`PendingTransaction(kind="payout", status="building")` and commit *before* -`client.broadcast()`, then promote to `"pending"` after. Teach -`reconcile.py:_promote`/`_abandon` to handle a `building` payout (promote if the tx is on -chain, otherwise clear `payout_txid` and leave the round for the retry routine). Since pool -UTXOs are invisible to `utxo_events`, `_abandon` cannot release them — so the payout builder -must additionally refuse to spend an outpoint already referenced by a non-terminal payout -`PendingTransaction`, which is what makes a retry safe against double-paying. - ### B-26 — A transient failure at payout time wedges the lottery permanently `rounds/scheduler.py:166-169`: if `listener.client is None` when `_trigger_payout` starts, it @@ -383,6 +363,42 @@ already does. ## Previously fixed +### B-25 — The payout has no two-phase write, unlike bets and withdrawals + +`rounds/scheduler.py` used to broadcast the payout and only afterwards write `payout_txid` +and the `PendingTransaction`. A crash in that window — and `docker-compose.yml` sets +`restart: unless-stopped`, so a crash means an automatic restart — left a payout on-chain +with **no record at all**: the round stuck in `paying_out`, the reconciler with nothing to +resolve, and a manual retry that would pay the winner a second time (pool UTXOs are not +tracked in `utxo_events`, so nothing reserved them). + +This was exactly what B-08 fixed for `place_bet`/`request_withdrawal`; the same fix had never +been applied to the path that moves the most money. + +**Fixed:** `_trigger_payout` (`rounds/scheduler.py`) now has four phases instead of three — +read, *build* (network read only, no write), *persist the intent as `PendingTransaction(kind= +"payout", status="building")` and commit*, then broadcast and promote to `"pending"`. A +broadcast rejection now leaves that `"building"` row behind for the existing reconciler +(`tx/reconcile.py`) to resolve — its generic `building`/`pending` handling already covered a +`payout` kind correctly (including clearing `payout_txid` on abandonment), so no changes were +needed there. + +Two guards were added alongside the two-phase write, since pool UTXOs are invisible to +`utxo_events` and so can never be released/reserved the way a user's own UTXOs are: +`_trigger_payout` now refuses to build a second payout for a round that already has a +non-terminal `PendingTransaction(kind="payout")`, and the payout builder excludes any UTXO +already referenced by *any* non-terminal payout transaction (`_reserved_payout_outpoints`) — +not just this round's — so a stale payout from an earlier round that the reconciler hasn't +abandoned yet can't be double-spent by a fresh attempt. `should_bump`/reconciler retry timing +around a fee-bumped payout is unaffected by this fix (see B-27, still open). + +This makes a payout retry *safe*; it does not yet make one *automatic* — that is B-26, still +open. Regression tests: `tests/unit/test_scheduler.py` +(`test_trigger_payout_persists_before_broadcasting`, +`test_trigger_payout_broadcast_failure_leaves_a_recoverable_row`, +`test_trigger_payout_skips_when_already_in_flight`, +`test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payout`). + 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 diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index 614eec7..0a3dcf0 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -3,8 +3,9 @@ import logging from datetime import datetime, timedelta, timezone from embit import script +from embit.transaction import Transaction from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.audit.log import write_audit_log from app.db.models import PendingTransaction, Round, RoundParticipant, User @@ -161,8 +162,18 @@ class RoundScheduler: return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex) async def _trigger_payout(self, round_id: int) -> None: - """Three phases, so no DB session is held across a network call (B-18): read - what's needed, do the chain work, then persist the outcome.""" + """Four phases, so no DB session is held across a network call (B-18): read + what's needed, build the tx, persist the intent, then broadcast. + + The persist happens *before* the broadcast (B-25) — the same two-phase shape + as place_bet/request_withdrawal (B-08): a crash between building the payout + and recording it used to leave money on-chain with zero trace in the DB (no + payout_txid, no PendingTransaction), so a manual retry would have paid the + winner a second time. Now the worst case is a "building" PendingTransaction + the reconciler (app/tx/reconcile.py) can resolve either way by asking the + chain whether the tx exists, exactly like it already does for bets and + withdrawals. + """ client = self._listener.client if client is None: logger.error("round %s payout deferred: not connected", round_id) @@ -171,6 +182,25 @@ class RoundScheduler: # --- Phase 1: read (session closed before any network I/O) --------------- async with self._session_factory() as session: round_ = await session.get(Round, round_id) + already_in_flight = await session.scalar( + select(PendingTransaction).where( + PendingTransaction.round_id == round_id, + PendingTransaction.kind == "payout", + PendingTransaction.status.in_(("building", "pending")), + ) + ) + if already_in_flight is not None: + # A payout for this round is already building or broadcast — this + # must not build a second one, or a retry (manual, or a future + # automatic one) would pay the winner twice. Confirmation/ + # reconciliation already owns resolving that row. + logger.info( + "round %s payout already in flight (pending_transaction %s), skipping", + round_id, + already_in_flight.id, + ) + return + reserved_outpoints = await _reserved_payout_outpoints(session) config = await get_round_config(session) fee_address = config.fee_address fee_rate = config.fee_rate_sat_vb @@ -192,13 +222,17 @@ class RoundScheduler: winner_share = pool_amount_sats * 70 // 100 commission_share = pool_amount_sats - winner_share # remainder from rounding goes to fees - # --- Phase 2: build and broadcast ---------------------------------------- + # --- Phase 2: build (network read only, no DB write yet) ----------------- try: pool_key = derive_pool_key() pool_script_obj = script.p2wpkh(pool_key.to_public()) pool_address = pool_script_obj.address(network=PLM_MAINNET) entries = await client.listunspent(address_to_scripthash(pool_address)) - utxos = [Utxo(e["tx_hash"], e["tx_pos"], e["value"]) for e in entries if e["height"] > 0] + utxos = [ + Utxo(e["tx_hash"], e["tx_pos"], e["value"]) + for e in entries + if e["height"] > 0 and (e["tx_hash"], e["tx_pos"]) not in reserved_outpoints + ] built = build_payout_transaction( signing_key=pool_key, @@ -211,36 +245,54 @@ class RoundScheduler: change_address=pool_address, fee_rate_sat_vb=fee_rate, ) - await client.broadcast(built.raw_hex) except InsufficientFundsError: logger.exception("round %s payout failed: insufficient pool UTXOs", round_id) return except Exception: - # Anything else — a malformed fee_address (EmbitError), a rejected - # broadcast, a dead connection. This used to escape all the way to - # run()'s catch-all, which logged it without recording anything, leaving - # no trace of *why* the round was stuck (B-05). The round stays in - # "paying_out" either way: automatic payout retry is still an open gap. - logger.exception("round %s payout failed", round_id) + # Anything else — a malformed fee_address (EmbitError) or similar. This + # used to escape all the way to run()'s catch-all, which logged it + # without recording anything, leaving no trace of *why* the round was + # stuck (B-05). The round stays in "paying_out" either way: automatic + # payout retry is still an open gap. + logger.exception("round %s payout build failed", round_id) await self._log_payout_failure(round_id, winner_user_id) return - # --- Phase 3: persist ----------------------------------------------------- + # --- Phase 3: persist the intent, *then* broadcast (B-25) ----------------- async with self._session_factory() as session: round_ = await session.get(Round, round_id) round_.winner_amount_sats = built.winner_sats round_.fee_amount_sats = built.commission_sats round_.payout_txid = built.txid - session.add( - PendingTransaction( - kind="payout", - round_id=round_id, - current_txid=built.txid, - fee_rate_sat_vb=fee_rate, - raw_tx_hex=built.raw_hex, - status="pending", - ) + pending = PendingTransaction( + kind="payout", + round_id=round_id, + current_txid=built.txid, + fee_rate_sat_vb=fee_rate, + raw_tx_hex=built.raw_hex, + # "building" until the broadcast succeeds, exactly like place_bet's + # two phases — see the reconciler, which gives this a short grace + # period before asking the chain whether it made it out after all. + status="building", ) + session.add(pending) + await session.commit() + pending_id = pending.id + + # --- Phase 4: broadcast, then promote the pending row -------------------- + try: + await client.broadcast(built.raw_hex) + except Exception: + # The row stays "building": the reconciler will ask the chain about it + # and, finding nothing, abandon it and clear payout_txid (B-25) — instead + # of the round being stuck with a payout_txid that never went anywhere. + logger.exception("round %s payout broadcast failed", round_id) + await self._log_payout_failure(round_id, winner_user_id) + return + + async with self._session_factory() as session: + pending = await session.get(PendingTransaction, pending_id) + pending.status = "pending" await write_audit_log( session, "payout_sent", @@ -267,3 +319,25 @@ class RoundScheduler: await session.commit() except Exception: logger.exception("could not record the payout failure of round %s", round_id) + + +async def _reserved_payout_outpoints(session: AsyncSession) -> set[tuple[str, int]]: + """Pool UTXOs already claimed by a payout that hasn't resolved yet — this + round's own in-flight payout (guarded against separately in _trigger_payout) or + a stale one from an earlier round the reconciler hasn't abandoned yet (B-25). + These must be excluded from selection, or a retry would double-spend the same + coins into two payouts before the reconciler gets a chance to release them.""" + rows = ( + await session.scalars( + select(PendingTransaction).where( + PendingTransaction.kind == "payout", + PendingTransaction.status.in_(("building", "pending")), + ) + ) + ).all() + reserved: set[tuple[str, int]] = set() + for row in rows: + tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex)) + for vin in tx.vin: + reserved.add((vin.txid.hex(), vin.vout)) + return reserved diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 1aa4a9e..7bcf771 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -4,9 +4,10 @@ import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from app.config import settings from app.db.base import Base -from app.db.models import Round, RoundConfig -from app.rounds.scheduler import RoundScheduler +from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User +from app.rounds.scheduler import RoundScheduler, _reserved_payout_outpoints class FakeListener: @@ -52,3 +53,197 @@ async def test_tick_closes_round_with_no_participants_once_due(session_factory, async with session_factory() as session: round_ = (await session.scalars(select(Round))).one() assert round_.status == "closed" + + +# --- B-25: the payout must be persisted before it is broadcast, like bets/withdrawals --- + +# A real, reusable PLM bech32 address so build_payout_transaction's +# script.Script.from_address(...) succeeds — this is not a value the scheduler +# validates itself (that's the admin panel's job for fee_address), it just needs to +# actually decode. +_WINNER_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd" +_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n" + +_POOL_AMOUNT_SATS = 10_000_000_000 # 100 PLM + + +class FakePayoutClient: + def __init__(self, entries, *, fail_broadcast=False): + self._entries = entries + self._fail_broadcast = fail_broadcast + self.broadcasted: list[str] = [] + + async def listunspent(self, scripthash): + return self._entries + + async def broadcast(self, raw_tx_hex): + if self._fail_broadcast: + raise RuntimeError("node rejected the transaction") + self.broadcasted.append(raw_tx_hex) + return "network-txid" + + +class FakePayoutListener: + def __init__(self, client): + self.client = client + + +@pytest.fixture +async def payout_session_factory(tmp_path, monkeypatch): + """Same master-key bootstrap as test_broadcast.py's fixture: _trigger_payout + needs a real pool key to sign with.""" + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + monkeypatch.setattr( + settings, + "xprv_encryption_key", + __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(), + ) + from app.wallet import hd + + hd._account_key = None + hd.generate_master_key() + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield async_sessionmaker(engine, expire_on_commit=False) + await engine.dispose() + hd._account_key = None + + +async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int: + async with session_factory() as session: + winner = User(username="winner", password_hash="x", derivation_index=0, address=_WINNER_ADDRESS) + session.add(winner) + await session.flush() + session.add(RoundConfig(fee_address=_FEE_ADDRESS, fee_rate_sat_vb=1)) + session.add( + Round( + id=round_id, + status="paying_out", + pool_amount_sats=_POOL_AMOUNT_SATS, + winner_user_id=winner.id, + ) + ) + await session.commit() + return winner.id + + +async def test_trigger_payout_persists_before_broadcasting(payout_session_factory): + """The happy path: payout_txid and a PendingTransaction must exist once the + broadcast succeeds, promoted from "building" to "pending" — the two-phase write + that used to be missing entirely (B-25).""" + await _seed_paying_out_round(payout_session_factory) + entries = [{"tx_hash": "33" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + 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: + round_ = await session.get(Round, 1) + assert round_.payout_txid is not None + assert round_.winner_amount_sats and round_.fee_amount_sats + + pending = (await session.scalars(select(PendingTransaction))).one() + assert pending.kind == "payout" + assert pending.status == "pending" + assert pending.current_txid == round_.payout_txid + + events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] + assert "payout_sent" 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 + broadcast. Now the intent is already durable, so the reconciler has something to + resolve instead of the round being stuck with zero trace of what was attempted.""" + await _seed_paying_out_round(payout_session_factory) + entries = [{"tx_hash": "44" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + client = FakePayoutClient(entries, fail_broadcast=True) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._trigger_payout(1) + + assert not client.broadcasted + async with payout_session_factory() as session: + round_ = await session.get(Round, 1) + assert round_.payout_txid is not None # durable, even though the broadcast failed + + pending = (await session.scalars(select(PendingTransaction))).one() + assert pending.kind == "payout" + assert pending.status == "building" # not lost — the reconciler resolves this + assert pending.current_txid == round_.payout_txid + + events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] + assert "payout_failed" in events + + +async def test_trigger_payout_skips_when_already_in_flight(payout_session_factory): + """A second call for a round that already has a non-terminal payout + PendingTransaction must not build (and broadcast) another one — that would pay + the winner twice.""" + winner_id = await _seed_paying_out_round(payout_session_factory) + async with payout_session_factory() as session: + round_ = await session.get(Round, 1) + round_.payout_txid = "already-sent-txid" + session.add( + PendingTransaction( + kind="payout", + round_id=1, + current_txid="already-sent-txid", + fee_rate_sat_vb=1, + raw_tx_hex="00", + status="pending", + ) + ) + await session.commit() + + entries = [{"tx_hash": "55" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + client = FakePayoutClient(entries) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._trigger_payout(1) + + assert not client.broadcasted + async with payout_session_factory() as session: + assert (await session.scalars(select(PendingTransaction))).all() # still just the one seeded + rows = (await session.scalars(select(PendingTransaction))).all() + assert len(rows) == 1 + assert rows[0].current_txid == "already-sent-txid" + + +async def test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payout(payout_session_factory): + """A payout still "building"/"pending" for some round — most plausibly a stale + one the reconciler hasn't abandoned yet — must keep its inputs off the table for + a fresh payout attempt, or the same pool coins could be spent twice.""" + from embit import script + from embit.transaction import Transaction, TransactionInput, TransactionOutput + + reserved_txid = "66" * 32 + raw_tx = ( + Transaction( + vin=[TransactionInput(bytes.fromhex(reserved_txid), 2)], + vout=[TransactionOutput(1_000_000, script.Script.from_address(_WINNER_ADDRESS))], + ) + .serialize() + .hex() + ) + async with payout_session_factory() as session: + session.add( + PendingTransaction( + kind="payout", + round_id=99, + current_txid="stale-payout-txid", + fee_rate_sat_vb=1, + raw_tx_hex=raw_tx, + status="building", + ) + ) + await session.commit() + + reserved = await _reserved_payout_outpoints(session) + + assert reserved == {(reserved_txid, 2)} From 50a43ae3ca67261b735cd0d8608552efb1323ca8 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 09:29:14 +0200 Subject: [PATCH 03/29] Retry a stuck payout automatically, and log every failure (B-26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _trigger_payout used to run exactly once, from _close_and_draw. Any failure after that point — no Electrum client, insufficient pool UTXOs, a missing fee_address, a rejected broadcast — wedged the round in paying_out forever, and every one of those early returns except the generic exception handler logged nothing at all: /admin showed a stalled round with no explanation. A process restart while paying_out hit the same dead end. _tick now handles status == "paying_out": it calls the new _retry_payout_if_due, which re-invokes _trigger_payout unless the most recent payout_failed audit entry for the round is younger than _PAYOUT_RETRY_INTERVAL_SECONDS (60s) — throttled so a persistently broken payout (e.g. no fee_address set yet) doesn't retry, and re-log a failure, on every 5-second tick. Every early return in _trigger_payout now calls _log_payout_failure with a reason string, so that throttle always has something to check against and /admin always shows why a round is stuck. This is safe to fire on a restart too, because B-25 already made _trigger_payout idempotent (it no-ops if a non-terminal payout PendingTransaction already exists) and persists before broadcasting — so a round found paying_out at startup, whatever state its payout was actually in, gets retried the same way. That closes the paying_out half of the "scheduler doesn't resume mid-flight rounds after a restart" gap in CLAUDE.md; the drawing/block-wait half is untouched (see BUGS.md B-36). BUGS.md moves B-26 to "Previously fixed" with the fix description; the suite grows from 143 to 148 tests. Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 94 +++++++++++++++++++------------ app/rounds/scheduler.py | 68 +++++++++++++++++++--- tests/unit/test_scheduler.py | 106 +++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 45 deletions(-) diff --git a/BUGS.md b/BUGS.md index 119ebbf..1e751d1 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,21 +1,23 @@ # 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-25 … B-49. B-25 is fixed as of 2026-07-27; the other 24 -are open. 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 (B-25's four tests brought the -suite to 143). +7 medium, 8 low), listed below as B-25 … B-49. B-25 and B-26 are fixed as of 2026-07-27; the +other 23 are open. 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 (B-25 and B-26 +together brought the suite from 139 to 148). -The recurring pattern across B-26, B-27, B-29 and B-36 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. Outgoing transactions reconcile; deposits do not. Broadcast failures are -audit-logged; *pre*-broadcast failures (no client, insufficient pool funds) are not. +The recurring pattern across B-27, B-29 and B-36 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. +Outgoing transactions reconcile; deposits do not. -**Highest remaining priority: make `paying_out` fully recoverable, not just idempotent.** -B-25 made a payout retry *safe* (persisted before broadcast, guarded against double-spend); -B-26 is what would make a retry actually *happen* automatically. Together with the -already-known "scheduler doesn't resume" gap, that's every way the lottery currently stops -and cannot restart on its own. +**`paying_out` is now fully recoverable, not just idempotent.** B-25 made a payout retry +*safe* (persisted before broadcast, guarded against double-spend); B-26 made it *automatic* +(the scheduler retries a stuck `paying_out` round on its own, throttled, and every failure — +including ones that used to fail silently — is now audit-logged with a reason). Together +these close every way a payout specifically could wedge the lottery forever. What's still open +in the same family is narrower: the "drawing" phase (waiting on a block) has no equivalent +resume-after-restart or stall visibility — see B-36 and the "scheduler doesn't resume" entry +in CLAUDE.md's Known gaps, which this doesn't touch. For limitations that are accepted by design rather than bugs (single-shared-token admin auth, single-process assumptions, no user-facing history, etc.), see "Known gaps / TODO" in @@ -25,27 +27,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD ## Critical -### B-26 — A transient failure at payout time wedges the lottery permanently - -`rounds/scheduler.py:166-169`: if `listener.client is None` when `_trigger_payout` starts, it -returns. `_trigger_payout` is called exactly once, from `_close_and_draw`, and `_tick` -ignores any round not in `open`/`closing` (`:58`). The round stays in `paying_out`, no new -round can open, and — unlike the `except Exception` branch — nothing is written to -`audit_log`, so `/admin` shows a stalled state with no explanation. - -The payout runs immediately after a ~2-minute wait on a block, so an Electrum drop in that -window is entirely plausible. Same shape at `:215-217`: `InsufficientFundsError` returns -without calling `_log_payout_failure`. - -CLAUDE.md lists "payout retry" as an accepted gap, but treats it as an operational -inconvenience; in practice it is a single point of failure that stops the whole platform. - -**Proposed fix.** (a) Call `_log_payout_failure` on *every* early return, with a reason in the -payload, so the operator sees it. (b) Make `_tick` handle `paying_out`: if the round has no -non-terminal payout `PendingTransaction`, re-run `_trigger_payout`. That turns every early -return into a retry rather than a dead end, and — combined with B-25's idempotency guard — -also covers the process-restart case. - ### B-27 — Every RBF bump resets the reconciler's abandon clock, so it never fires `tx/broadcast.py:122` sets `pending.broadcast_at = now` on each bump, but @@ -392,13 +373,54 @@ not just this round's — so a stale payout from an earlier round that the recon abandoned yet can't be double-spent by a fresh attempt. `should_bump`/reconciler retry timing around a fee-bumped payout is unaffected by this fix (see B-27, still open). -This makes a payout retry *safe*; it does not yet make one *automatic* — that is B-26, still -open. Regression tests: `tests/unit/test_scheduler.py` +This made a payout retry *safe*; B-26 (below) is what makes one *automatic*. Regression +tests: `tests/unit/test_scheduler.py` (`test_trigger_payout_persists_before_broadcasting`, `test_trigger_payout_broadcast_failure_leaves_a_recoverable_row`, `test_trigger_payout_skips_when_already_in_flight`, `test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payout`). +### B-26 — A transient failure at payout time wedges the lottery permanently + +`rounds/scheduler.py`: if `listener.client is None` when `_trigger_payout` starts, it returned +without recording anything. `_trigger_payout` was called exactly once, from `_close_and_draw`, +and `_tick` ignored any round not in `open`/`closing`. The round stayed in `paying_out`, no new +round could open, and — unlike the generic `except Exception` branch — nothing was written to +`audit_log`, so `/admin` showed a stalled state with no explanation. + +The payout runs immediately after a ~2-minute wait on a block, so an Electrum drop in that +window is entirely plausible. Same shape applied to `InsufficientFundsError`, a missing +`fee_address` and a missing winner user — none of them logged anything either. + +CLAUDE.md listed "payout retry" as an accepted gap, but treated it as an operational +inconvenience; in practice it was a single point of failure that stopped the whole platform, +including across a process restart while a round was `paying_out`. + +**Fixed:** two changes, matching the proposed fix exactly. (a) Every early return in +`_trigger_payout` — not connected, no `fee_address`, winner not found, insufficient pool +UTXOs, a build error, a rejected broadcast — now calls `_log_payout_failure` with a `reason` +string in the payload, so `/admin`'s audit log always shows *why* a round is stuck, not just +that it is. (b) `_tick` now handles `status == "paying_out"` by calling the new +`_retry_payout_if_due`, which re-invokes `_trigger_payout` unless the most recent +`payout_failed` audit entry for this round is younger than `_PAYOUT_RETRY_INTERVAL_SECONDS` +(60s) — throttled so a persistently-broken payout (e.g. an operator hasn't set `fee_address` +yet) doesn't retry, and re-log a failure, on every 5-second tick. + +Because B-25 already made `_trigger_payout` idempotent (it no-ops if a non-terminal payout +`PendingTransaction` already exists for the round) and persists before broadcasting, this +retry is safe to fire on a process restart too: a round found `paying_out` at startup — whose +payout may have already broadcast, may never have been attempted, or may have been abandoned +by the reconciler — is retried the same way, closing the `paying_out` half of the "scheduler +doesn't resume mid-flight rounds after a restart" gap in CLAUDE.md (the "drawing"/block-wait +half is unrelated and still open, see B-36). + +Regression tests: `tests/unit/test_scheduler.py` +(`test_trigger_payout_logs_a_failure_when_not_connected`, +`test_trigger_payout_logs_a_failure_when_fee_address_missing`, +`test_tick_retries_a_stuck_paying_out_round_with_no_recent_failure`, +`test_tick_throttles_retry_after_a_recent_payout_failure`, +`test_tick_retries_once_the_throttle_window_has_elapsed`). + 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 diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index 0a3dcf0..1159c6b 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -8,7 +8,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.audit.log import write_audit_log -from app.db.models import PendingTransaction, Round, RoundParticipant, User +from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User from app.electrum.listener import ElectrumListener from app.electrum.scripthash import address_to_scripthash from app.rounds.config import get_round_config @@ -23,6 +23,13 @@ logger = logging.getLogger(__name__) _TICK_INTERVAL_SECONDS = 5 +# B-26: how long to wait after a payout failure before automatically retrying it. +# Long enough that a persistently-broken payout (misconfigured fee_address, +# insufficient pool UTXOs) doesn't re-attempt — and re-write a payout_failed audit +# entry — every _TICK_INTERVAL_SECONDS; short enough that a transient failure +# (a dropped Electrum connection, a momentarily-empty pool) self-heals quickly. +_PAYOUT_RETRY_INTERVAL_SECONDS = 60 + class RoundScheduler: """Background task implementing flowchart.mmd's DRAW subgraph: closes the @@ -56,8 +63,17 @@ class RoundScheduler: round_id, status, opened_at = round_.id, round_.status, round_.opened_at round_duration_seconds = (await get_round_config(session)).round_duration_seconds + if status == "paying_out": + # B-26: _trigger_payout used to run exactly once, from _close_and_draw — + # any failure after that (no Electrum client, insufficient pool UTXOs, a + # rejected broadcast) or a process restart while paying_out left the round + # wedged here forever. Every tick now re-checks and retries, throttled by + # _retry_payout_if_due so a persistent failure doesn't retry on every tick. + await self._retry_payout_if_due(round_id) + return + if status not in ("open", "closing"): - return # already drawing/paying_out; progress happens elsewhere + return # "drawing" — progress happens inside the in-flight _close_and_draw call if status == "open": opened_at = opened_at.replace(tzinfo=timezone.utc) @@ -161,6 +177,32 @@ class RoundScheduler: await asyncio.sleep(_TICK_INTERVAL_SECONDS) return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex) + async def _retry_payout_if_due(self, round_id: int) -> None: + """B-26: whether a "paying_out" round is due for another payout attempt. + + Throttled by the most recent payout_failed audit entry for this round + (written by _log_payout_failure on every early return in _trigger_payout, + including ones that used to fail silently) rather than by any new DB state, + since a failed attempt doesn't necessarily leave a PendingTransaction behind + (a build failure like a missing fee_address never gets that far). No entry + yet means this round hasn't failed before — either it's a fresh "paying_out" + (the very first call already happened from _close_and_draw and hasn't had a + chance to fail yet) or the process restarted before ever recording one — + either way it's due immediately. + """ + async with self._session_factory() as session: + last_failure_at = await session.scalar( + select(AuditLog.created_at) + .where(AuditLog.event_type == "payout_failed", AuditLog.round_id == round_id) + .order_by(AuditLog.id.desc()) + .limit(1) + ) + if last_failure_at is not None: + last_failure_at = last_failure_at.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) < last_failure_at + timedelta(seconds=_PAYOUT_RETRY_INTERVAL_SECONDS): + return # too soon — avoid hammering a persistently-broken payout + await self._trigger_payout(round_id) + async def _trigger_payout(self, round_id: int) -> None: """Four phases, so no DB session is held across a network call (B-18): read what's needed, build the tx, persist the intent, then broadcast. @@ -177,6 +219,7 @@ class RoundScheduler: client = self._listener.client if client is None: logger.error("round %s payout deferred: not connected", round_id) + await self._log_payout_failure(round_id, None, "electrum client not connected") return # --- Phase 1: read (session closed before any network I/O) --------------- @@ -214,9 +257,11 @@ class RoundScheduler: logger.error( "round %s payout blocked: no fee_address configured (set it via the admin endpoint)", round_id ) + await self._log_payout_failure(round_id, winner_user_id, "no fee_address configured") return if winner_address is None: logger.error("round %s payout blocked: winner user %s not found", round_id, winner_user_id) + await self._log_payout_failure(round_id, winner_user_id, "winner user not found") return winner_share = pool_amount_sats * 70 // 100 @@ -247,15 +292,16 @@ class RoundScheduler: ) 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") return except Exception: # Anything else — a malformed fee_address (EmbitError) or similar. This # used to escape all the way to run()'s catch-all, which logged it # without recording anything, leaving no trace of *why* the round was - # stuck (B-05). The round stays in "paying_out" either way: automatic - # payout retry is still an open gap. + # stuck (B-05). _retry_payout_if_due (B-26) is what turns this recorded + # failure into an automatic retry instead of a dead end. logger.exception("round %s payout build failed", round_id) - await self._log_payout_failure(round_id, winner_user_id) + await self._log_payout_failure(round_id, winner_user_id, "payout build failed") return # --- Phase 3: persist the intent, *then* broadcast (B-25) ----------------- @@ -287,7 +333,7 @@ class RoundScheduler: # and, finding nothing, abandon it and clear payout_txid (B-25) — instead # of the round being stuck with a payout_txid that never went anywhere. logger.exception("round %s payout broadcast failed", round_id) - await self._log_payout_failure(round_id, winner_user_id) + await self._log_payout_failure(round_id, winner_user_id, "broadcast rejected") return async with self._session_factory() as session: @@ -304,15 +350,19 @@ class RoundScheduler: logger.info("round %s payout broadcast: txid=%s", round_id, built.txid) - async def _log_payout_failure(self, round_id: int, winner_user_id: int | None) -> None: + async def _log_payout_failure(self, round_id: int, winner_user_id: int | None, reason: str) -> None: """Leaves an operator-visible trace in the audit log for a round stuck in - "paying_out" — the logs alone don't show up in /admin.""" + "paying_out" — the logs alone don't show up in /admin. Called from every + early return in _trigger_payout (B-26), not just the generic exception + branch as before, so _retry_payout_if_due always has an entry to throttle + against and /admin always shows *why* a round is stuck rather than just + that it is.""" try: async with self._session_factory() as session: await write_audit_log( session, "payout_failed", - {"round_id": round_id}, + {"round_id": round_id, "reason": reason}, user_id=winner_user_id, round_id=round_id, ) diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 7bcf771..7b323d2 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -247,3 +247,109 @@ async def test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payou reserved = await _reserved_payout_outpoints(session) assert reserved == {(reserved_txid, 2)} + + +# --- B-26: a "paying_out" round must retry its payout automatically --------------- + + +async def test_trigger_payout_logs_a_failure_when_not_connected(payout_session_factory): + """Before B-26, this early return logged nothing beyond a log line — invisible + in /admin and unusable as a signal for an automatic retry.""" + await _seed_paying_out_round(payout_session_factory) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client=None)) + + await scheduler._trigger_payout(1) + + async with payout_session_factory() as session: + entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "payout_failed"))).all() + assert len(entries) == 1 + assert entries[0].payload_json.count("electrum client not connected") == 1 + + +async def test_trigger_payout_logs_a_failure_when_fee_address_missing(payout_session_factory): + winner_id = await _seed_paying_out_round(payout_session_factory) + async with payout_session_factory() as session: + config = (await session.scalars(select(RoundConfig))).one() + config.fee_address = "" + await session.commit() + + entries = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(FakePayoutClient(entries))) + + await scheduler._trigger_payout(1) + + async with payout_session_factory() as session: + entry = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "payout_failed"))).one() + assert "no fee_address configured" in entry.payload_json + assert entry.user_id == winner_id + + +async def test_tick_retries_a_stuck_paying_out_round_with_no_recent_failure(payout_session_factory): + """The scenario B-26 exists for: a round stuck in "paying_out" (a prior failure, + or a process restart mid-payout) with no non-terminal PendingTransaction. A + fresh tick must retry rather than leaving it wedged forever.""" + await _seed_paying_out_round(payout_session_factory) + entries = [{"tx_hash": "88" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + client = FakePayoutClient(entries) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._tick() + + assert client.broadcasted + async with payout_session_factory() as session: + round_ = await session.get(Round, 1) + assert round_.payout_txid is not None + pending = (await session.scalars(select(PendingTransaction))).one() + assert pending.status == "pending" + + +async def test_tick_throttles_retry_after_a_recent_payout_failure(payout_session_factory): + """A payout that just failed must not be retried on the very next tick, or a + persistently-broken payout (e.g. no fee_address) would spam a retry — and a + fresh payout_failed audit entry — every _TICK_INTERVAL_SECONDS.""" + await _seed_paying_out_round(payout_session_factory) + async with payout_session_factory() as session: + session.add( + AuditLog( + event_type="payout_failed", + payload_json='{"round_id": 1, "reason": "insufficient pool UTXOs"}', + round_id=1, + created_at=datetime.now(timezone.utc), + ) + ) + await session.commit() + + entries = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + client = FakePayoutClient(entries) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._tick() + + assert not client.broadcasted + async with payout_session_factory() as session: + assert (await session.scalars(select(PendingTransaction))).all() == [] + + +async def test_tick_retries_once_the_throttle_window_has_elapsed(payout_session_factory): + await _seed_paying_out_round(payout_session_factory) + async with payout_session_factory() as session: + session.add( + AuditLog( + event_type="payout_failed", + payload_json='{"round_id": 1, "reason": "insufficient pool UTXOs"}', + round_id=1, + created_at=datetime.now(timezone.utc) - timedelta(seconds=120), + ) + ) + await session.commit() + + entries = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + client = FakePayoutClient(entries) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._tick() + + assert client.broadcasted + async with payout_session_factory() as session: + pending = (await session.scalars(select(PendingTransaction))).one() + assert pending.status == "pending" From 933760e9484b359f4db7e31cee0bd69a45965f3d Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 09:39:38 +0200 Subject: [PATCH 04/29] Decouple the RBF abandon clock from the bump clock (B-27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bump_fee (tx/broadcast.py) used to overwrite PendingTransaction. broadcast_at on every fee bump, but reconcile.py's abandon-after-N- hours grace period is measured from that same column. A transaction successfully bumped every rbf_timeout_seconds (900s by default) but never mined reset that clock before it could ever reach the 6-hour abandon window, so it was never abandoned: its UTXOs never returned to the user, and if it was a bet the round stayed in "closing" indefinitely. PendingTransaction gains a last_broadcast_at column (migration 861e76aaf34c, backfilled from broadcast_at for existing rows before the NOT NULL constraint is applied). broadcast_at is now never rewritten after creation, so reconcile.py's _is_due keeps measuring from the first broadcast unchanged. bump_fee updates last_broadcast_at instead, and should_bump now reads last_broadcast_at rather than broadcast_at — correct, since whether another bump is due should reset after every bump, unlike the reconciler's abandon check, which must not. BUGS.md moves B-27 to "Previously fixed" with the fix description; the suite grows from 148 to 151 tests, including a direct proof that a tx bumped a minute ago but first broadcast 7 hours ago still gets abandoned. Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 54 ++++++++------ app/db/models.py | 7 ++ app/tx/broadcast.py | 21 ++++-- app/tx/reconcile.py | 6 ++ ...af34c_add_last_broadcast_at_to_pending_.py | 41 +++++++++++ tests/unit/test_broadcast.py | 73 ++++++++++++++++++- tests/unit/test_reconcile.py | 35 ++++++++- 7 files changed, 208 insertions(+), 29 deletions(-) create mode 100644 migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py diff --git a/BUGS.md b/BUGS.md index 1e751d1..495b281 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,13 +1,13 @@ # 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-25 … B-49. B-25 and B-26 are fixed as of 2026-07-27; the -other 23 are open. 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 (B-25 and B-26 -together brought the suite from 139 to 148). +7 medium, 8 low), listed below as B-25 … B-49. B-25, B-26 and B-27 are fixed as of 2026-07-27; +the other 22 are open. 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 (B-25, B-26 and B-27 +together brought the suite from 139 to 151). -The recurring pattern across B-27, B-29 and B-36 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 recurring pattern across B-29 and B-36 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. Outgoing transactions reconcile; deposits do not. **`paying_out` is now fully recoverable, not just idempotent.** B-25 made a payout retry @@ -27,22 +27,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD ## Critical -### B-27 — Every RBF bump resets the reconciler's abandon clock, so it never fires - -`tx/broadcast.py:122` sets `pending.broadcast_at = now` on each bump, but -`tx/reconcile.py:133` computes the 6-hour abandon deadline **from that same field**. - -With the default `rbf_timeout_seconds = 900`, a transaction that is successfully bumped every -15 minutes but never mined resets the counter long before it can reach 6 hours: it is **never -abandoned**, its UTXOs never return to the user, and if it is a bet the round stays in -`closing` indefinitely (`scheduler.py:90-91`). `reconcile.py` exists precisely to prevent -this, and the bumper disarms it. - -**Proposed fix.** Split the field: keep `broadcast_at` as the *first* broadcast (never -rewritten — it is what `_is_due` must use) and add `last_broadcast_at`, updated by -`bump_fee` and used by `should_bump`. Alembic migration backfilling `last_broadcast_at = -broadcast_at`. - ### B-28 — A hostile Electrum server (or a MITM) can choose the winner `electrum/listener.py:167-186` accepts any header whose `height >= tip_height`: no @@ -421,6 +405,32 @@ Regression tests: `tests/unit/test_scheduler.py` `test_tick_throttles_retry_after_a_recent_payout_failure`, `test_tick_retries_once_the_throttle_window_has_elapsed`). +### B-27 — Every RBF bump resets the reconciler's abandon clock, so it never fires + +`tx/broadcast.py` used to set `pending.broadcast_at = now` on each bump, but +`tx/reconcile.py`'s `_is_due` computes the 6-hour abandon deadline **from that same field**. + +With the default `rbf_timeout_seconds = 900`, a transaction that is successfully bumped every +15 minutes but never mined reset the counter long before it could reach 6 hours: it was +**never abandoned**, its UTXOs never returned to the user, and if it was a bet the round stayed +in `closing` indefinitely (`scheduler.py:90-91`). `reconcile.py` exists precisely to prevent +this, and the bumper disarmed it. + +**Fixed:** the field is split, exactly as proposed. `PendingTransaction` gained a +`last_broadcast_at` column (Alembic migration `861e76aaf34c`, backfilled from the existing +`broadcast_at` for every pre-existing row, then made `NOT NULL`). `broadcast_at` is now never +rewritten after creation — it stays the *first* broadcast, which is what `reconcile.py:_is_due` +already read and continues to read unchanged. `bump_fee` (`tx/broadcast.py`) now updates +`last_broadcast_at` instead, and `should_bump` reads `last_broadcast_at` rather than +`broadcast_at` — correctly, since *that* decision (is another bump due?) should reset after +every bump, unlike the reconciler's abandon check, which must not. + +Regression tests: `tests/unit/test_broadcast.py` +(`test_should_bump_measures_from_last_broadcast_not_first`, +`test_bump_fee_leaves_broadcast_at_untouched`) and `tests/unit/test_reconcile.py` +(`test_abandons_a_repeatedly_bumped_tx_despite_a_recent_last_broadcast`, the direct proof that +a tx bumped minutes ago but first broadcast 7 hours ago is still abandoned). + 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 diff --git a/app/db/models.py b/app/db/models.py index 2ea24ce..2ceb766 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -144,7 +144,14 @@ class PendingTransaction(Base): current_txid: Mapped[str] = mapped_column(String(64)) fee_rate_sat_vb: Mapped[int] raw_tx_hex: Mapped[str] = mapped_column(String) + # The *first* broadcast — never rewritten by a bump — since this is what the + # reconciler's abandon-after-N-hours grace period (app/tx/reconcile.py) measures + # from. Bumping used to overwrite this field, which reset that clock on every + # bump and meant a repeatedly-bumped-but-never-mined tx was never abandoned + # (B-27). last_broadcast_at is the one bump_fee updates, and the one should_bump + # (app/tx/broadcast.py) reads to decide whether another bump is due. broadcast_at: Mapped[datetime] = mapped_column(default=utcnow) + last_broadcast_at: Mapped[datetime] = mapped_column(default=utcnow) status: Mapped[str] = mapped_column(String(16), default="pending") # The txid this row had *before* its most recent RBF bump (bump_fee rewrites # current_txid in place). Despite the name reading forwards, it points diff --git a/app/tx/broadcast.py b/app/tx/broadcast.py index 0df974e..d2fa708 100644 --- a/app/tx/broadcast.py +++ b/app/tx/broadcast.py @@ -27,12 +27,19 @@ class RbfError(Exception): def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool: - """Pure decision: has this pending tx been unconfirmed for longer than the - configured timeout (RoundConfig.rbf_timeout_seconds)? Kept separate from the - I/O-heavy bump_fee() so it's trivially unit-testable.""" + """Pure decision: has this pending tx gone unconfirmed for longer than the + configured timeout (RoundConfig.rbf_timeout_seconds) *since it was last + broadcast*? Kept separate from the I/O-heavy bump_fee() so it's trivially + unit-testable. + + Deliberately measured from last_broadcast_at, not broadcast_at: this decides + whether *another* bump is due, which should reset after every bump (a tx just + rebroadcast at a higher fee deserves the same grace period again) — unlike + reconcile.py's abandon check, which must measure from the *first* broadcast so + repeated bumping can't indefinitely postpone ever giving up on a tx (B-27).""" if pending.status != "pending": return False - return now >= pending.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds) + return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds) async def _signing_context(session: AsyncSession, pending: PendingTransaction) -> tuple: @@ -119,7 +126,11 @@ async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: Pendi pending.raw_tx_hex = raw_hex pending.fee_rate_sat_vb = new_fee_rate pending.attempt_count += 1 - pending.broadcast_at = datetime.now(timezone.utc) + # last_broadcast_at, not broadcast_at (B-27): broadcast_at must stay the *first* + # broadcast, since reconcile.py's abandon-after-N-hours grace period is measured + # from it — overwriting it here used to reset that clock on every bump, so a + # repeatedly-bumped-but-never-mined tx was never abandoned. + pending.last_broadcast_at = datetime.now(timezone.utc) await _retarget_txid_references(session, pending, old_txid, new_txid) await session.commit() diff --git a/app/tx/reconcile.py b/app/tx/reconcile.py index 32ddf79..26683ce 100644 --- a/app/tx/reconcile.py +++ b/app/tx/reconcile.py @@ -129,6 +129,12 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl def _is_due(row: PendingTransaction, now: datetime) -> bool: + # Deliberately broadcast_at (the *first* broadcast), not last_broadcast_at: an + # RBF bump used to overwrite this same field, which reset this grace period on + # every bump and meant a repeatedly-bumped-but-never-mined tx was never + # abandoned (B-27). tx/broadcast.py:bump_fee now only ever touches + # last_broadcast_at, so this keeps measuring from when the tx first appeared, + # no matter how many times it's since been bumped. grace = _BUILDING_GRACE_SECONDS if row.status == "building" else _ABANDON_AFTER_SECONDS return now >= row.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=grace) diff --git a/migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py b/migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py new file mode 100644 index 0000000..d56abc8 --- /dev/null +++ b/migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py @@ -0,0 +1,41 @@ +"""add last_broadcast_at to pending_transactions + +Fixes B-27: bump_fee used to overwrite broadcast_at on every RBF bump, but +tx/reconcile.py's abandon-after-N-hours grace period is measured from that same +column — so a transaction bumped repeatedly but never mined reset that clock on +every bump and was never abandoned. broadcast_at now stays the *first* broadcast +(what the reconciler measures from); last_broadcast_at is the new column bump_fee +updates and should_bump reads to decide whether another bump is due. + +Backfilled from the existing broadcast_at (the best available approximation for +rows written before this column existed — for a row never bumped it's exact) +before the NOT NULL constraint is applied, so this is safe against any existing +data. + +Revision ID: 861e76aaf34c +Revises: 8a1c4e7b2d90 +Create Date: 2026-07-27 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '861e76aaf34c' +down_revision: Union[str, Sequence[str], None] = '8a1c4e7b2d90' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('pending_transactions', sa.Column('last_broadcast_at', sa.DateTime(), nullable=True)) + op.execute('UPDATE pending_transactions SET last_broadcast_at = broadcast_at') + with op.batch_alter_table('pending_transactions') as batch_op: + batch_op.alter_column('last_broadcast_at', nullable=False) + + +def downgrade() -> None: + op.drop_column('pending_transactions', 'last_broadcast_at') diff --git a/tests/unit/test_broadcast.py b/tests/unit/test_broadcast.py index 3ccc33e..d7dee34 100644 --- a/tests/unit/test_broadcast.py +++ b/tests/unit/test_broadcast.py @@ -22,7 +22,7 @@ def _key(seed_byte: int) -> HDKey: def test_should_bump_false_before_timeout(): pending = PendingTransaction( kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending", - broadcast_at=datetime.now(timezone.utc), + broadcast_at=datetime.now(timezone.utc), last_broadcast_at=datetime.now(timezone.utc), ) assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False @@ -31,6 +31,7 @@ def test_should_bump_true_after_timeout(): pending = PendingTransaction( kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending", broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), ) assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is True @@ -39,6 +40,19 @@ def test_should_bump_false_when_not_pending(): pending = PendingTransaction( kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="confirmed", broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + ) + assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False + + +def test_should_bump_measures_from_last_broadcast_not_first(monkeypatch): + """B-27 regression: a tx first broadcast long ago, but bumped recently, must not + be due for another bump yet — should_bump has to look at last_broadcast_at, not + the original broadcast_at, or every tick would try to re-bump it.""" + pending = PendingTransaction( + kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending", + broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=10_000), + last_broadcast_at=datetime.now(timezone.utc), ) assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False @@ -136,6 +150,63 @@ async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory): assert row.attempt_count == 2 +async def test_bump_fee_leaves_broadcast_at_untouched(session_factory): + """B-27 regression: bump_fee must only ever update last_broadcast_at. Before + this, it overwrote broadcast_at on every bump — the same field + tx/reconcile.py's abandon-after-N-hours grace period measures from — so a + repeatedly-bumped-but-never-mined tx reset that clock forever and was never + abandoned.""" + from app.wallet.hd import derive_user_address, derive_user_key + + signer = derive_user_key(0) + my_address = derive_user_address(0) + from_script = script.p2wpkh(signer.to_public()) + to_address = script.p2wpkh(_key(97).to_public()).address(network=PLM_MAINNET) + + utxo_amount = 150_000_000 + utxo_txid = "33" * 32 + built = build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=[Utxo(utxo_txid, 0, utxo_amount)], + to_address=to_address, + amount_sats=10_000_000, + change_address=my_address, + fee_rate_sat_vb=1, + ) + + original_broadcast_at = datetime.now(timezone.utc) - timedelta(days=1) + async with session_factory() as session: + user = User(username="carol", password_hash="x", derivation_index=0, address=my_address) + session.add(user) + await session.commit() + pending = PendingTransaction( + kind="bet", + user_id=user.id, + current_txid=built.txid, + fee_rate_sat_vb=1, + raw_tx_hex=built.raw_hex, + status="pending", + broadcast_at=original_broadcast_at, + last_broadcast_at=original_broadcast_at, + ) + session.add(pending) + await session.commit() + pending_id = pending.id + + client = FakeClient({utxo_txid: utxo_amount}) + before_bump = datetime.now(timezone.utc) + + async with session_factory() as session: + row = await session.get(PendingTransaction, pending_id) + await bump_fee(session, client, row) + + async with session_factory() as session: + row = await session.get(PendingTransaction, pending_id) + assert row.broadcast_at.replace(tzinfo=timezone.utc) == original_broadcast_at + assert row.last_broadcast_at.replace(tzinfo=timezone.utc) >= before_bump + + async def test_bump_fee_raises_when_no_change_output(session_factory): from app.wallet.hd import derive_user_address, derive_user_key diff --git a/tests/unit/test_reconcile.py b/tests/unit/test_reconcile.py index 3c757a3..2785a7b 100644 --- a/tests/unit/test_reconcile.py +++ b/tests/unit/test_reconcile.py @@ -59,7 +59,14 @@ _RAW_TX = ( ) -async def _seed_bet(session_factory, *, pending_status: str, participant_status: str, age_seconds: int): +async def _seed_bet( + session_factory, + *, + pending_status: str, + participant_status: str, + age_seconds: int, + last_broadcast_age_seconds: int | None = None, +): from datetime import datetime, timedelta, timezone async with session_factory() as session: @@ -85,6 +92,10 @@ async def _seed_bet(session_factory, *, pending_status: str, participant_status: status=participant_status, ) ) + # last_broadcast_age_seconds defaults to age_seconds (never bumped): the two + # timestamps only diverge in the B-27 regression test below, which simulates + # a tx that's been bumped recently but first appeared long ago. + last_age = age_seconds if last_broadcast_age_seconds is None else last_broadcast_age_seconds session.add( PendingTransaction( kind="bet", @@ -95,6 +106,7 @@ async def _seed_bet(session_factory, *, pending_status: str, participant_status: raw_tx_hex=_RAW_TX, status=pending_status, broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=age_seconds), + last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=last_age), ) ) await session.commit() @@ -167,6 +179,27 @@ async def test_leaves_a_recently_broadcast_pending_row_alone(session_factory): assert await reconcile_once(session_factory, UnknownTxClient()) == 0 +async def test_abandons_a_repeatedly_bumped_tx_despite_a_recent_last_broadcast(session_factory): + """B-27 regression: before last_broadcast_at existed, bump_fee overwrote + broadcast_at on every bump, which is the same field the abandon grace period is + measured from — so a tx first seen long ago but bumped minutes ago (exactly what + a stuck-but-repeatedly-bumped tx looks like) reset its own clock forever and was + never abandoned. The reconciler must still abandon it based on when it *first* + appeared, ignoring how recently it was last bumped.""" + await _seed_bet( + session_factory, + pending_status="pending", + participant_status="broadcast", + age_seconds=7 * 3600, # first broadcast 7h ago — past the 6h abandon window + last_broadcast_age_seconds=60, # bumped a minute ago + ) + + assert await reconcile_once(session_factory, UnknownTxClient()) == 1 + + async with session_factory() as session: + assert (await session.scalars(select(PendingTransaction))).one().status == "failed" + + async def test_transport_failure_never_abandons_anything(session_factory): """A dead connection says nothing about the transaction. Treating it as "gone" would release coins for transactions that are perfectly alive.""" From 7224ca0e6606b2bcc25528691e1c3229197a7e47 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 09:42:24 +0200 Subject: [PATCH 05/29] Shorten the fixed B-25/26/27 entries in BUGS.md Same as before: once a bug is fixed, its long write-up collapses into a short paragraph pointing at the fix commits instead of repeating what the code and commit messages already say. File goes from 442 to 343 lines. --- BUGS.md | 142 +++++++++----------------------------------------------- 1 file changed, 22 insertions(+), 120 deletions(-) diff --git a/BUGS.md b/BUGS.md index 495b281..8f47162 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,23 +1,16 @@ # 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-25 … B-49. B-25, B-26 and B-27 are fixed as of 2026-07-27; -the other 22 are open. 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 (B-25, B-26 and B-27 -together brought the suite from 139 to 151). +7 medium, 8 low), listed below as B-25 … B-49. B-25, B-26 and B-27 are fixed (see "Previously +fixed" below); the other 22 are open. 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 +three fixes so far brought the suite from 139 to 151). -The recurring pattern across B-29 and B-36 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. -Outgoing transactions reconcile; deposits do not. - -**`paying_out` is now fully recoverable, not just idempotent.** B-25 made a payout retry -*safe* (persisted before broadcast, guarded against double-spend); B-26 made it *automatic* -(the scheduler retries a stuck `paying_out` round on its own, throttled, and every failure — -including ones that used to fail silently — is now audit-logged with a reason). Together -these close every way a payout specifically could wedge the lottery forever. What's still open -in the same family is narrower: the "drawing" phase (waiting on a block) has no equivalent -resume-after-restart or stall visibility — see B-36 and the "scheduler doesn't resume" entry -in CLAUDE.md's Known gaps, which this doesn't touch. +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. +Outgoing transactions reconcile; deposits do not (B-29). The payout phase is now fully +recoverable; the "drawing" phase (waiting on a block) still has no equivalent +resume-after-restart or stall visibility (B-36). For limitations that are accepted by design rather than bugs (single-shared-token admin auth, single-process assumptions, no user-facing history, etc.), see "Known gaps / TODO" in @@ -126,8 +119,8 @@ ignores the `le=10_000` bound the admin panel enforces on the config field. **Proposed fix.** Compute the delta from the actual replacement vsize (`fee_delta = max(new_fee - old_fee, ceil(vsize * incremental_relay_rate))`) so the bump is always relay-valid. Cap `new_fee_rate` at the configured maximum and raise `RbfError` once -reached, so the transaction falls through to the reconciler (which needs B-27 fixed to -actually act on it) rather than being retried indefinitely. +reached, so the transaction falls through to the reconciler (which, since B-27, correctly +abandons it) rather than being retried indefinitely. ### B-33 — No brute-force protection on a custodial wallet @@ -328,108 +321,17 @@ already does. ## Previously fixed -### B-25 — The payout has no two-phase write, unlike bets and withdrawals - -`rounds/scheduler.py` used to broadcast the payout and only afterwards write `payout_txid` -and the `PendingTransaction`. A crash in that window — and `docker-compose.yml` sets -`restart: unless-stopped`, so a crash means an automatic restart — left a payout on-chain -with **no record at all**: the round stuck in `paying_out`, the reconciler with nothing to -resolve, and a manual retry that would pay the winner a second time (pool UTXOs are not -tracked in `utxo_events`, so nothing reserved them). - -This was exactly what B-08 fixed for `place_bet`/`request_withdrawal`; the same fix had never -been applied to the path that moves the most money. - -**Fixed:** `_trigger_payout` (`rounds/scheduler.py`) now has four phases instead of three — -read, *build* (network read only, no write), *persist the intent as `PendingTransaction(kind= -"payout", status="building")` and commit*, then broadcast and promote to `"pending"`. A -broadcast rejection now leaves that `"building"` row behind for the existing reconciler -(`tx/reconcile.py`) to resolve — its generic `building`/`pending` handling already covered a -`payout` kind correctly (including clearing `payout_txid` on abandonment), so no changes were -needed there. - -Two guards were added alongside the two-phase write, since pool UTXOs are invisible to -`utxo_events` and so can never be released/reserved the way a user's own UTXOs are: -`_trigger_payout` now refuses to build a second payout for a round that already has a -non-terminal `PendingTransaction(kind="payout")`, and the payout builder excludes any UTXO -already referenced by *any* non-terminal payout transaction (`_reserved_payout_outpoints`) — -not just this round's — so a stale payout from an earlier round that the reconciler hasn't -abandoned yet can't be double-spent by a fresh attempt. `should_bump`/reconciler retry timing -around a fee-bumped payout is unaffected by this fix (see B-27, still open). - -This made a payout retry *safe*; B-26 (below) is what makes one *automatic*. Regression -tests: `tests/unit/test_scheduler.py` -(`test_trigger_payout_persists_before_broadcasting`, -`test_trigger_payout_broadcast_failure_leaves_a_recoverable_row`, -`test_trigger_payout_skips_when_already_in_flight`, -`test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payout`). - -### B-26 — A transient failure at payout time wedges the lottery permanently - -`rounds/scheduler.py`: if `listener.client is None` when `_trigger_payout` starts, it returned -without recording anything. `_trigger_payout` was called exactly once, from `_close_and_draw`, -and `_tick` ignored any round not in `open`/`closing`. The round stayed in `paying_out`, no new -round could open, and — unlike the generic `except Exception` branch — nothing was written to -`audit_log`, so `/admin` showed a stalled state with no explanation. - -The payout runs immediately after a ~2-minute wait on a block, so an Electrum drop in that -window is entirely plausible. Same shape applied to `InsufficientFundsError`, a missing -`fee_address` and a missing winner user — none of them logged anything either. - -CLAUDE.md listed "payout retry" as an accepted gap, but treated it as an operational -inconvenience; in practice it was a single point of failure that stopped the whole platform, -including across a process restart while a round was `paying_out`. - -**Fixed:** two changes, matching the proposed fix exactly. (a) Every early return in -`_trigger_payout` — not connected, no `fee_address`, winner not found, insufficient pool -UTXOs, a build error, a rejected broadcast — now calls `_log_payout_failure` with a `reason` -string in the payload, so `/admin`'s audit log always shows *why* a round is stuck, not just -that it is. (b) `_tick` now handles `status == "paying_out"` by calling the new -`_retry_payout_if_due`, which re-invokes `_trigger_payout` unless the most recent -`payout_failed` audit entry for this round is younger than `_PAYOUT_RETRY_INTERVAL_SECONDS` -(60s) — throttled so a persistently-broken payout (e.g. an operator hasn't set `fee_address` -yet) doesn't retry, and re-log a failure, on every 5-second tick. - -Because B-25 already made `_trigger_payout` idempotent (it no-ops if a non-terminal payout -`PendingTransaction` already exists for the round) and persists before broadcasting, this -retry is safe to fire on a process restart too: a round found `paying_out` at startup — whose -payout may have already broadcast, may never have been attempted, or may have been abandoned -by the reconciler — is retried the same way, closing the `paying_out` half of the "scheduler -doesn't resume mid-flight rounds after a restart" gap in CLAUDE.md (the "drawing"/block-wait -half is unrelated and still open, see B-36). - -Regression tests: `tests/unit/test_scheduler.py` -(`test_trigger_payout_logs_a_failure_when_not_connected`, -`test_trigger_payout_logs_a_failure_when_fee_address_missing`, -`test_tick_retries_a_stuck_paying_out_round_with_no_recent_failure`, -`test_tick_throttles_retry_after_a_recent_payout_failure`, -`test_tick_retries_once_the_throttle_window_has_elapsed`). - -### B-27 — Every RBF bump resets the reconciler's abandon clock, so it never fires - -`tx/broadcast.py` used to set `pending.broadcast_at = now` on each bump, but -`tx/reconcile.py`'s `_is_due` computes the 6-hour abandon deadline **from that same field**. - -With the default `rbf_timeout_seconds = 900`, a transaction that is successfully bumped every -15 minutes but never mined reset the counter long before it could reach 6 hours: it was -**never abandoned**, its UTXOs never returned to the user, and if it was a bet the round stayed -in `closing` indefinitely (`scheduler.py:90-91`). `reconcile.py` exists precisely to prevent -this, and the bumper disarmed it. - -**Fixed:** the field is split, exactly as proposed. `PendingTransaction` gained a -`last_broadcast_at` column (Alembic migration `861e76aaf34c`, backfilled from the existing -`broadcast_at` for every pre-existing row, then made `NOT NULL`). `broadcast_at` is now never -rewritten after creation — it stays the *first* broadcast, which is what `reconcile.py:_is_due` -already read and continues to read unchanged. `bump_fee` (`tx/broadcast.py`) now updates -`last_broadcast_at` instead, and `should_bump` reads `last_broadcast_at` rather than -`broadcast_at` — correctly, since *that* decision (is another bump due?) should reset after -every bump, unlike the reconciler's abandon check, which must not. - -Regression tests: `tests/unit/test_broadcast.py` -(`test_should_bump_measures_from_last_broadcast_not_first`, -`test_bump_fee_leaves_broadcast_at_untouched`) and `tests/unit/test_reconcile.py` -(`test_abandons_a_repeatedly_bumped_tx_despite_a_recent_last_broadcast`, the direct proof that -a tx bumped minutes ago but first broadcast 7 hours ago is still abandoned). +B-25 (the payout had no two-phase write, unlike bets and withdrawals — a crash or rejected +broadcast could leave money on-chain with no DB record, or leave the round wedged with no way +to retry safely), B-26 (a transient failure or a process restart at payout time wedged the +round in `paying_out` forever, with most failure paths logging nothing) and B-27 (every RBF +bump reset the reconciler's own abandon clock, so a repeatedly-bumped-but-never-mined +transaction was never abandoned) are fixed as of 2026-07-27. Together B-25 and B-26 make +`paying_out` fully recoverable — a payout retry is now safe (persisted before broadcast, +guarded against double-spend) and automatic (the scheduler retries a stuck round on its own, +throttled, with every failure audit-logged with a reason, including across a process restart). +See git history (commits `f13f685`, `50a43ae`, `933760e`) for the fix-by-fix breakdown; the +regression suite grew from 139 to 151 tests over the 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, From 0ce0562fd736f3e064d256f2502fc3a74f02b897 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 10:07:21 +0200 Subject: [PATCH 06/29] Validate Electrum headers and corroborate the draw's block (B-28) A single hostile Electrum server, or a MITM on the one active connection, could fabricate the block header the draw's entropy comes from and so pick the winner of every round: headers were accepted with no proof-of-work check and no link to the previous tip. app/rounds/draw.py adds header_meets_its_own_target (rejects a header whose hash doesn't satisfy the difficulty target it claims) and header_prev_hash. electrum/listener.py's _apply_header now rejects a header failing either check by raising HeaderValidationError, which ends the session the same way a dropped connection would so the listener rotates to the next configured server. ElectrumListener gains corroborate_header: before the draw uses a block, it's independently checked against the other configured servers and needs a majority to agree. rounds/scheduler.py's _wait_for_next_block now calls this and, on failure, logs why and waits for a further block instead of ever using an uncorroborated header. Certificate/hostname verification stays disabled, so this doesn't cover an attacker able to MITM every configured server at once - BUGS.md notes that as not covered. Suite grows from 151 to 165 tests. BUGS.md moves B-28 to Previously fixed. --- BUGS.md | 52 ++------ app/electrum/listener.py | 90 +++++++++++++- app/rounds/draw.py | 67 ++++++++++ app/rounds/scheduler.py | 38 +++++- tests/unit/test_draw.py | 51 ++++++-- tests/unit/test_electrum_listener.py | 178 +++++++++++++++++++++++++-- tests/unit/test_scheduler.py | 50 ++++++++ 7 files changed, 463 insertions(+), 63 deletions(-) diff --git a/BUGS.md b/BUGS.md index 8f47162..429d36b 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,10 +1,11 @@ # 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-25 … B-49. B-25, B-26 and B-27 are fixed (see "Previously -fixed" below); the other 22 are open. 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 -three fixes so far brought the suite from 139 to 151). +7 medium, 8 low), listed below as B-29 … B-49. B-25 through B-28 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 21 are High/Medium/Low. +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 four fixes so far brought the suite from +139 to 165). 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. @@ -18,31 +19,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD --- -## Critical - -### B-28 — A hostile Electrum server (or a MITM) can choose the winner - -`electrum/listener.py:167-186` accepts any header whose `height >= tip_height`: no -proof-of-work check, no linkage to the previous block hash. That header is the **sole source -of entropy for the draw** (`scheduler.py:129`). - -In parallel, `electrum/client.py:104-106` sets `check_hostname = False` and -`verify_mode = CERT_NONE`. The comment justifies this with "the protocol's trust model is -server consensus" — but there is no consensus here: one server at a time, rotated over a list -of arbitrary third parties. So a hostile server, or anyone able to MITM a connection that -validates no certificate, can fabricate a header and thereby decide who wins every round. - -**Proposed fix, in order of value.** (1) Validate headers before accepting them: check the -PoW against the claimed target and that `prev_block` matches the current tip; reject anything -that fails. (2) Do not trust one server for the draw — fetch the header for -`draw_block_height` from *several* endpoints in the rotation and require agreement before -using it as the seed. (3) Pin certificates (or verify hostnames) for the configured servers -rather than disabling verification wholesale. Longer term this is the argument for replacing -the v1 draw algorithm — CLAUDE.md already calls it a replaceable component — with a scheme -that does not depend on a single unauthenticated data source. - ---- - ## High ### B-29 — `detect_external_spends` is irreversible and trusts a single response @@ -321,17 +297,13 @@ already does. ## Previously fixed -B-25 (the payout had no two-phase write, unlike bets and withdrawals — a crash or rejected -broadcast could leave money on-chain with no DB record, or leave the round wedged with no way -to retry safely), B-26 (a transient failure or a process restart at payout time wedged the -round in `paying_out` forever, with most failure paths logging nothing) and B-27 (every RBF -bump reset the reconciler's own abandon clock, so a repeatedly-bumped-but-never-mined -transaction was never abandoned) are fixed as of 2026-07-27. Together B-25 and B-26 make -`paying_out` fully recoverable — a payout retry is now safe (persisted before broadcast, -guarded against double-spend) and automatic (the scheduler retries a stuck round on its own, -throttled, with every failure audit-logged with a reason, including across a process restart). -See git history (commits `f13f685`, `50a43ae`, `933760e`) for the fix-by-fix breakdown; the -regression suite grew from 139 to 151 tests over the three. +- **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 + +See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the +B-28 fix). Suite grew from 139 to 165 tests over the four. 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, diff --git a/app/electrum/listener.py b/app/electrum/listener.py index 42c7c43..327462b 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -9,6 +9,12 @@ from app.db.models import User from app.deposits.service import credit_confirmed_utxos, detect_external_spends from app.electrum.client import ElectrumClient, ElectrumEndpoint from app.electrum.scripthash import address_to_scripthash +from app.rounds.draw import ( + HeaderValidationError, + header_hex_to_block_hash, + header_meets_its_own_target, + header_prev_hash, +) from app.rounds.events import broadcaster logger = logging.getLogger(__name__) @@ -18,6 +24,12 @@ logger = logging.getLogger(__name__) # difference between noticing the drop in a minute and never noticing it at all. _PING_INTERVAL_SECONDS = 60 +# B-28: how long to wait for any *one* other server's answer when corroborating the +# draw's block header. Shorter than the standard request timeout since this is a +# supplementary check across several servers at once — a single slow fallback +# shouldn't hold up the others. +_CORROBORATION_TIMEOUT_SECONDS = 10 + class ElectrumListener: """Long-lived background task: keeps one Electrum connection open, subscribes @@ -173,8 +185,19 @@ class ElectrumListener: the draw's wait. height and hex are applied together or not at all — applying a losing header's hex would leave tip_height and tip_header_hex describing different blocks, and that hex is the draw's entropy source. + + Two validation checks guard against a hostile or MITM'd server simply + fabricating a header (B-28), since that header is the draw's sole source of + entropy: it must satisfy the difficulty target it claims for itself, and — + when it's a direct single-block advance from our own current tip, the only + case we can check without a full header chain — it must chain from that + tip's hash. Either failure raises HeaderValidationError rather than + silently ignoring the header, which (via _consume_headers/_run_once) ends + this session the same way a dropped connection would, so run() rotates to + the next configured server instead of continuing to trust this one. """ height = header["height"] + header_hex = header.get("hex") if height < self.tip_height: logger.warning( "ignoring Electrum header at height %s, below the current tip %s (reorg or server switch?)", @@ -182,8 +205,73 @@ class ElectrumListener: self.tip_height, ) return + + if header_hex: + if not header_meets_its_own_target(header_hex): + raise HeaderValidationError( + f"header at height {height} does not satisfy its own claimed difficulty target" + ) + if ( + self.tip_header_hex + and height == self.tip_height + 1 + and header_prev_hash(header_hex) != header_hex_to_block_hash(self.tip_header_hex) + ): + raise HeaderValidationError( + f"header at height {height} does not chain from the current tip (height {self.tip_height})" + ) + self.tip_height = height - self.tip_header_hex = header.get("hex") + self.tip_header_hex = header_hex + + async def corroborate_header(self, height: int, expected_hash: str) -> bool: + """B-28: independently ask every *other* configured server for the header + at `height` and require a strict majority of the ones that actually answer + to agree with `expected_hash` — the hash our own active connection + reported — before the draw (rounds/scheduler.py:_wait_for_next_block) treats + it as trustworthy entropy. Without this, a single hostile server (or a MITM + on the one active connection) can single-handedly decide who wins every + round; this raises the bar to controlling a majority of the configured + servers. + + Returns True if there are no other servers configured at all — a + single-endpoint deployment has nothing to corroborate against, and accepted + that risk when ELECTRUM_FALLBACK_SERVERS was left empty (see CLAUDE.md). + Also returns False (never silently "passes") if none of the other servers + could be reached at all, since an unreachable network answers nothing about + whether the header is genuine. + """ + others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint] + if not others: + return True + + async def _ask(endpoint: ElectrumEndpoint) -> str | None: + client = self._client_factory(endpoint) + try: + await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS) + result = await asyncio.wait_for( + client.request("blockchain.block.header", [height]), + timeout=_CORROBORATION_TIMEOUT_SECONDS, + ) + if not isinstance(result, str): + return None + return header_hex_to_block_hash(result) + except Exception: + return None + finally: + await client.close() + + results = await asyncio.gather(*(_ask(endpoint) for endpoint in others)) + responded = [block_hash for block_hash in results if block_hash is not None] + if not responded: + logger.warning( + "could not corroborate block %s header with any of %s other configured server(s)", + height, + len(others), + ) + return False + + agreements = sum(1 for block_hash in responded if block_hash == expected_hash) + return agreements * 2 > len(responded) async def _consume_headers(self, queue: asyncio.Queue) -> None: while True: diff --git a/app/rounds/draw.py b/app/rounds/draw.py index 9e26ffc..35107f8 100644 --- a/app/rounds/draw.py +++ b/app/rounds/draw.py @@ -1,5 +1,22 @@ import hashlib +# Byte offsets of a standard 80-byte block header: version(4) + prev_block(32) + +# merkle_root(32) + timestamp(4) + bits(4) + nonce(4). +_HEADER_LENGTH_BYTES = 80 +_PREV_BLOCK_OFFSET = 4 +_PREV_BLOCK_LENGTH = 32 +_BITS_OFFSET = 72 +_BITS_LENGTH = 4 + + +class HeaderValidationError(Exception): + """Raised by ElectrumListener._apply_header (B-28) when a header either doesn't + satisfy the difficulty target it claims for itself, or doesn't chain from the + previously accepted tip. Letting this propagate out of the header-consuming + task ends the current Electrum session the same way a dropped connection would + (see ElectrumListener._run_once), so the listener rotates to the next + configured server instead of trusting a header a server just forged.""" + def header_hex_to_block_hash(header_hex: str) -> str: """Block hash from a raw Electrum header: sha256d, byte-reversed, hex. @@ -10,6 +27,56 @@ def header_hex_to_block_hash(header_hex: str) -> str: return digest[::-1].hex() +def header_prev_hash(header_hex: str) -> str: + """The header's `prev_block` field, byte-reversed to the same conventional + (display) order as header_hex_to_block_hash's return value, so the two can be + compared directly to check that one header actually chains from another.""" + header_bytes = bytes.fromhex(header_hex) + prev = header_bytes[_PREV_BLOCK_OFFSET : _PREV_BLOCK_OFFSET + _PREV_BLOCK_LENGTH] + return prev[::-1].hex() + + +def _target_from_bits(bits: int) -> int: + """Decompress Bitcoin-style compact `nBits` difficulty encoding into the full + 256-bit target a valid header's hash must be less than or equal to.""" + exponent = bits >> 24 + mantissa = bits & 0xFFFFFF + if exponent <= 3: + return mantissa >> (8 * (3 - exponent)) + return mantissa << (8 * (exponent - 3)) + + +def header_meets_its_own_target(header_hex: str) -> bool: + """Whether this header's hash satisfies the difficulty target *it claims for + itself* (the `bits` field). Rejects a header that was never actually mined — + e.g. one fabricated wholesale by a hostile or MITM'd Electrum server (B-28), + since satisfying a self-chosen target still requires real proof-of-work. + + This does NOT — and, short of downloading and validating the full header + chain's difficulty-retarget history, cannot — catch a header honestly mined at + a real but implausibly low self-chosen difficulty: a server could still declare + an easy target and grind it out with modest hardware. That residual risk is why + the draw additionally requires the winning block's header to be corroborated by + the *other* configured servers before using it as the seed (see + ElectrumListener.corroborate_header and rounds/scheduler.py:_wait_for_next_block) + rather than relying on this check alone. + """ + header_bytes = bytes.fromhex(header_hex) + if len(header_bytes) != _HEADER_LENGTH_BYTES: + return False + bits = int.from_bytes(header_bytes[_BITS_OFFSET : _BITS_OFFSET + _BITS_LENGTH], "little") + target = _target_from_bits(bits) + if target <= 0: + return False + digest = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest() + # The hash as the integer comparable against `target`: this is the same digest + # header_hex_to_block_hash reverses into the conventional display hex, so + # reading it byte-reversed as a big-endian int is equivalent to reading the + # original digest bytes as little-endian — both give the same integer. + hash_int = int.from_bytes(digest, "little") + return hash_int <= target + + def draw_winner(participants: list[str], block_hash_hex: str) -> str: """v1 draw algorithm (flowchart.mmd, node R): seed = block hash as an integer, index = seed mod participant_count, winner = participants[index]. Anyone can diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index 1159c6b..e71fdb6 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -142,7 +142,7 @@ class RoundScheduler: broadcaster.publish() tip_at_close = self._listener.tip_height - block_height, block_hash = await self._wait_for_next_block(tip_at_close) + block_height, block_hash = await self._wait_for_next_block(round_id, tip_at_close) winner_address = draw_winner(addresses, block_hash) async with self._session_factory() as session: @@ -172,10 +172,38 @@ class RoundScheduler: logger.info("round %s: winner=%s pool=%s", round_id, winner_address, pool_amount) await self._trigger_payout(round_id) - async def _wait_for_next_block(self, tip_at_close: int) -> tuple[int, str]: - while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex: - await asyncio.sleep(_TICK_INTERVAL_SECONDS) - return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex) + async def _wait_for_next_block(self, round_id: int, tip_at_close: int) -> tuple[int, str]: + """Waits for a block after tip_at_close and, before handing it back as the + draw's entropy source, requires it to be corroborated by the other + configured Electrum servers (B-28) — our own active connection is exactly + the thing a hostile server or a MITM would control, so its header alone is + not enough to seed a payout. A candidate that fails corroboration is never + used: this keeps waiting for a further block and tries corroborating that + one instead, logging why every time so a stuck draw is visible in + /admin's audit log rather than a silent, unexplained wait.""" + while True: + while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex: + await asyncio.sleep(_TICK_INTERVAL_SECONDS) + height = self._listener.tip_height + block_hash = header_hex_to_block_hash(self._listener.tip_header_hex) + if await self._listener.corroborate_header(height, block_hash): + return height, block_hash + logger.error( + "round %s: block %s header %s could not be corroborated by other Electrum servers; " + "waiting for a further block", + round_id, + height, + block_hash, + ) + async with self._session_factory() as session: + await write_audit_log( + session, + "draw_header_corroboration_failed", + {"height": height, "reported_hash": block_hash}, + round_id=round_id, + ) + await session.commit() + tip_at_close = height async def _retry_payout_if_due(self, round_id: int) -> None: """B-26: whether a "paying_out" round is due for another payout attempt. diff --git a/tests/unit/test_draw.py b/tests/unit/test_draw.py index 4f1bea6..c2a63c1 100644 --- a/tests/unit/test_draw.py +++ b/tests/unit/test_draw.py @@ -1,19 +1,50 @@ import pytest -from app.rounds.draw import draw_winner, header_hex_to_block_hash +from app.rounds.draw import ( + draw_winner, + header_hex_to_block_hash, + header_meets_its_own_target, + header_prev_hash, +) + +# Real PLM mainnet block 477486: header from blockchain.block.header, hash +# cross-checked against the blockhash reported by blockchain.transaction.get for a +# tx confirmed in that block. +_REAL_HEADER_HEX = ( + "0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000" + "009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875" + "e6a47a61b1ad8012582" +) def test_header_hex_to_block_hash_matches_known_mainnet_block(): - # Real PLM mainnet block 477486: header from blockchain.block.header, hash - # cross-checked against the blockhash reported by blockchain.transaction.get - # for a tx confirmed in that block. - header_hex = ( - "0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000" - "009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875" - "e6a47a61b1ad8012582" - ) known_block_hash = "00000000000008788b55ade13b74d54ceffda9e54315b802411be1ca65064e86" - assert header_hex_to_block_hash(header_hex) == known_block_hash + assert header_hex_to_block_hash(_REAL_HEADER_HEX) == known_block_hash + + +def test_header_meets_its_own_target_accepts_a_real_mined_header(): + """B-28: a genuinely mined mainnet header must pass its own self-consistency + check — this isn't just a synthetic-header property.""" + assert header_meets_its_own_target(_REAL_HEADER_HEX) is True + + +def test_header_meets_its_own_target_rejects_a_tampered_header(): + """Flipping a single nonce bit changes the hash completely (avalanche effect) + without changing the claimed difficulty, so a tampered-but-otherwise-real + header should almost certainly fail — this is what would catch a + hostile/MITM'd server replaying a real header with a doctored field.""" + tampered = bytearray(bytes.fromhex(_REAL_HEADER_HEX)) + tampered[-1] ^= 0xFF # flip the last byte of the nonce + assert header_meets_its_own_target(tampered.hex()) is False + + +def test_header_meets_its_own_target_rejects_wrong_length(): + assert header_meets_its_own_target("aa" * 10) is False + + +def test_header_prev_hash_matches_the_known_previous_block(): + # Block 477486's predecessor, 477485 — independently known from the same chain. + assert header_prev_hash(_REAL_HEADER_HEX) == "000000000000170c8179321dfd323075bfe7365f7c10f51402d10052e49ec3fb" def test_draw_winner_is_deterministic_and_within_range(): diff --git a/tests/unit/test_electrum_listener.py b/tests/unit/test_electrum_listener.py index d0a63ca..2e18730 100644 --- a/tests/unit/test_electrum_listener.py +++ b/tests/unit/test_electrum_listener.py @@ -1,5 +1,6 @@ """Listener-level behaviour: server rotation on failure (the fallback-servers -feature), and the chain-tip monotonicity guard (B-19). +feature), the chain-tip monotonicity guard (B-19), header validation and +multi-server corroboration (B-28). The reconnect loop itself (B-01) is covered from the client side in test_electrum_client.py — what's asserted here is that the listener *acts* on a @@ -7,6 +8,7 @@ dead connection by moving to the next server instead of retrying the same one. """ import asyncio +import struct import pytest from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine @@ -14,6 +16,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base from app.electrum.client import ElectrumEndpoint from app.electrum.listener import ElectrumListener +from app.rounds.draw import HeaderValidationError, header_hex_to_block_hash, header_meets_its_own_target @pytest.fixture @@ -31,6 +34,34 @@ _ENDPOINTS = [ ElectrumEndpoint("third.example", 50001, False), ] +# A regtest-style trivial difficulty target (~50% of hashes satisfy it), so mining +# a real, self-consistent test header takes a handful of nonce attempts rather than +# needing actual mainnet-grade hashpower. Not a valid PLM mainnet difficulty — +# irrelevant here, since header_meets_its_own_target only checks self-consistency. +_EASY_BITS = 0x207FFFFF + + +def _build_header(prev_hash_hex: str, nonce: int, *, bits: int = _EASY_BITS) -> str: + return ( + struct.pack(" str: + """A real header that satisfies its own claimed target — good enough to + exercise header_meets_its_own_target/_apply_header for real, without needing + genuine PLM-mainnet-grade hashpower.""" + for nonce in range(100_000): + header_hex = _build_header(prev_hash_hex, nonce, bits=bits) + if header_meets_its_own_target(header_hex): + return header_hex + raise RuntimeError("failed to mine a test header within the attempt budget") + async def test_rotates_to_the_next_server_after_a_failed_session(session_factory): """One unreachable server should cost a single attempt, not an outage: every @@ -132,11 +163,144 @@ def test_tip_never_moves_backwards(session_factory): a stale one.""" listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) - listener._apply_header({"height": 100, "hex": "aa"}) - assert (listener.tip_height, listener.tip_header_hex) == (100, "aa") + header_100 = _mine_header("00" * 32) + listener._apply_header({"height": 100, "hex": header_100}) + assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) - listener._apply_header({"height": 99, "hex": "bb"}) # reorg, or a server switch - assert (listener.tip_height, listener.tip_header_hex) == (100, "aa") + # A lower height is ignored purely on height, before any header validation even + # runs — reorg or server switch, not a real advance. + listener._apply_header({"height": 99, "hex": "bb"}) + assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) - listener._apply_header({"height": 101, "hex": "cc"}) - assert (listener.tip_height, listener.tip_header_hex) == (101, "cc") + header_101 = _mine_header(header_hex_to_block_hash(header_100)) + listener._apply_header({"height": 101, "hex": header_101}) + assert (listener.tip_height, listener.tip_header_hex) == (101, header_101) + + +# --- B-28: a hostile or MITM'd server can no longer single-handedly decide the +# draw's entropy — header self-consistency/linkage checks, and multi-server +# corroboration for the block the draw actually uses. --------------------------- + + +def test_apply_header_rejects_one_that_fails_its_own_pow_target(session_factory): + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + # Real mainnet-grade difficulty (genesis-era Bitcoin bits): satisfying it by + # chance is astronomically unlikely, so this header is self-inconsistent. + forged = _build_header("00" * 32, nonce=0, bits=0x1D00FFFF) + + with pytest.raises(HeaderValidationError): + listener._apply_header({"height": 100, "hex": forged}) + assert (listener.tip_height, listener.tip_header_hex) == (0, None) # untouched + + +def test_apply_header_rejects_one_that_does_not_chain_from_the_tip(session_factory): + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + header_100 = _mine_header("00" * 32) + listener._apply_header({"height": 100, "hex": header_100}) + + # A single-block advance (101 = 100 + 1) whose prev_block claims an unrelated + # chain — well-formed and self-consistently mined, but not actually built on + # top of our current tip. + disconnected = _mine_header("ff" * 32) + + with pytest.raises(HeaderValidationError): + listener._apply_header({"height": 101, "hex": disconnected}) + assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched + + +def test_apply_header_skips_linkage_check_across_a_height_gap(session_factory): + """A reconnect (or the very first header of a session) hands us whatever the + server's current tip is — which is legitimately not a single-block advance + from whatever we last saw. There's no full header chain to check linkage + against in that case, so only self-consistency is enforced.""" + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + header_100 = _mine_header("00" * 32) + listener._apply_header({"height": 100, "hex": header_100}) + + header_150 = _mine_header("ff" * 32) # unrelated prev_block, height jumps by 50 + listener._apply_header({"height": 150, "hex": header_150}) # must not raise + + assert (listener.tip_height, listener.tip_header_hex) == (150, header_150) + + +async def _endpoint_client_factory(responses: dict[str, object]): + """Builds a client_factory whose fake clients answer blockchain.block.header + per-endpoint according to `responses`: a header hex string to agree/disagree + with, `None` to simulate an unreachable server, or an Exception instance to + simulate a request failure.""" + + class _FakeClient: + def __init__(self, answer): + self._answer = answer + self.closed = False + + async def connect(self): + if isinstance(self._answer, Exception): + raise self._answer + + async def request(self, method, params): + assert method == "blockchain.block.header" + if self._answer is None: + raise ConnectionRefusedError("unreachable") + return self._answer + + async def close(self): + self.closed = True + + def factory(endpoint): + return _FakeClient(responses[endpoint.host]) + + return factory + + +async def test_corroborate_header_true_with_no_other_servers_configured(session_factory): + single = [ElectrumEndpoint("only.example", 50002, True)] + listener = ElectrumListener(lambda endpoint: None, session_factory, single) + assert await listener.corroborate_header(100, "deadbeef") is True + + +async def test_corroborate_header_true_when_others_agree(session_factory): + header_hex = _mine_header("00" * 32) + expected_hash = header_hex_to_block_hash(header_hex) + factory = await _endpoint_client_factory( + {"first.example": header_hex, "second.example": header_hex, "third.example": header_hex} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_header(100, expected_hash) is True + + +async def test_corroborate_header_never_asks_the_currently_active_endpoint(session_factory): + """The active connection is exactly what a hostile server or a MITM would + control — corroborating against it too would defeat the point.""" + header_hex = _mine_header("00" * 32) + expected_hash = header_hex_to_block_hash(header_hex) + # first.example (the active endpoint) would raise if ever queried. + factory = await _endpoint_client_factory( + {"first.example": RuntimeError("must not be called"), "second.example": header_hex, "third.example": header_hex} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + assert listener.current_endpoint.host == "first.example" + + assert await listener.corroborate_header(100, expected_hash) is True + + +async def test_corroborate_header_false_when_majority_disagrees(session_factory): + header_hex = _mine_header("00" * 32) + expected_hash = header_hex_to_block_hash(header_hex) + disagreeing_hex = _mine_header("11" * 32) + factory = await _endpoint_client_factory( + {"first.example": header_hex, "second.example": disagreeing_hex, "third.example": disagreeing_hex} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_header(100, expected_hash) is False + + +async def test_corroborate_header_false_when_nobody_responds(session_factory): + factory = await _endpoint_client_factory( + {"first.example": "irrelevant", "second.example": None, "third.example": ConnectionRefusedError("down")} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_header(100, "deadbeef") is False diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 7b323d2..1d3dc97 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -353,3 +353,53 @@ async def test_tick_retries_once_the_throttle_window_has_elapsed(payout_session_ async with payout_session_factory() as session: pending = (await session.scalars(select(PendingTransaction))).one() assert pending.status == "pending" + + +# --- B-28: the draw must not seed itself from an uncorroborated header ----------- + + +class CorroboratingListener: + """A fake listener whose tip advances the moment a corroboration attempt + fails, simulating a further block arriving — lets tests drive + _wait_for_next_block's retry loop deterministically without real sleeps.""" + + def __init__(self, *, responses: dict[int, bool], advance_to: dict[int, tuple[int, str]] | None = None): + self.tip_height, self.tip_header_hex = next(iter(responses)), "aa" + self._responses = dict(responses) + self._advance_to = advance_to or {} + self.corroboration_calls: list[int] = [] + + async def corroborate_header(self, height: int, expected_hash: str) -> bool: + self.corroboration_calls.append(height) + result = self._responses[height] + if not result and height in self._advance_to: + self.tip_height, self.tip_header_hex = self._advance_to[height] + return result + + +async def test_wait_for_next_block_accepts_an_immediately_corroborated_block(session_factory): + listener = CorroboratingListener(responses={101: True}) + scheduler = RoundScheduler(session_factory, listener) + + height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100) + + assert height == 101 + assert listener.corroboration_calls == [101] + + +async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_factory): + """B-28: an uncorroborated header must never be used — the wait keeps going + until a later block's header *is* corroborated, logging why each time.""" + listener = CorroboratingListener( + responses={101: False, 102: True}, advance_to={101: (102, "bb")} + ) + scheduler = RoundScheduler(session_factory, listener) + + height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100) + + assert height == 102 + assert listener.corroboration_calls == [101, 102] + + async with session_factory() as session: + events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] + assert events == ["draw_header_corroboration_failed"] From e8fdea038938f18c78374fa1b01930e22236e9fd Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 10:25:07 +0200 Subject: [PATCH 07/29] Corroborate an external spend before marking a UTXO gone (B-29) detect_external_spends marked a UTXO spent_txid='external-spend' irreversibly the moment it was missing from one listunspent reply, on one server, with no way to undo it. A rotated-to server that's broken or behind, or an empty reply, could zero a user's balance permanently. Split into three functions in deposits/service.py: find_utxos_missing_ from (read-only candidate detection, and refuses to flag anything at all when listunspent comes back entirely empty for a funded address - that reads as a broken response, not a full sweep), mark_utxos_spent_ externally (persistence only, once a candidate is already confirmed), and reinstate_reappeared_utxos (undoes the mark if the outpoint reappears as unspent later). ElectrumListener gains corroborate_utxo_spent, sharing the same majority-quorum logic corroborate_header already uses for B-28: before a candidate is marked, the other configured servers are asked whether they also see it as spent. _refresh_user now reads candidates, then corroborates each one with no DB session held open across those network calls (same shape as B-18/B-25), then persists. Suite grows from 165 to 176 tests. BUGS.md moves B-29 to Previously fixed. --- BUGS.md | 34 ++----- app/deposits/service.py | 107 +++++++++++++++---- app/electrum/listener.py | 128 +++++++++++++++++------ tests/unit/test_deposits.py | 121 ++++++++++++++++++++-- tests/unit/test_electrum_listener.py | 147 +++++++++++++++++++++++++++ 5 files changed, 453 insertions(+), 84 deletions(-) diff --git a/BUGS.md b/BUGS.md index 429d36b..738bbb3 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,17 +1,17 @@ # 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-29 … B-49. B-25 through B-28 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 21 are High/Medium/Low. +7 medium, 8 low), listed below as B-30 … B-49. B-25 through B-29 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 20 are High/Medium/Low. 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 four fixes so far brought the suite from -139 to 165). +coverage — every fix lands with a regression test (the five fixes so far brought the suite from +139 to 176). 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. -Outgoing transactions reconcile; deposits do not (B-29). The payout phase is now fully -recoverable; the "drawing" phase (waiting on a block) still has no equivalent -resume-after-restart or stall visibility (B-36). +The payout phase is now fully recoverable; the "drawing" phase (waiting on a block) still has +no equivalent resume-after-restart or stall visibility (B-36), and there's still no periodic +deposit-side reconciler independent of scripthash notifications (B-30). For limitations that are accepted by design rather than bugs (single-shared-token admin auth, single-process assumptions, no user-facing history, etc.), see "Known gaps / TODO" in @@ -21,23 +21,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD ## High -### B-29 — `detect_external_spends` is irreversible and trusts a single response - -`deposits/service.py:87` marks `spent_txid = "external-spend"` for any UTXO missing from the -current `listunspent`. There is **no path to undo it**: `credit_confirmed_utxos` skips -`(txid, vout)` keys that already exist, regardless of their spent status (`:19-31`). - -One incomplete `listunspent` — a rotated-to server that is broken or behind, an empty reply on -error, or a reorg — permanently and silently zeroes a user's balance, recoverable only by -editing the database. Crediting is idempotent and conservative; debiting is neither, and it -acts on a single reply from a single unauthenticated server. - -**Proposed fix.** Treat a missing outpoint as *evidence*, not proof. Require the same UTXO to -be absent across N consecutive refreshes (or confirm the spend by looking up the outpoint's -spending tx) before marking it, and skip the whole pass when `listunspent` returns empty for -an address the DB believes is funded. Make the mark reversible: re-crediting should clear -`spent_txid` when the sentinel value is present and the outpoint reappears as unspent. - ### B-30 — No deposit-side reconciler: one missed subscription means deposits are never credited `electrum/listener.py:61-67` (`address_for_new_user`) fires `asyncio.create_task(...)` without @@ -301,9 +284,10 @@ already does. - **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 See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the -B-28 fix). Suite grew from 139 to 165 tests over the four. +B-28/B-29 fixes). Suite grew from 139 to 176 tests over the five. 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, diff --git a/app/deposits/service.py b/app/deposits/service.py index 9c3cd34..4b88aae 100644 --- a/app/deposits/service.py +++ b/app/deposits/service.py @@ -1,3 +1,5 @@ +import logging + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -6,6 +8,8 @@ from app.db.models import UtxoEvent from app.rounds.events import broadcaster from app.wallet.balance import recompute_balance +logger = logging.getLogger(__name__) + async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int: """Insert utxo_events for newly-confirmed entries from an Electrum @@ -59,18 +63,64 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l _EXTERNAL_SPEND_SENTINEL = "external-spend" -async def detect_external_spends(session: AsyncSession, user_id: int, entries: list[dict]) -> int: - """Mirror of credit_confirmed_utxos: catches a UTXO leaving the address - through a transaction this platform never built (e.g. someone spending it - directly with the raw privkey, bypassing /withdrawals entirely). +async def reinstate_reappeared_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int: + """The reverse of a mark applied by find_utxos_missing_from/ + mark_utxos_spent_externally (B-29): if an outpoint we'd previously flagged as + spent outside the platform reappears as unspent in a later listunspent, undo + the mark instead of leaving it permanent no matter what the chain says + afterwards. Cheap and purely DB-side — always safe to run on every refresh. + """ + current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries} - Everything the platform itself spends (bets, withdrawals, payouts) sets - spent_txid at broadcast time, before the tx ever reaches the chain — so by - the time an Electrum refresh runs, an outpoint still marked unspent in our - own DB that Electrum no longer reports as unspent was never on our radar. - entries is this address's current `listunspent`; anything in our unspent - set but missing from it left the address some other way. Returns the - number of UTXOs newly marked spent. + marked_rows = ( + await session.scalars( + select(UtxoEvent).where( + UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL + ) + ) + ).all() + + reinstated = 0 + for row in marked_rows: + if (row.txid, row.vout) not in current_keys: + continue + row.spent_txid = None + await write_audit_log( + session, + "utxo_external_spend_reinstated", + {"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats}, + user_id=user_id, + ) + reinstated += 1 + + if reinstated: + await session.flush() + await recompute_balance(session, user_id) + await session.commit() + broadcaster.publish() + + return reinstated + + +async def find_utxos_missing_from(session: AsyncSession, user_id: int, entries: list[dict]) -> list[UtxoEvent]: + """Candidates for an external spend (B-29): unspent UTXOs the DB believes this + user still holds that are absent from `entries`, this address's current + listunspent. Everything the platform itself spends (bets, withdrawals, + payouts) sets spent_txid at broadcast time, before the tx ever reaches the + chain — so an outpoint still marked unspent in our own DB that Electrum no + longer reports as unspent was never on our own radar. + + Returning a row here is *not* proof it was actually spent — only that this one + server's reply no longer lists it. A single broken, behind, or malicious + server could otherwise zero a user's balance on one bad reply, which is why + the caller (electrum/listener.py:_refresh_user) must independently + corroborate each candidate against other configured servers before treating + it as genuine, rather than this function marking anything itself. + + An entirely empty `entries` for an address the DB believes is funded returns + no candidates at all: it would otherwise flag every one of this user's UTXOs + as missing from a single reply, which is a strong sign of an incomplete or + broken response rather than N independent spends landing in the same refresh. """ current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries} @@ -80,9 +130,32 @@ async def detect_external_spends(session: AsyncSession, user_id: int, entries: l ) ).all() - newly_spent = 0 - for row in unspent_rows: - if (row.txid, row.vout) in current_keys: + if not entries and unspent_rows: + logger.warning( + "listunspent for user_id=%s returned no entries at all while %s UTXO(s) are still recorded " + "unspent — treating this as an incomplete response rather than a full external sweep", + user_id, + len(unspent_rows), + ) + return [] + + return [row for row in unspent_rows if (row.txid, row.vout) not in current_keys] + + +async def mark_utxos_spent_externally(session: AsyncSession, user_id: int, utxo_ids: list[int]) -> int: + """Applies the external-spend sentinel to UTXOs the caller has already + corroborated against other servers (B-29) — this function does no + verification of its own, only persistence, so it never runs with a session + held open across the network calls that verification needs. + + Re-checks each row is still unspent before applying the mark: something else + may have resolved it (a legitimate platform spend, or a prior refresh) between + when the caller read the candidate list and finished corroborating it. + """ + marked = 0 + for utxo_id in utxo_ids: + row = await session.get(UtxoEvent, utxo_id) + if row is None or row.spent_txid is not None: continue row.spent_txid = _EXTERNAL_SPEND_SENTINEL await write_audit_log( @@ -91,12 +164,12 @@ async def detect_external_spends(session: AsyncSession, user_id: int, entries: l {"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats}, user_id=user_id, ) - newly_spent += 1 + marked += 1 - if newly_spent: + if marked: await session.flush() await recompute_balance(session, user_id) await session.commit() broadcaster.publish() - return newly_spent + return marked diff --git a/app/electrum/listener.py b/app/electrum/listener.py index 327462b..f83daf0 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -6,7 +6,12 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker from app.db.models import User -from app.deposits.service import credit_confirmed_utxos, detect_external_spends +from app.deposits.service import ( + credit_confirmed_utxos, + find_utxos_missing_from, + mark_utxos_spent_externally, + reinstate_reappeared_utxos, +) from app.electrum.client import ElectrumClient, ElectrumEndpoint from app.electrum.scripthash import address_to_scripthash from app.rounds.draw import ( @@ -24,10 +29,10 @@ logger = logging.getLogger(__name__) # difference between noticing the drop in a minute and never noticing it at all. _PING_INTERVAL_SECONDS = 60 -# B-28: how long to wait for any *one* other server's answer when corroborating the -# draw's block header. Shorter than the standard request timeout since this is a -# supplementary check across several servers at once — a single slow fallback -# shouldn't hold up the others. +# How long to wait for any *one* other server's answer when corroborating the +# draw's block header (B-28) or a candidate external spend (B-29). Shorter than +# the standard request timeout since this is a supplementary check across several +# servers at once — a single slow fallback shouldn't hold up the others. _CORROBORATION_TIMEOUT_SECONDS = 10 @@ -223,27 +228,49 @@ class ElectrumListener: self.tip_height = height self.tip_header_hex = header_hex - async def corroborate_header(self, height: int, expected_hash: str) -> bool: - """B-28: independently ask every *other* configured server for the header - at `height` and require a strict majority of the ones that actually answer - to agree with `expected_hash` — the hash our own active connection - reported — before the draw (rounds/scheduler.py:_wait_for_next_block) treats - it as trustworthy entropy. Without this, a single hostile server (or a MITM - on the one active connection) can single-handedly decide who wins every - round; this raises the bar to controlling a majority of the configured - servers. + async def _corroborate_majority( + self, + ask: Callable[[ElectrumEndpoint], "asyncio.Future"], + agrees: Callable[[object], bool], + description: str, + ) -> bool: + """Shared quorum logic behind corroborate_header (B-28) and + corroborate_utxo_spent (B-29): ask every *other* configured server (never + the currently active one — that's exactly what a hostile server or a MITM + would control) and require a strict majority of the ones that actually + answer to agree, via `agrees`, with what our own connection reported. - Returns True if there are no other servers configured at all — a - single-endpoint deployment has nothing to corroborate against, and accepted - that risk when ELECTRUM_FALLBACK_SERVERS was left empty (see CLAUDE.md). - Also returns False (never silently "passes") if none of the other servers - could be reached at all, since an unreachable network answers nothing about - whether the header is genuine. + Returns True with no other servers configured — nothing to corroborate + against, a risk accepted when ELECTRUM_FALLBACK_SERVERS was left empty + (see CLAUDE.md). Returns False (never silently "passes") if none of the + others could be reached, since an unreachable network proves nothing + either way. """ others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint] if not others: return True + results = await asyncio.gather(*(ask(endpoint) for endpoint in others)) + responded = [result for result in results if result is not None] + if not responded: + logger.warning( + "could not corroborate %s with any of %s other configured server(s)", description, len(others) + ) + return False + + agreements = sum(1 for result in responded if agrees(result)) + return agreements * 2 > len(responded) + + async def corroborate_header(self, height: int, expected_hash: str) -> bool: + """B-28: is `expected_hash` — the header our own active connection + reported for `height` — corroborated by other configured servers before + the draw (rounds/scheduler.py:_wait_for_next_block) treats it as + trustworthy entropy? Without this, a single hostile server (or a MITM on + the one active connection) can single-handedly decide who wins every + round; this raises the bar to controlling a majority of the configured + servers. See _corroborate_majority for the shared quorum logic. + """ + async def _ask(endpoint: ElectrumEndpoint) -> str | None: client = self._client_factory(endpoint) try: @@ -260,18 +287,32 @@ class ElectrumListener: finally: await client.close() - results = await asyncio.gather(*(_ask(endpoint) for endpoint in others)) - responded = [block_hash for block_hash in results if block_hash is not None] - if not responded: - logger.warning( - "could not corroborate block %s header with any of %s other configured server(s)", - height, - len(others), - ) - return False + return await self._corroborate_majority(_ask, lambda block_hash: block_hash == expected_hash, f"block {height} header") - agreements = sum(1 for block_hash in responded if block_hash == expected_hash) - return agreements * 2 > len(responded) + async def corroborate_utxo_spent(self, scripthash: str, txid: str, vout: int) -> bool: + """B-29: before deposits/service.py's find_utxos_missing_from candidates + are treated as genuinely spent outside the platform, ask the other + configured servers whether *they* also no longer report this outpoint as + unspent. A single broken, behind, or malicious server could otherwise zero + a user's balance on one incomplete listunspent reply. See + _corroborate_majority for the shared quorum logic. + """ + + async def _ask(endpoint: ElectrumEndpoint) -> bool | None: + client = self._client_factory(endpoint) + try: + await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS) + entries = await asyncio.wait_for( + client.listunspent(scripthash), timeout=_CORROBORATION_TIMEOUT_SECONDS + ) + still_unspent = any(e.get("tx_hash") == txid and e.get("tx_pos") == vout for e in entries) + return not still_unspent # True = this server agrees the outpoint is gone + except Exception: + return None + finally: + await client.close() + + return await self._corroborate_majority(_ask, lambda agrees: agrees, f"outpoint {txid}:{vout}") async def _consume_headers(self, queue: asyncio.Queue) -> None: while True: @@ -291,12 +332,35 @@ class ElectrumListener: await self._refresh_user(user_id, scripthash) async def _refresh_user(self, user_id: int, scripthash: str) -> None: + """Three phases, so no DB session is held across a network call (B-18), + same shape as _trigger_payout: read what's needed, corroborate any + candidate external spends against other servers (B-29), then persist. + """ assert self.client is not None entries = await self.client.listunspent(scripthash) + async with self._session_factory() as session: credited = await credit_confirmed_utxos(session, user_id, entries) - spent_externally = await detect_external_spends(session, user_id, entries) + reinstated = await reinstate_reappeared_utxos(session, user_id, entries) + candidates = [ + (row.id, row.txid, row.vout) + for row in await find_utxos_missing_from(session, user_id, entries) + ] + + confirmed_ids = [ + utxo_id + for utxo_id, txid, vout in candidates + if await self.corroborate_utxo_spent(scripthash, txid, vout) + ] + + spent_externally = 0 + if confirmed_ids: + async with self._session_factory() as session: + spent_externally = await mark_utxos_spent_externally(session, user_id, confirmed_ids) + if credited: logger.info("credited %s new UTXO(s) for user_id=%s", credited, user_id) + if reinstated: + logger.info("reinstated %s previously-flagged UTXO(s) for user_id=%s", reinstated, user_id) if spent_externally: logger.warning("%s UTXO(s) spent outside the platform for user_id=%s", spent_externally, user_id) diff --git a/tests/unit/test_deposits.py b/tests/unit/test_deposits.py index 299ec9c..d744916 100644 --- a/tests/unit/test_deposits.py +++ b/tests/unit/test_deposits.py @@ -4,7 +4,12 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base from app.db.models import AuditLog, User, UtxoEvent -from app.deposits.service import credit_confirmed_utxos, detect_external_spends +from app.deposits.service import ( + credit_confirmed_utxos, + find_utxos_missing_from, + mark_utxos_spent_externally, + reinstate_reappeared_utxos, +) @pytest.fixture @@ -55,14 +60,66 @@ async def test_idempotent_on_repeated_notification(session_factory, user_id): assert user.cached_balance_sats == 7_000_000 -async def test_external_spend_marks_utxo_spent_and_corrects_balance(session_factory, user_id): - entries = [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] +# --- B-29: detecting a UTXO spent outside the platform is now a three-step, +# corroborate-before-you-mark process, split across find_utxos_missing_from +# (read-only candidate detection), the caller's own corroboration against other +# servers (electrum/listener.py, not exercised here), and mark_utxos_spent_ +# externally (persistence only, once a candidate is already confirmed). --------- + + +async def test_find_utxos_missing_from_returns_the_missing_candidate(session_factory, user_id): + async with session_factory() as session: + await credit_confirmed_utxos( + session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + ) + + # A different outpoint present in this refresh — our own tracked one is + # genuinely absent from it, not just from an entirely empty reply. + other_entries = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000}] + async with session_factory() as session: + candidates = await find_utxos_missing_from(session, user_id, other_entries) + assert len(candidates) == 1 + assert candidates[0].txid == "dd" * 32 + assert candidates[0].spent_txid is None # read-only: nothing is marked yet + + +async def test_find_utxos_missing_from_returns_nothing_when_present(session_factory, user_id): + entries = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 100, "value": 3_000_000}] async with session_factory() as session: await credit_confirmed_utxos(session, user_id, entries) async with session_factory() as session: - spent = await detect_external_spends(session, user_id, []) - assert spent == 1 + candidates = await find_utxos_missing_from(session, user_id, entries) + assert candidates == [] + user = await session.get(User, user_id) + assert user.cached_balance_sats == 3_000_000 + + +async def test_find_utxos_missing_from_skips_a_totally_empty_response(session_factory, user_id): + """B-29: an entirely empty listunspent for a funded address reads as an + incomplete/broken response, not proof of a full external sweep — it would + otherwise flag every UTXO of this user as missing from one bad reply.""" + async with session_factory() as session: + await credit_confirmed_utxos( + session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + ) + + async with session_factory() as session: + candidates = await find_utxos_missing_from(session, user_id, []) + assert candidates == [] + + +async def test_mark_utxos_spent_externally_marks_and_corrects_balance(session_factory, user_id): + async with session_factory() as session: + await credit_confirmed_utxos( + session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + ) + + async with session_factory() as session: + utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one() + marked = await mark_utxos_spent_externally(session, user_id, [utxo.id]) + assert marked == 1 + user = await session.get(User, user_id) assert user.cached_balance_sats == 0 @@ -73,13 +130,57 @@ async def test_external_spend_marks_utxo_spent_and_corrects_balance(session_fact assert any(e.event_type == "utxo_spent_externally" for e in audit_events) -async def test_no_spend_detected_when_utxo_still_unspent(session_factory, user_id): +async def test_mark_utxos_spent_externally_skips_an_already_resolved_row(session_factory, user_id): + """Something else (a legitimate platform spend, or a prior refresh) may have + resolved the row between the caller reading the candidate list and finishing + corroboration — mark_utxos_spent_externally must not clobber that.""" + async with session_factory() as session: + await credit_confirmed_utxos( + session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + ) + utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one() + utxo_id = utxo.id + utxo.spent_txid = "some-real-platform-txid" + await session.commit() + + async with session_factory() as session: + marked = await mark_utxos_spent_externally(session, user_id, [utxo_id]) + assert marked == 0 + utxo = await session.get(UtxoEvent, utxo_id) + assert utxo.spent_txid == "some-real-platform-txid" # untouched + + +async def test_reinstate_reappeared_utxos_clears_the_mark_and_restores_balance(session_factory, user_id): + async with session_factory() as session: + await credit_confirmed_utxos( + session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + ) + utxo_id = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().id + + async with session_factory() as session: + await mark_utxos_spent_externally(session, user_id, [utxo_id]) + + # The outpoint reappears as unspent in a later refresh. + entries = [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + async with session_factory() as session: + reinstated = await reinstate_reappeared_utxos(session, user_id, entries) + assert reinstated == 1 + + user = await session.get(User, user_id) + assert user.cached_balance_sats == 20_000_000 + + utxo = await session.get(UtxoEvent, utxo_id) + assert utxo.spent_txid is None + + audit_events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] + assert "utxo_external_spend_reinstated" in audit_events + + +async def test_reinstate_reappeared_utxos_ignores_unmarked_rows(session_factory, user_id): entries = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 100, "value": 3_000_000}] async with session_factory() as session: await credit_confirmed_utxos(session, user_id, entries) async with session_factory() as session: - spent = await detect_external_spends(session, user_id, entries) - assert spent == 0 - user = await session.get(User, user_id) - assert user.cached_balance_sats == 3_000_000 + reinstated = await reinstate_reappeared_utxos(session, user_id, entries) + assert reinstated == 0 diff --git a/tests/unit/test_electrum_listener.py b/tests/unit/test_electrum_listener.py index 2e18730..9d4568a 100644 --- a/tests/unit/test_electrum_listener.py +++ b/tests/unit/test_electrum_listener.py @@ -11,9 +11,11 @@ import asyncio import struct import pytest +from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base +from app.db.models import User, UtxoEvent from app.electrum.client import ElectrumEndpoint from app.electrum.listener import ElectrumListener from app.rounds.draw import HeaderValidationError, header_hex_to_block_hash, header_meets_its_own_target @@ -304,3 +306,148 @@ async def test_corroborate_header_false_when_nobody_responds(session_factory): listener = ElectrumListener(factory, session_factory, _ENDPOINTS) assert await listener.corroborate_header(100, "deadbeef") is False + + +# --- B-29: a UTXO absent from our own connection's listunspent must be +# corroborated by other configured servers before it's treated as genuinely spent +# outside the platform. ------------------------------------------------------------ + + +async def _listunspent_client_factory(responses: dict[str, object]): + """Builds a client_factory whose fake clients answer listunspent per-endpoint: + a list of entries to report as unspent, `None` to simulate an unreachable + server (fails at listunspent), or an Exception instance to simulate a connect + failure.""" + + class _FakeClient: + def __init__(self, answer): + self._answer = answer + + async def connect(self): + if isinstance(self._answer, Exception): + raise self._answer + + async def listunspent(self, scripthash): + if self._answer is None: + raise ConnectionRefusedError("unreachable") + return self._answer + + async def close(self): + pass + + def factory(endpoint): + return _FakeClient(responses[endpoint.host]) + + return factory + + +async def test_corroborate_utxo_spent_true_with_no_other_servers_configured(session_factory): + single = [ElectrumEndpoint("only.example", 50002, True)] + listener = ElectrumListener(lambda endpoint: None, session_factory, single) + assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True + + +async def test_corroborate_utxo_spent_true_when_others_agree_its_gone(session_factory): + factory = await _listunspent_client_factory({"first.example": [], "second.example": [], "third.example": []}) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True + + +async def test_corroborate_utxo_spent_false_when_majority_still_see_it_unspent(session_factory): + still_there = [{"tx_hash": "dd" * 32, "tx_pos": 0}] + factory = await _listunspent_client_factory( + {"first.example": [], "second.example": still_there, "third.example": still_there} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False + + +async def test_corroborate_utxo_spent_false_when_nobody_responds(session_factory): + factory = await _listunspent_client_factory( + {"first.example": [], "second.example": None, "third.example": ConnectionRefusedError("down")} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False + + +class _ActiveClient: + """Stands in for `self.client`, the listener's one active connection — + _refresh_user only ever calls listunspent on it.""" + + def __init__(self, entries: list[dict]): + self._entries = entries + + async def listunspent(self, scripthash): + return self._entries + + +async def _seed_funded_user(session_factory, *, username: str, address: str) -> int: + from app.wallet.balance import recompute_balance + + async with session_factory() as session: + user = User(username=username, password_hash="x", derivation_index=0, address=address) + session.add(user) + await session.commit() + session.add( + UtxoEvent(user_id=user.id, txid="dd" * 32, vout=0, amount_sats=20_000_000, confirmed_height=100) + ) + await recompute_balance(session, user.id) + await session.commit() + return user.id + + +# An unrelated outpoint present alongside our own connection's listunspent reply — +# keeps `entries` non-empty so find_utxos_missing_from's "entirely empty response" +# guard doesn't swallow these tests; our own tracked UTXO is still genuinely +# absent from it. +_UNRELATED_ENTRY = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000}] + + +async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(session_factory): + user_id = await _seed_funded_user(session_factory, username="bob", address="plm1qtest") + + others_factory = await _listunspent_client_factory( + {"first.example": [], "second.example": [], "third.example": []} + ) + listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS) + listener.client = _ActiveClient(_UNRELATED_ENTRY) # our own connection no longer sees the UTXO either + + await listener._refresh_user(user_id, "scripthash") + + async with session_factory() as session: + utxo = ( + await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.txid == "dd" * 32)) + ).one() + assert utxo.spent_txid == "external-spend" + user = await session.get(User, user_id) + # The original 20_000_000 is spent; the unrelated entry the "active" + # connection also reported gets freshly credited alongside it. + assert user.cached_balance_sats == 1_000_000 + + +async def test_refresh_user_does_not_mark_when_corroboration_fails(session_factory): + """The single most important case: our own connection alone reporting the + UTXO missing must not be enough — before B-29 this zeroed the balance on one + bad reply.""" + user_id = await _seed_funded_user(session_factory, username="carol", address="plm1qtest2") + + still_there = [{"tx_hash": "dd" * 32, "tx_pos": 0}] + others_factory = await _listunspent_client_factory( + {"first.example": [], "second.example": still_there, "third.example": still_there} + ) + listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS) + listener.client = _ActiveClient(_UNRELATED_ENTRY) + + await listener._refresh_user(user_id, "scripthash") + + async with session_factory() as session: + utxo = ( + await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.txid == "dd" * 32)) + ).one() + assert utxo.spent_txid is None + user = await session.get(User, user_id) + # Untouched, plus the unrelated entry credited alongside it. + assert user.cached_balance_sats == 21_000_000 From 63df38d30b3767c660cb859cc88402fe9a6ad083 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 10:35:23 +0200 Subject: [PATCH 08/29] Add a periodic deposit reconciler, and stop losing subscribe tasks (B-30) Deposits were credited exclusively by scripthash-change notifications, with nothing re-verifying a user's balance against the chain if a subscription was ever silently lost. address_for_new_user's subscribe was fire-and-forget: the task wasn't retained, so it could be garbage-collected mid-flight, and any failure (including self.client turning None between the check and the task running) vanished into asyncio's default unretrieved-exception handler instead of being logged anywhere. On an otherwise healthy, long-lived connection there may be no reconnect for days to re-subscribe everyone, so a user in that state never saw their deposits. address_for_new_user now retains the task and logs its exception if it fails. New app/deposits/reconcile.py adds DepositReconciler, a periodic sweep (every 5 minutes, gated on the Electrum client being connected, same shape as tx/reconcile.py) that round-robins over every user and calls the listener's own refresh_user (renamed from _refresh_user since it's now called from outside the class) - so the notification-driven and periodic paths can never behave differently. Deliberately sweeps every user rather than only ones missing from the in-memory scripthash map, since that map can't tell "never subscribed" apart from "subscribed, but the server stopped delivering notifications for it". Wired into app/main.py's lifespan alongside the other three background reconcilers. Suite grows from 176 to 182 tests. BUGS.md moves B-30 to Previously fixed. --- BUGS.md | 34 ++-------- app/deposits/reconcile.py | 69 ++++++++++++++++++++ app/deposits/service.py | 2 +- app/electrum/listener.py | 36 ++++++++-- app/main.py | 6 ++ tests/unit/test_deposit_reconcile.py | 98 ++++++++++++++++++++++++++++ tests/unit/test_electrum_listener.py | 50 ++++++++++++-- 7 files changed, 255 insertions(+), 40 deletions(-) create mode 100644 app/deposits/reconcile.py create mode 100644 tests/unit/test_deposit_reconcile.py diff --git a/BUGS.md b/BUGS.md index 738bbb3..1ab436e 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,17 +1,16 @@ # 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-30 … B-49. B-25 through B-29 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 20 are High/Medium/Low. +7 medium, 8 low), listed below as B-31 … B-49. B-25 through B-30 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 19 are High/Medium/Low. 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 five fixes so far brought the suite from -139 to 176). +coverage — every fix lands with a regression test (the six fixes so far brought the suite from +139 to 182). 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) still has -no equivalent resume-after-restart or stall visibility (B-36), and there's still no periodic -deposit-side reconciler independent of scripthash notifications (B-30). +no equivalent resume-after-restart or stall visibility (B-36). For limitations that are accepted by design rather than bugs (single-shared-token admin auth, single-process assumptions, no user-facing history, etc.), see "Known gaps / TODO" in @@ -21,26 +20,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD ## High -### B-30 — No deposit-side reconciler: one missed subscription means deposits are never credited - -`electrum/listener.py:61-67` (`address_for_new_user`) fires `asyncio.create_task(...)` without -retaining the reference and without handling exceptions. If `self.client` becomes `None` -between the check and the task running, the `assert` at `:163` raises inside an orphan task -and the exception is swallowed. - -What makes this serious is what happens next: deposits are credited **exclusively** by -scripthash notifications. There is no periodic routine reconciling balances against the chain -(the reconciler only covers outgoing transactions). On a healthy keepalive'd connection there -are no reconnects, so a lost subscription is never recovered and that user **never sees their -deposits**, indefinitely. - -**Proposed fix.** Two parts. (a) Make the subscription reliable: retain the task, log its -exceptions, and retry with backoff instead of relying on a reconnect. (b) Add the missing -safety net — a periodic sweep (say every few minutes, similar in shape to -`PendingTransactionReconciler`) that re-runs `_refresh_user` for users whose scripthash is not -in `_scripthash_to_user`, or simply round-robins over all users so a missed notification is -always eventually caught. - ### B-31 — Reconnect costs O(users) sequential round-trips and stalls the draw In `_run_once` the order is: subscribe headers → `_subscribe_all_users()` → *then* start the @@ -285,9 +264,10 @@ already does. - **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 See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the -B-28/B-29 fixes). Suite grew from 139 to 176 tests over the five. +B-28/B-29/B-30 fixes). Suite grew from 139 to 182 tests over the six. 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, diff --git a/app/deposits/reconcile.py b/app/deposits/reconcile.py new file mode 100644 index 0000000..048d9d3 --- /dev/null +++ b/app/deposits/reconcile.py @@ -0,0 +1,69 @@ +"""Periodic safety net for deposit crediting and external-spend detection (B-30), +independent of scripthash-change notifications. + +Those notifications are the fast path, but nothing else re-verifies a user's +balance against the chain if one is ever silently lost: `address_for_new_user`'s +subscribe is best-effort (its own failure just logs, see electrum/listener.py), +and on an otherwise healthy, long-lived connection there may be no reconnect for +days — the only other event that re-subscribes everyone from scratch. Without +this, a single lost subscription meant that user's deposits were never credited, +indefinitely. + +This mirrors app/tx/reconcile.py's shape (a periodic sweep gated on the Electrum +client being connected) but reuses ElectrumListener.refresh_user directly rather +than re-implementing crediting/spend-detection, so the notification-driven and +periodic paths can never behave differently from each other. +""" + +import asyncio +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from app.db.models import User +from app.electrum.listener import ElectrumListener +from app.electrum.scripthash import address_to_scripthash + +logger = logging.getLogger(__name__) + +_SWEEP_INTERVAL_SECONDS = 300 + + +class DepositReconciler: + def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener): + self._session_factory = session_factory + self._listener = listener + + async def run(self) -> None: + while True: + await asyncio.sleep(_SWEEP_INTERVAL_SECONDS) + if self._listener.client is None: + continue + try: + await self._sweep_once() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("deposit reconciliation sweep failed") + + async def _sweep_once(self) -> None: + """Round-robins over every user's address rather than only ones missing + from the listener's in-memory `_scripthash_to_user` map: that map can't + tell "never subscribed" apart from "subscribed, but this server silently + stopped delivering notifications for it" — exactly the failure mode this + exists to catch. One user failing (a transient network hiccup) must not + stop the sweep from reaching the rest, mirroring poll_once's per-item + isolation in tx/confirmation.py. + """ + async with self._session_factory() as session: + users = (await session.scalars(select(User))).all() + + for user in users: + if self._listener.client is None: + return # connection dropped mid-sweep; the next reconnect's own _subscribe_all_users covers everyone + scripthash = address_to_scripthash(user.address) + try: + await self._listener.refresh_user(user.id, scripthash) + except Exception: + logger.exception("deposit reconciliation failed for user_id=%s", user.id) diff --git a/app/deposits/service.py b/app/deposits/service.py index 4b88aae..ef9a037 100644 --- a/app/deposits/service.py +++ b/app/deposits/service.py @@ -113,7 +113,7 @@ async def find_utxos_missing_from(session: AsyncSession, user_id: int, entries: Returning a row here is *not* proof it was actually spent — only that this one server's reply no longer lists it. A single broken, behind, or malicious server could otherwise zero a user's balance on one bad reply, which is why - the caller (electrum/listener.py:_refresh_user) must independently + the caller (electrum/listener.py:refresh_user) must independently corroborate each candidate against other configured servers before treating it as genuine, rather than this function marking anything itself. diff --git a/app/electrum/listener.py b/app/electrum/listener.py index f83daf0..55267cd 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -63,6 +63,11 @@ class ElectrumListener: self._endpoints = list(endpoints or []) self._endpoint_index = 0 self._scripthash_to_user: dict[str, int] = {} + # Retains address_for_new_user's fire-and-forget subscribe task so it + # can't be garbage-collected mid-flight, and so its exception (if any) is + # actually observed instead of only reaching asyncio's default "Task + # exception was never retrieved" handler (B-30). + self._background_tasks: set[asyncio.Task] = set() self.tip_height: int = 0 self.tip_header_hex: str | None = None self.client: ElectrumClient | None = None @@ -77,11 +82,32 @@ class ElectrumListener: def address_for_new_user(self, user_id: int, address: str) -> None: """Called right after a user registers so their deposit address starts - being watched immediately, without waiting for the next reconnect cycle.""" + being watched immediately, without waiting for the next reconnect cycle. + + Best-effort, not retried on its own: `self.client` can still become None + between the check below and the task actually running (the connection + drops in between), which used to raise an AssertionError inside an + untracked task and vanish silently (B-30). The exception is now logged + instead, and — since a failure here just means this one address stays + unsubscribed until the next reconnect's `_subscribe_all_users` or the + periodic `DepositReconciler` sweep (also B-30) catches it — that's an + acceptable, self-healing outcome rather than something worth its own + retry/backoff loop. + """ scripthash = address_to_scripthash(address) self._scripthash_to_user[scripthash] = user_id if self.client is not None: - asyncio.create_task(self._subscribe_and_refresh(scripthash, user_id)) + task = asyncio.create_task(self._subscribe_and_refresh(scripthash, user_id)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + task.add_done_callback(self._log_subscribe_task_failure) + + def _log_subscribe_task_failure(self, task: asyncio.Task) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.warning("could not subscribe a newly-registered user's address: %r", exc) async def run(self) -> None: backoff = 1 @@ -179,7 +205,7 @@ class ElectrumListener: async def _subscribe_and_refresh(self, scripthash: str, user_id: int) -> None: assert self.client is not None await self.client.subscribe_scripthash(scripthash) - await self._refresh_user(user_id, scripthash) + await self.refresh_user(user_id, scripthash) def _apply_header(self, header: dict) -> None: """Record a new chain tip, refusing to move backwards. @@ -329,9 +355,9 @@ class ElectrumListener: scripthash, _status = await queue.get() user_id = self._scripthash_to_user.get(scripthash) if user_id is not None: - await self._refresh_user(user_id, scripthash) + await self.refresh_user(user_id, scripthash) - async def _refresh_user(self, user_id: int, scripthash: str) -> None: + async def refresh_user(self, user_id: int, scripthash: str) -> None: """Three phases, so no DB session is held across a network call (B-18), same shape as _trigger_payout: read what's needed, corroborate any candidate external spends against other servers (B-29), then persist. diff --git a/app/main.py b/app/main.py index 03627e6..cc5a5b5 100644 --- a/app/main.py +++ b/app/main.py @@ -23,6 +23,7 @@ from app.auth.routes import router as auth_router from app.api.errors import ApiError from app.config import settings, validate_runtime_secrets from app.db.base import AsyncSessionLocal +from app.deposits.reconcile import DepositReconciler from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints from app.electrum.listener import ElectrumListener from app.rounds.scheduler import RoundScheduler @@ -61,6 +62,10 @@ async def lifespan(app: FastAPI): # Resolves in-flight transactions against the chain — the piece that lets the # system recover on its own from a broadcast that never confirmed (B-04/B-08). reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client) + # Periodic safety net for deposit crediting/external-spend detection, + # independent of scripthash-change notifications — catches a subscription + # silently lost on an otherwise healthy connection (B-30). + deposit_reconciler = DepositReconciler(AsyncSessionLocal, listener) tasks = [ asyncio.create_task(listener.run()), @@ -68,6 +73,7 @@ async def lifespan(app: FastAPI): asyncio.create_task(poller.run()), asyncio.create_task(bumper.run()), asyncio.create_task(reconciler.run()), + asyncio.create_task(deposit_reconciler.run()), ] try: yield diff --git a/tests/unit/test_deposit_reconcile.py b/tests/unit/test_deposit_reconcile.py new file mode 100644 index 0000000..824e2e8 --- /dev/null +++ b/tests/unit/test_deposit_reconcile.py @@ -0,0 +1,98 @@ +"""Regression tests for B-30: a periodic sweep must catch a deposit whose +scripthash notification was silently lost, independent of whatever the +notification-driven path (electrum/listener.py:refresh_user) is doing.""" + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.db.base import Base +from app.db.models import User +from app.deposits.reconcile import DepositReconciler + + +@pytest.fixture +async def session_factory(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield async_sessionmaker(engine, expire_on_commit=False) + await engine.dispose() + + +async def _seed_users(session_factory, addresses: list[str]) -> list[int]: + async with session_factory() as session: + ids = [] + for i, address in enumerate(addresses): + user = User(username=f"user{i}", password_hash="x", derivation_index=i, address=address) + session.add(user) + await session.flush() + ids.append(user.id) + await session.commit() + return ids + + +# Real, decodable PLM bech32 addresses (address_to_scripthash actually parses +# them) — arbitrary otherwise. +_ADDRESSES = [ + "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd", + "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n", + "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx", +] + + +class FakeListener: + def __init__(self, *, fail_for: set[int] | None = None, disconnect_after: int | None = None): + self.client = object() # truthy: "connected" + self.refreshed: list[int] = [] + self._fail_for = fail_for or set() + self._disconnect_after = disconnect_after + + async def refresh_user(self, user_id: int, scripthash: str) -> None: + self.refreshed.append(user_id) + if self._disconnect_after is not None and len(self.refreshed) >= self._disconnect_after: + self.client = None + if user_id in self._fail_for: + raise RuntimeError(f"listunspent failed for user {user_id}") + + +async def test_sweep_once_refreshes_every_user(session_factory): + user_ids = await _seed_users(session_factory, _ADDRESSES) + listener = FakeListener() + reconciler = DepositReconciler(session_factory, listener) + + await reconciler._sweep_once() + + assert listener.refreshed == user_ids + + +async def test_sweep_once_continues_past_a_failing_user(session_factory): + """One user's refresh failing (a transient network hiccup) must not stop the + sweep from reaching the rest — mirrors poll_once's per-item isolation.""" + user_ids = await _seed_users(session_factory, _ADDRESSES) + listener = FakeListener(fail_for={user_ids[1]}) + reconciler = DepositReconciler(session_factory, listener) + + await reconciler._sweep_once() + + assert listener.refreshed == user_ids + + +async def test_sweep_once_stops_when_the_connection_drops_mid_sweep(session_factory): + """No point continuing once the connection is gone — the next reconnect's own + _subscribe_all_users will cover everyone anyway.""" + user_ids = await _seed_users(session_factory, _ADDRESSES) + listener = FakeListener(disconnect_after=1) + reconciler = DepositReconciler(session_factory, listener) + + await reconciler._sweep_once() + + assert listener.refreshed == user_ids[:1] + + +async def test_sweep_once_does_nothing_with_no_users(session_factory): + listener = FakeListener() + reconciler = DepositReconciler(session_factory, listener) + + await reconciler._sweep_once() # must not raise + + assert listener.refreshed == [] diff --git a/tests/unit/test_electrum_listener.py b/tests/unit/test_electrum_listener.py index 9d4568a..12e9afc 100644 --- a/tests/unit/test_electrum_listener.py +++ b/tests/unit/test_electrum_listener.py @@ -1,6 +1,7 @@ """Listener-level behaviour: server rotation on failure (the fallback-servers feature), the chain-tip monotonicity guard (B-19), header validation and -multi-server corroboration (B-28). +multi-server corroboration (B-28), and the new-user subscribe task's retention +and error logging (B-30). The reconnect loop itself (B-01) is covered from the client side in test_electrum_client.py — what's asserted here is that the listener *acts* on a @@ -8,6 +9,7 @@ dead connection by moving to the next server instead of retrying the same one. """ import asyncio +import logging import struct import pytest @@ -157,6 +159,40 @@ async def test_listener_with_no_endpoints_gives_up_loudly(session_factory): assert listener.current_endpoint is None +# --- B-30: address_for_new_user's subscribe task must be retained (not fire-and- +# forget) and its failure must be observable, not silently swallowed. ------------- + + +async def test_address_for_new_user_does_nothing_without_a_connection(session_factory): + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + listener.address_for_new_user(1, "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd") # listener.client is None + assert listener._background_tasks == set() + + +async def test_address_for_new_user_retains_and_logs_a_failed_subscribe_task(session_factory, caplog): + """Before B-30, this task was fire-and-forget: an AssertionError (self.client + turning None mid-flight) or any other failure vanished into asyncio's default + unretrieved-exception handler instead of being logged anywhere the operator + could see, and nothing kept the task alive in the meantime.""" + + class FailingClient: + async def subscribe_scripthash(self, scripthash): + raise ConnectionResetError("dropped mid-subscribe") + + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + listener.client = FailingClient() + + listener.address_for_new_user(1, "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd") + assert len(listener._background_tasks) == 1 # retained while in flight + + with caplog.at_level(logging.WARNING): + await asyncio.gather(*list(listener._background_tasks), return_exceptions=True) + await asyncio.sleep(0) # let the done_callbacks (scheduled via call_soon) run + + assert listener._background_tasks == set() # discarded once done + assert "could not subscribe" in caplog.text + + def test_tip_never_moves_backwards(session_factory): """B-19: `self.tip_height = header["height"]` accepted a lower height, and _wait_for_next_block waits for tip_height > tip_at_close — so a regression @@ -375,7 +411,7 @@ async def test_corroborate_utxo_spent_false_when_nobody_responds(session_factory class _ActiveClient: """Stands in for `self.client`, the listener's one active connection — - _refresh_user only ever calls listunspent on it.""" + refresh_user only ever calls listunspent on it.""" def __init__(self, entries: list[dict]): self._entries = entries @@ -406,8 +442,8 @@ async def _seed_funded_user(session_factory, *, username: str, address: str) -> _UNRELATED_ENTRY = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000}] -async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(session_factory): - user_id = await _seed_funded_user(session_factory, username="bob", address="plm1qtest") +async def testrefresh_user_marks_a_utxo_spent_once_others_corroborate_it(session_factory): + user_id = await _seed_funded_user(session_factory, username="bob", address="plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd") others_factory = await _listunspent_client_factory( {"first.example": [], "second.example": [], "third.example": []} @@ -415,7 +451,7 @@ async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(sessio listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS) listener.client = _ActiveClient(_UNRELATED_ENTRY) # our own connection no longer sees the UTXO either - await listener._refresh_user(user_id, "scripthash") + await listener.refresh_user(user_id, "scripthash") async with session_factory() as session: utxo = ( @@ -428,7 +464,7 @@ async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(sessio assert user.cached_balance_sats == 1_000_000 -async def test_refresh_user_does_not_mark_when_corroboration_fails(session_factory): +async def testrefresh_user_does_not_mark_when_corroboration_fails(session_factory): """The single most important case: our own connection alone reporting the UTXO missing must not be enough — before B-29 this zeroed the balance on one bad reply.""" @@ -441,7 +477,7 @@ async def test_refresh_user_does_not_mark_when_corroboration_fails(session_facto listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS) listener.client = _ActiveClient(_UNRELATED_ENTRY) - await listener._refresh_user(user_id, "scripthash") + await listener.refresh_user(user_id, "scripthash") async with session_factory() as session: utxo = ( From 12df04178ec44dee7352f7ddbdd9bdd1dde3c1ed Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 10:44:23 +0200 Subject: [PATCH 09/29] Resubscribe concurrently and in the background on reconnect (B-31) _run_once awaited _subscribe_all_users() inline, before starting the header/scripthash consumer tasks, and that method subscribed one user at a time. At thousands of users that's thousands of sequential round-trips during which nothing else ran: tip_height was frozen and an in-flight draw's _wait_for_next_block made zero progress for the entire resubscribe - a reconnect (which the listener already treats as routine, not exceptional) could stall the lottery for minutes. _subscribe_all_users now fans out with bounded concurrency (asyncio.Semaphore, 20 at a time) instead of a serial loop, and one user's failure no longer stops the rest. _run_once now starts it as its own background task, created after the consumer tasks rather than awaited before them, so tip updates and already-subscribed users' notifications keep flowing throughout - its own completion is deliberately not raced against the session-ending tasks (unlike them, it's expected to finish normally), and its failure is logged the same way address_for_new_user's background task is (B-30). Left out: decoupling the listunspent refresh from the subscribe call itself (the third part of the proposed fix) - the periodic DepositReconciler (B-30) already provides a backstop for a slow or delayed initial refresh, so the added complexity wasn't worth it here. Suite grows from 182 to 185 tests, including an end-to-end test against _run_once proving a new tip is processed while a slow resubscribe is still in flight. BUGS.md moves B-31 to Previously fixed. --- BUGS.md | 29 ++--- app/electrum/listener.py | 49 ++++++++- tests/unit/test_electrum_listener.py | 151 ++++++++++++++++++++++++++- 3 files changed, 198 insertions(+), 31 deletions(-) diff --git a/BUGS.md b/BUGS.md index 1ab436e..33e289a 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-31 … B-49. B-25 through B-30 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 19 are High/Medium/Low. +7 medium, 8 low), listed below as B-32 … B-49. B-25 through B-31 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 18 are High/Medium/Low. 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 six fixes so far brought the suite from -139 to 182). +coverage — every fix lands with a regression test (the seven fixes so far brought the suite +from 139 to 185). 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. @@ -20,24 +20,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD ## High -### B-31 — Reconnect costs O(users) sequential round-trips and stalls the draw - -In `_run_once` the order is: subscribe headers → `_subscribe_all_users()` → *then* start the -consumer tasks (`electrum/listener.py:118-134`). `_subscribe_all_users` iterates users -**sequentially**, and each iteration is a subscribe plus a `listunspent` plus a DB write -(`:154-165`). - -At 5.000 users that is 10.000 serialized round-trips (15s timeout each). Throughout, -`_consume_headers` is not running, so `tip_height` is frozen and `_wait_for_next_block` makes -no progress: **a reconnect stalls an in-flight draw** for the entire resubscribe. And since -registration has no rate limiting, the user count is attacker-controlled. - -**Proposed fix.** Start the consumer tasks (headers especially) *before* resubscribing, so tip -updates keep flowing during the sweep. Batch the resubscribe with bounded concurrency -(e.g. `asyncio.Semaphore(20)` over `asyncio.gather`) instead of a serial loop, and decouple -the `listunspent` refresh from the subscribe so the initial refresh can proceed in the -background. - ### B-32 — `bump_fee` can loop forever on rebroadcasts the node always rejects `tx/broadcast.py:87-88` forces `fee_delta = 1` when `fee_delta <= 0`. A **one-satoshi** total @@ -265,9 +247,10 @@ already does. - **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 See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the -B-28/B-29/B-30 fixes). Suite grew from 139 to 182 tests over the six. +B-28/B-29/B-30/B-31 fixes). Suite grew from 139 to 185 tests over the seven. 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, diff --git a/app/electrum/listener.py b/app/electrum/listener.py index 55267cd..dab78bc 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -35,6 +35,11 @@ _PING_INTERVAL_SECONDS = 60 # servers at once — a single slow fallback shouldn't hold up the others. _CORROBORATION_TIMEOUT_SECONDS = 10 +# How many users to resubscribe at once on reconnect (B-31), instead of one at a +# time. Bounded rather than unlimited so a huge user base doesn't open thousands +# of simultaneous in-flight requests against the one active connection. +_RESUBSCRIBE_CONCURRENCY = 20 + class ElectrumListener: """Long-lived background task: keeps one Electrum connection open, subscribes @@ -161,8 +166,6 @@ class ElectrumListener: header = await client.subscribe_headers() self._apply_header(header) - await self._subscribe_all_users() - headers_queue = client.notifications("blockchain.headers.subscribe") scripthash_queue = client.notifications("blockchain.scripthash.subscribe") # The consumers below block on their queues forever by design, so they @@ -175,9 +178,25 @@ class ElectrumListener: asyncio.create_task(self._keepalive(client)), asyncio.create_task(client.wait_closed()), ] + # B-31: resubscribing every user is O(users) sequential round-trips — + # at thousands of users that's minutes during which, previously, + # nothing above had started yet: tip_height was frozen and an + # in-flight draw's _wait_for_next_block made zero progress for the + # entire resubscribe. Running it as its own background task instead + # of awaiting it inline here means tip updates (and notifications for + # whichever users are already subscribed) keep flowing throughout. + # It's deliberately not one of the raced `tasks` above: unlike those, + # it's expected to finish normally, and its own completion must not + # look like the session ending. Any failure partway through is + # logged the same way address_for_new_user's background task is + # (B-30), and it's cancelled below along with everything else once + # the session actually does end. + subscribe_task = asyncio.create_task(self._subscribe_all_users()) + subscribe_task.add_done_callback(self._log_subscribe_all_users_failure) try: done, still_running = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) finally: + subscribe_task.cancel() for task in tasks: task.cancel() for task in done: @@ -189,18 +208,40 @@ class ElectrumListener: await client.close() return True + def _log_subscribe_all_users_failure(self, task: asyncio.Task) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.warning("resubscribing all users failed partway through: %r", exc) + async def _keepalive(self, client: ElectrumClient) -> None: while True: await asyncio.sleep(_PING_INTERVAL_SECONDS) await client.ping() # raises (and so ends the session) on timeout or a dead socket async def _subscribe_all_users(self) -> None: + """B-31: subscribes with bounded concurrency (_RESUBSCRIBE_CONCURRENCY at + a time) instead of one user at a time — at thousands of users a serial + loop meant thousands of sequential round-trips. One user's failure (a + single slow or briefly-erroring request) must not stop the rest from + being subscribed, mirroring the same per-item isolation used elsewhere + (e.g. tx/confirmation.py's poll_once, deposits/reconcile.py's sweep).""" async with self._session_factory() as session: users = (await session.scalars(select(User))).all() - for user in users: + + semaphore = asyncio.Semaphore(_RESUBSCRIBE_CONCURRENCY) + + async def _subscribe_one(user: User) -> None: scripthash = address_to_scripthash(user.address) self._scripthash_to_user[scripthash] = user.id - await self._subscribe_and_refresh(scripthash, user.id) + async with semaphore: + try: + await self._subscribe_and_refresh(scripthash, user.id) + except Exception: + logger.exception("failed to resubscribe user_id=%s", user.id) + + await asyncio.gather(*(_subscribe_one(user) for user in users)) async def _subscribe_and_refresh(self, scripthash: str, user_id: int) -> None: assert self.client is not None diff --git a/tests/unit/test_electrum_listener.py b/tests/unit/test_electrum_listener.py index 12e9afc..2270254 100644 --- a/tests/unit/test_electrum_listener.py +++ b/tests/unit/test_electrum_listener.py @@ -1,7 +1,8 @@ """Listener-level behaviour: server rotation on failure (the fallback-servers feature), the chain-tip monotonicity guard (B-19), header validation and -multi-server corroboration (B-28), and the new-user subscribe task's retention -and error logging (B-30). +multi-server corroboration (B-28), the new-user subscribe task's retention and +error logging (B-30), and bounded-concurrency, non-blocking resubscribe on +reconnect (B-31). The reconnect loop itself (B-01) is covered from the client side in test_electrum_client.py — what's asserted here is that the listener *acts* on a @@ -442,7 +443,7 @@ async def _seed_funded_user(session_factory, *, username: str, address: str) -> _UNRELATED_ENTRY = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000}] -async def testrefresh_user_marks_a_utxo_spent_once_others_corroborate_it(session_factory): +async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(session_factory): user_id = await _seed_funded_user(session_factory, username="bob", address="plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd") others_factory = await _listunspent_client_factory( @@ -464,7 +465,7 @@ async def testrefresh_user_marks_a_utxo_spent_once_others_corroborate_it(session assert user.cached_balance_sats == 1_000_000 -async def testrefresh_user_does_not_mark_when_corroboration_fails(session_factory): +async def test_refresh_user_does_not_mark_when_corroboration_fails(session_factory): """The single most important case: our own connection alone reporting the UTXO missing must not be enough — before B-29 this zeroed the balance on one bad reply.""" @@ -487,3 +488,145 @@ async def testrefresh_user_does_not_mark_when_corroboration_fails(session_factor user = await session.get(User, user_id) # Untouched, plus the unrelated entry credited alongside it. assert user.cached_balance_sats == 21_000_000 + + +# --- B-31: resubscribing on reconnect must be bounded-concurrency and must not +# block tip updates (and so an in-flight draw) for its entire duration. ----------- + + +def _fake_address(i: int) -> str: + """A real, decodable PLM bech32 P2WPKH address (address_to_scripthash + actually parses it) — distinct per index, since User.address is unique.""" + from embit import script + + from app.wallet.plm_network import PLM_MAINNET + + payload = (i + 1).to_bytes(20, "big") + return script.Script(b"\x00\x14" + payload).address(network=PLM_MAINNET) + + +async def _seed_users(session_factory, count: int) -> None: + async with session_factory() as session: + for i in range(count): + session.add( + User(username=f"user{i}", password_hash="x", derivation_index=i, address=_fake_address(i)) + ) + await session.commit() + + +async def test_subscribe_all_users_bounds_concurrency(session_factory): + """B-31: at thousands of users, subscribing one at a time meant thousands of + sequential round-trips. Concurrency must be bounded (not unlimited either — + a huge user base shouldn't open thousands of simultaneous requests).""" + user_count = 45 + await _seed_users(session_factory, user_count) + + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + in_flight = 0 + max_in_flight = 0 + calls = [] + + async def fake_subscribe_and_refresh(scripthash, user_id): + nonlocal in_flight, max_in_flight + in_flight += 1 + max_in_flight = max(max_in_flight, in_flight) + calls.append(user_id) + await asyncio.sleep(0) # yield, so genuinely-concurrent calls interleave + in_flight -= 1 + + listener._subscribe_and_refresh = fake_subscribe_and_refresh + + await listener._subscribe_all_users() + + assert len(calls) == user_count + assert 1 < max_in_flight <= 20 # bounded, and actually concurrent (not serial) + + +async def test_subscribe_all_users_continues_past_a_failing_user(session_factory): + await _seed_users(session_factory, 5) + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + succeeded = [] + + async def flaky_subscribe_and_refresh(scripthash, user_id): + if user_id == 3: + raise ConnectionResetError("dropped mid-subscribe") + succeeded.append(user_id) + + listener._subscribe_and_refresh = flaky_subscribe_and_refresh + + await listener._subscribe_all_users() # must not raise + + assert succeeded == [1, 2, 4, 5] + + +class _FakeConnectClient: + """A minimally-real ElectrumClient double: enough of connect/subscribe/notify/ + ping/wait_closed/close to drive ElectrumListener._run_once end-to-end.""" + + def __init__(self, header: dict): + self._header = header + self._queues: dict[str, asyncio.Queue] = {} + self._closed = asyncio.Event() + + async def connect(self): + pass + + async def subscribe_headers(self): + return self._header + + def notifications(self, method: str) -> asyncio.Queue: + return self._queues.setdefault(method, asyncio.Queue()) + + async def ping(self): + pass + + async def wait_closed(self): + await self._closed.wait() + + async def close(self): + self._closed.set() + + +async def _wait_until(predicate, *, timeout: float = 2.0, interval: float = 0.01) -> None: + async def _poll(): + while not predicate(): + await asyncio.sleep(interval) + + await asyncio.wait_for(_poll(), timeout=timeout) + + +async def test_run_once_keeps_consuming_headers_while_resubscribing(session_factory): + """The core B-31 fix: before this, _subscribe_all_users ran to completion + *before* the header-consuming task even started, so a reconnect with many + users froze tip_height — and so _wait_for_next_block's draw wait — for the + entire resubscribe. It must now keep advancing while resubscribing is still + in flight.""" + await _seed_users(session_factory, 3) + + header_hex = _mine_header("00" * 32) + client = _FakeConnectClient({"height": 100, "hex": header_hex}) + listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS) + + subscribe_started = asyncio.Event() + + async def blocked_subscribe_and_refresh(scripthash, user_id): + subscribe_started.set() + await asyncio.sleep(3600) # simulates a slow sweep; cancelled on cleanup + + listener._subscribe_and_refresh = blocked_subscribe_and_refresh + + run_once_task = asyncio.create_task(listener._run_once(_ENDPOINTS[0])) + try: + await asyncio.wait_for(subscribe_started.wait(), timeout=2) + + # Resubscribing is still stuck mid-flight — but a new tip must still be + # processed, proving the header consumer isn't blocked behind it. + headers_queue = client.notifications("blockchain.headers.subscribe") + next_header_hex = _mine_header(header_hex_to_block_hash(header_hex)) + await headers_queue.put([{"height": 101, "hex": next_header_hex}]) + await _wait_until(lambda: listener.tip_height == 101) + + assert listener.tip_header_hex == next_header_hex + finally: + await client.close() + await run_once_task From fe5639a037456b6a661fde7d0af13223a4cbd832 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 11:05:53 +0200 Subject: [PATCH 10/29] Update CLAUDE.md --- CLAUDE.md | 342 +++++++++++++++++++++++++++--------------------------- 1 file changed, 174 insertions(+), 168 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 765cc66..3e821ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,233 +8,239 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin ## Project status -All 10 build-order stages from `/home/davide/.claude/plans/scalable-mixing-sloth.md` are code-complete and unit-tested (137 tests green): project skeleton, DB schema + Alembic migrations, auth, HD wallet derivation, Electrum client, deposit detection, bet flow, round/draw engine, payout, withdrawal, RBF fee-bump, admin config + audit log. Beyond the original 10 stages: a Docker + Caddy deployment (see below), a full admin dashboard (`/admin`), a static test UI for the user-facing flow (`/`), a pending-inclusive balance display (see "Balance display" below), and a Server-Sent Events push channel layered on top of the original polling (see "Real-time updates" below). +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. -Real-money verification on mainnet, done so far: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast, confirmed, change credited back), and a full round cycle — close → draw (real block hash) → payout (70/30 split, exact sat math verified against the broadcast tx) → confirmation → round closed → next round auto-opened. Withdrawal and the RBF bump path are unit-tested but have never been exercised against a live broadcast. See "Known gaps" below before treating this as production-ready. +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. -A full-codebase audit on 2026-07-26 found 24 bugs — five of them critical, including a -dropped Electrum connection that hung the whole server with no reconnect, an RBF fee bump -that wedged a round forever, and no way for the system to recover a broadcast that never -confirmed (funds frozen). All 24 are fixed; [BUGS.md](BUGS.md) is the record, with each -one's root cause, what was actually done, and where its regression test lives. Read it -before assuming any behaviour here predates those fixes. +**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-32 … B-49), of which **18 are still open** — no Critical, but High covers an RBF bump that can loop forever on a replacement the node always rejects (B-32), no brute-force protection on login (B-33), and password change/reset not invalidating existing JWTs (B-34). 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, always read the "Architecture" section below in full, plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) for the whole 5-phase flow, and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) for the round/draw lifecycle in detail. Every node in these diagrams corresponds to a behavior that must be implemented exactly as described, including the labels on the edges (conditions, retries, loops). Regenerate their companion PDFs with `flowchart/render-pdf.sh .mmd` after editing either one. +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 .mmd` after editing either. -Human-facing guides live in [docs/](docs/) (Italian, per explicit request — an exception to this file's English-only rule below): [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md). +Human-facing guides are in [docs/](docs/), in Italian by explicit request (an exception to the English-only rule): [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md). **[README.md](README.md)'s Quick start is stale** (bare `uvicorn --reload`, `docker compose run … generate_master_key.py` — neither is supported; B-44); this file and `docs/setup.md` are authoritative. ## Commands -The server itself — in development and in production alike — always runs via Docker (see "Deployment" below); there is no supported way to run `uvicorn` directly against this codebase. The venv (`.venv/`) is only for local tooling: running tests, authoring Alembic migrations, and running the one-time scripts that generate the secrets/key material that end up referenced from `.env`. +The server always runs via Docker, in dev and prod alike — there is no supported way to run `uvicorn` directly. The venv (`.venv/`) is only for local tooling: tests, Alembic migrations, and the one-time key/secret scripts. ```bash -source .venv/bin/activate # venv already created at .venv/ -pip install -e ".[dev]" # install/update deps +source .venv/bin/activate # venv already created at .venv/ +pip install -e ".[dev]" -alembic revision --autogenerate -m "message" # generate a new migration after editing app/db/models.py (applied automatically by the container's startup command — see Deployment — never run `alembic upgrade head` manually) +alembic revision --autogenerate -m "message" # after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand -PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the server's master xprv (requires XPRV_ENCRYPTION_KEY in .env; see Deployment for where MASTER_KEY_PATH should point) -PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print the existing master xprv (asks for confirmation first) -PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: bring your own externally-generated xprv instead of generating one (getpass prompt, --overwrite to replace) +PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the master xprv (needs XPRV_ENCRYPTION_KEY in .env) +PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print it (asks for confirmation) +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 # run all tests -python -m pytest tests/unit/test_hd.py # run one test file -python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # run a single test +python -m pytest # all 185 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 ``` -`.env` (gitignored) holds real secrets for local dev; `.env.example` documents the required keys and how to generate them. +`asyncio_mode = "auto"` (`pyproject.toml`), so async tests need no `@pytest.mark.asyncio`. + +`.env` (gitignored) holds the real secrets; `.env.example` documents the required keys and how to generate each. Note it does **not** list `DATABASE_URL` or `MASTER_KEY_PATH`, which the real `.env` does set. ## Deployment (Docker + Caddy) -The app is always run via Docker — dev and prod alike use the same `docker-compose.yml`, just with a different `SITE_ADDRESS` (see below); there's no separate dev-mode compose file or bare-`uvicorn` workflow. `docker-compose.yml` runs two containers: `app` (this codebase, built by `Dockerfile`, runs `alembic upgrade head` then `uvicorn`) and `caddy` (reverse proxy + automatic TLS). `.env` holds the app secrets; `docker-compose.yml` overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted master key, logs — all gitignored, persist across container restarts). Set `MASTER_KEY_PATH` in `.env` itself to the host-side equivalent, `./data/keys/master.xprv.enc`, so the venv-run key-generation scripts above (see "Commands") write to the exact same file the container reads — one source of truth for the key, whichever way it was generated. +Same `docker-compose.yml` for dev and prod — only `SITE_ADDRESS` differs. Two containers: `app` (this codebase; its startup command refuses to start if the master key file is missing, then runs `alembic upgrade head` and `uvicorn`) and `caddy` (reverse proxy + automatic TLS). The compose file overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted key, logs — gitignored, survive restarts). Set `MASTER_KEY_PATH` in `.env` to the host-side `./data/keys/master.xprv.enc` so the venv scripts write the exact file the container reads — one source of truth for the key. ```bash -mkdir -p data/db data/keys data/logs # one-time: host dirs bind-mounted into the app container - -# one-time: generate the master key via the venv script above (scripts/generate_master_key.py), -# not via `docker compose run` — MASTER_KEY_PATH in .env already points at ./data/keys/ - -docker compose up -d --build # build + start app and caddy — same command for dev and prod -docker compose logs -f app # tail app logs (also written to ./data/logs/app.log) -docker compose down # stop +mkdir -p data/db data/keys data/logs # one-time +docker compose up -d --build # dev and prod alike +docker compose logs -f app # also written to ./data/logs/app.log +docker compose down ``` -Caddy's site address comes from `SITE_ADDRESS` (env var on the host, read by `docker-compose.yml`): -- **Dev, no domain**: leave it unset (defaults to `localhost`). Caddy detects it isn't a public hostname and issues a self-signed cert from its own internal CA — browsers will warn on first visit, expected for local testing (`curl -k` or click through). -- **Production, with a domain**: `SITE_ADDRESS=lottery.example.com docker compose up -d` (DNS must already point at the server, ports 80+443 reachable). Caddy automatically requests and renews a real Let's Encrypt certificate — no other config needed. +`SITE_ADDRESS` unset → `localhost`, Caddy issues a self-signed cert from its internal CA (browser warning on first visit is expected; `curl -k`). `SITE_ADDRESS=lottery.example.com docker compose up -d` → real Let's Encrypt cert, automatically renewed (needs DNS pointing here and ports 80+443 reachable). -Known risk: `docker-compose.yml` sets `restart: unless-stopped` on `app`, so a crash mid-round auto-restarts the container — which hits the scheduler-resume gap below (a round stuck in `closing`/`drawing`/`paying_out` at restart stays stuck). Don't treat this as unattended-safe until that gap is closed. +The `Caddyfile` sets **no** security headers — no CSP, HSTS or `X-Frame-Options` (B-43). `restart: unless-stopped` on `app` means a mid-round crash auto-restarts: `closing` and `paying_out` resume on their own, `drawing` does not (see Known gaps). -## Tech stack (MVP) +## Tech stack -- **Backend language**: Python. -- **PLM node access**: Electrum protocol only (no full node/P2P). Bootstrap server for development: `santantonio.sytes.net:50002` (SSL). -- **Auth**: Argon2 password hashing + JWT sessions. -- **Secrets**: master xprv encrypted at rest with a symmetric scheme (AES-GCM/Fernet); the encryption key itself lives in an env var, never in the DB or in git. -- **Operational config**: every business/round parameter (fee address, bet amount, round duration, round cooldown, draw animation duration, minimum amount, network fee rate, RBF timeout) lives in the `round_config` DB table (single row, `app/rounds/config.py`) and is only editable live via the admin dashboard (`/admin`) or its API — no env var involved at all, no redeploy or restart needed. Defaults for a brand-new instance are hardcoded column defaults on the `RoundConfig` model (`app/db/models.py`), not `app/config.py`. Secrets and infra wiring (master key, JWT secret, Electrum host, admin token, database URL) stay env-var-driven in `.env` since those genuinely need a restart. -- **Round cooldown**: `round_cooldown_seconds` — gap after a round closes before the next one opens, so players have time to see the outcome (default 30s). Not in the original flowchart; added afterwards as an explicit design decision. -- **Maintenance pause**: `RoundConfig.paused` (default `false`), toggled via `POST /admin/pause` / `POST /admin/resume` (a dedicated "Manutenzione" card in `/admin`'s Parametri section, not a plain config field — it's a deliberate operator action, audit-logged as `lottery_paused`/`lottery_resumed`). When set, `rounds/service.py:open_new_round_if_needed` stops opening a *next* round once the current one closes — it never interrupts a round already in progress (that one still closes, draws, and pays out its winner normally). `GET /rounds/current` exposes it as `lottery_paused` so the user-facing page (`/`) shows a maintenance banner. +- Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite, `embit` for keys/PSBT/tx parsing. +- **PLM access via the Electrum protocol only** (no full node/P2P). Dev bootstrap server: `santantonio.sytes.net:50002` (SSL). +- Auth: Argon2 hashing + JWT (HS256, 24h, **no revocation** — B-34). +- Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or `XPRV_ENCRYPTION_KEY` is empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one. +- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway. -## PLM network parameters +## PLM network parameters (mainnet) -Source of truth: `PalladiumWallet` repo, [ChainProfiles.cs](../PalladiumWallet/src/Core/Chain/ChainProfiles.cs) and [PalladiumNetworks.cs](../PalladiumWallet/src/Core/Chain/PalladiumNetworks.cs) — always re-check that repo if a value is needed that isn't listed here, rather than guessing. +Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumWallet/src/Core/Chain/ChainProfiles.cs), [PalladiumNetworks.cs](../PalladiumWallet/src/Core/Chain/PalladiumNetworks.cs). Re-check there for anything not listed here rather than guessing; its `ChainProfiles.Mainnet.BootstrapServers` is also where `.env.example`'s suggested `ELECTRUM_FALLBACK_SERVERS` come from. -Mainnet: -- BIP44/84 coin type: `746` (i.e. HD path `m/84'/746'/0'/0/index`) -- Bech32 HRP: `plm` -- P2PKH address version byte: `55` (addresses start with `P`) -- P2SH address version byte: `5` -- WIF prefix: `0x80` -- Block time: 120s -- BIP32 extended key headers (Legacy/native-segwit `zprv`/`zpub` etc.): see `ExtKeyHeaders` in `ChainProfiles.cs` +| | | +|---|---| +| BIP44/84 coin type | `746` → `m/84'/746'/0'/0/index` | +| Bech32 HRP | `plm` | +| P2PKH / P2SH version byte | `55` (addresses start with `P`) / `5` | +| WIF prefix | `0x80` | +| Block time | 120s | +| BIP32 ext-key headers | see `ExtKeyHeaders` in `ChainProfiles.cs` | -## Electrum connection (rotation, keepalive, timeouts) +## Business parameters -One connection serves everything — deposit credits, broadcasts, confirmations, the chain -tip the draw waits on — which makes it the platform's biggest single point of failure. -Three things keep it honest: +| Parameter | Value | Where | +|---|---|---| +| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable | +| Prize split | **70% winner / 30% fees**, rounding remainder to fees | **hardcoded** in `rounds/scheduler.py` — a code change, not an admin edit | +| Round duration / cooldown | 600s / 30s | `RoundConfig` | +| Draw animation | 20s (cosmetic frontend minimum only) | `RoundConfig` | +| Fee rate / RBF timeout | 1 sat/vB / 900s | `RoundConfig` | +| Min withdrawal | = current `bet_amount_sats` (no separate field) | `withdrawals/service.py` | +| Min deposit | none | — | +| Min password length | 8 | `auth/security.py:MIN_PASSWORD_LENGTH` | +| Confirmations, every tx kind | **1** | hardcoded in `tx/confirmation.py` | -- **Server rotation.** `ELECTRUM_HOST`/`ELECTRUM_PORT` is the primary; - `ELECTRUM_FALLBACK_SERVERS` is a comma-separated list of `host:port[:notls]` extras - (parsed by `electrum/client.py:parse_endpoints`, which rejects malformed entries at - startup rather than during the outage when the fallback is needed). The listener tries - the next server after any failed or dropped session, and only sleeps on the backoff once - every server has had a turn — so one dead server costs a single attempt, not an outage. -- **Every request is bounded** (`_REQUEST_TIMEOUT_SECONDS`, 15s) and a timeout tears the - connection down. Unbounded waits used to hang a `POST /bets` *while holding the per-user - lock*, and could stop the confirmation poller permanently. -- **The drop is observable.** `client.wait_closed()` resolves when the read loop dies, and - `listener._run_once` races it against the notification consumers and a 60s `server.ping` - keepalive. Without this the listener sat on queues nobody would ever fill again and never - reconnected — while `listener.client` still looked alive to everything else. +`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). + +**Round cooldown** (`round_cooldown_seconds`, not in the original flowchart): gap after a round closes before the next opens, so players can see the outcome. + +**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner. + +## Code map + +| Package | Contents | +|---|---| +| `app/main.py` | entry point: lifespan starts the six background tasks, mounts the routers and `app/static/` | +| `app/api/routes/` | `admin`, `bets`, `withdrawals`, `rounds` (incl. SSE), `users`, `qr`; `app/api/errors.py` holds the error contract | +| `app/auth/` | routes (register/login), Argon2 + JWT (`security.py`), `get_current_user`/`get_optional_user` | +| `app/db/` | `models.py` (all tables + the active-round index), engine/session factories | +| `app/wallet/` | HD derivation + WIF export (`hd.py`), PLM network constants, address/scripthash, balance math, `psbt_builder.py` (build/sign bet, withdrawal, payout; `select_utxos`) | +| `app/electrum/` | `client.py` (JSON-RPC, endpoint parsing, timeouts), `listener.py` (the one connection: rotation, keepalive, header validation, corroboration, deposit crediting) | +| `app/deposits/` | crediting / external-spend detection / reinstatement (`service.py`), periodic sweep (`reconcile.py`) | +| `app/bets/`, `app/withdrawals/` | build+broadcast services and their confirmation handlers | +| `app/rounds/` | `scheduler.py` (close/draw/payout), `service.py` (open/active-round rules), `draw.py` (header math + winner pick), `config.py`, `events.py` (SSE pub/sub) | +| `app/tx/` | `broadcast.py` (RBF bumper), `confirmation.py` (poller + handler registry), `reconcile.py`, `locks.py` (per-user locks) | +| `app/static/` | the two SPAs (`index.html`/`app.js`/`style.css`, `admin.html`/`admin.js`/`admin.css`) + `i18n.js` | + +## Background tasks + +`app/main.py`'s lifespan starts six long-lived asyncio tasks and cancels them on shutdown. Their cadences determine how fast anything self-heals. + +| Task | File | Cadence | Role | +| --- | --- | --- | --- | +| `ElectrumListener` | `electrum/listener.py` | reconnect loop, 60s keepalive | the single connection; subscribes headers + every user's scripthash, credits deposits | +| `RoundScheduler` | `rounds/scheduler.py` | 5s | opens/closes rounds, draws, triggers and retries payouts | +| `ConfirmationPoller` | `tx/confirmation.py` | 10s | `pending` → `confirmed` via per-kind handlers registered by `app/{bets,rounds,withdrawals}/confirmation.py` — imported for that side effect in `main.py`, **don't "clean up" those imports** | +| `RbfBumper` | `tx/broadcast.py` | 30s | fee-bumps anything past `rbf_timeout_seconds` | +| `PendingTransactionReconciler` | `tx/reconcile.py` | at startup, then 120s | resolves `building`/`pending` rows against the chain | +| `DepositReconciler` | `deposits/reconcile.py` | 300s (sleeps first) | re-`refresh_user`s every address, catching a silently-lost subscription (B-30) | + +Chain access goes through `listener.client`, passed as `lambda: listener.client` so a reconnect swaps the client under its consumers; a task finding it `None` skips that cycle instead of failing. `DepositReconciler` takes the whole listener instead, reusing `refresh_user` so the periodic and notification-driven paths can't diverge. + +## Electrum connection + +One connection serves everything — deposit credits, broadcasts, confirmations, the tip the draw waits on — so it's both the biggest single point of failure and, with a hostile server on the other end, the biggest integrity risk. Five defences: + +- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn. +- **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets` *while holding the per-user lock*, and could stall the confirmation poller permanently. +- **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive. +- **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. +- **A quorum corroborates the two money-moving decisions** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29). No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing. + +On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep. + +## Architecture — the 5 phases + +Diagrams: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifecycle.mmd](flowchart/round-lifecycle.mmd). + +**REG** — on signup the server derives a P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from the encrypted master xprv. Permanent, and doubles as deposit address, winnings address and withdrawal change address. + +**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. + +**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`. +- *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms). +- *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount. +- *Payout* is signed with the pool key; its **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern. +- *UI, two independent layers.* A generic phase box ("Pagamento al vincitore in corso…") shows to **every** viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by `status`. **Additively**, a personalized "Hai vinto!/Non hai vinto" box appears only where `user_played` is true (computed via `get_optional_user`, since the endpoint is reachable logged-out) — nobody else has anything to reveal. +- *Reveal timing.* Delayed by at least `draw_animation_seconds`, anchored to the server's `closes_at` so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for `winner_user_id`. Once revealed it's persisted in `localStorage.plm_persisted_result`, surviving the move to `closed` — at which point `get_active_round` stops returning the round and `winner_user_id` disappears from `GET /rounds/current`. `GET /users/me/last-round-result` is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: `refreshRound`/`checkLastRoundResult` in `app/static/app.js`. + +**WITHDRAW** — the only way out to an external address: PSBT user-address → external + change back to the user, fee deducted from the withdrawn amount, same RBF pattern. + +PLAY and WITHDRAW share a **per-user lock** (`tx/locks.py`): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set. + +**Three separate on-chain confirmations sit between the timer hitting zero and the payout landing** — a common point of confusion: +1. **Last bet's confirmation** — the round doesn't even flip to `"closing"` until every broadcast bet has 1 conf (`_tick`'s `pending_count` check). May already have happened before the deadline. +2. **The draw block** — `_wait_for_next_block` waits for `tip_height > tip_at_close`, recorded only once step 1 is done, so this is necessarily a later block. +3. **Payout confirmation** — built only after step 2's winner is known, so it needs yet another block; the generic `ConfirmationPoller` tracks it. + +At 120s blocks that's ~4–6 min worst case (last bet confirms right at the deadline), ~2–4 min best case — independent of `draw_animation_seconds`. ## Balance display -`place_bet`/`request_withdrawal` (`app/bets/service.py`, `app/withdrawals/service.py`) select whole UTXOs to cover the amount (`select_utxos`, largest-first) and mark every selected UTXO `spent_txid` immediately at broadcast time — well before the tx has any confirmations. `User.cached_balance_sats` (`recompute_balance`, `app/wallet/balance.py`) only sums confirmed, unspent UTXOs, so right after a bet/withdrawal it understates the user's real balance by the entire unconfirmed change amount, which is often far larger than the amount actually moving. +`place_bet`/`request_withdrawal` select whole UTXOs (`select_utxos`, largest-first) and mark each `spent_txid` at broadcast time, long before any confirmation. `cached_balance_sats` (`recompute_balance`) sums only confirmed, unspent UTXOs, so right after a bet it understates the real balance by the whole unconfirmed change — often far more than the amount actually moving. -`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without touching what's actually spendable: it decodes the raw tx of every in-flight (`status="pending"`) bet/withdrawal `PendingTransaction` belonging to the user and sums whichever outputs pay back to the user's own address, adding that to `cached_balance_sats`. `GET /users/me` returns both `balance_sats` (confirmed-only — still what withdrawal-max and internal spend logic use, since only confirmed UTXOs are actually spendable) and `pending_balance_sats` + `has_pending` (what the frontend displays, colored green when settled and amber while `has_pending` is true). +`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without changing what's spendable: it decodes the raw tx of every in-flight (`pending`) bet/withdrawal for the user and adds back the outputs paying to the user's own address. `GET /users/me` returns both — `balance_sats` (confirmed only; still what withdrawal-max and spend logic use, since only confirmed UTXOs are spendable) and `pending_balance_sats` + `has_pending` (what the UI shows: green when settled, amber while pending). The gap is user-visible and currently under-explained in errors (B-37). ## Real-time updates (SSE) -`GET /rounds/stream` (`app/api/routes/rounds.py`) is a Server-Sent Events channel layered *on top of* the original polling loops in `app/static/index.html`/`admin.html` — polling is the fallback, not replaced, so a blocked/dropped SSE connection just degrades to the pre-existing behavior. The channel carries no payload and needs no auth: it's purely a "something changed, go refetch" ping; personalization (e.g. `user_played` below) still lives entirely in the normal per-user REST endpoints. +`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. -`app/rounds/events.py`'s `RoundEventBroadcaster` (module-level singleton `broadcaster`) is a simple in-process pub/sub — one `asyncio.Queue` (maxsize 1, so redundant notifications coalesce) per connected SSE client. `broadcaster.publish()` is called from every point that changes something a dashboard would want to know about: a new round opening (`rounds/service.py`), every round status transition (`rounds/scheduler.py`: closing/drawing/paying_out/closed), a bet or withdrawal broadcast (`bets/service.py`, `withdrawals/service.py`), any pending tx confirming — bet/withdrawal/payout (`tx/confirmation.py`), a deposit credited (`deposits/service.py`), and a new block tip arriving (`electrum/listener.py` — the exact moment the "drawing" phase is waiting on). +`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). -Deliberate scope decisions, not oversights: -- **Single-process only, no cross-worker fan-out.** Fine for the current deployment (one uvicorn process, see `docker-compose.yml`). A multi-worker/multi-container deployment would need a shared channel (e.g. Redis pub/sub) instead — don't add that speculatively before it's actually needed. -- **Generic broadcast, not a per-user channel.** Every connected client refetches on every event, even ones irrelevant to them. Acceptable at the expected scale (~100 concurrent users); a targeted per-user channel would need auth on the SSE endpoint and server-side knowledge of who's affected by each event — real engineering work, only worth it well past current expected concurrency. -- `MAX_SUBSCRIBERS` (default 500, `app/rounds/events.py`) is a defensive cap only — past it, `GET /rounds/stream` returns 503 instead of opening a stream, and the client's `EventSource` just falls back to polling. Not a substitute for the app-wide "no rate limiting anywhere" gap (see Known gaps). +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). -Frontend: both `index.html` and `admin.html` open an `EventSource('/rounds/stream')` and, on an `update` message *or* on `open` (which fires on the initial connection and every automatic reconnect), immediately re-run the same refresh calls polling would eventually do — this matters most right after a dropped connection reconnects, closing most of the "missed while disconnected" gap. +Both SPAs refresh on an `update` message *or* on `open` — the latter fires on every automatic reconnect, closing most of the "missed while disconnected" gap. -## MVP business parameters +## Transaction lifecycle and reconciliation -- Bet cost per round: **10 PLM** by default, admin-configurable (`RoundConfig.bet_amount_sats`) — not a fixed constant. -- Prize split: **70% winner / 30% fees**, hardcoded in `rounds/scheduler.py` (`winner_share = pool_amount_sats * 70 // 100`) — unlike bet amount, this ratio is not in `RoundConfig` and would need a code change, not an admin-panel edit. -- Minimum withdrawal amount: equal to the current bet amount (`RoundConfig.bet_amount_sats`), enforced in `app/withdrawals/service.py` — not a separate admin-configurable field. Deposits have no server-side minimum check. -- Confirmations required for all tx types (deposit, bet, payout, withdrawal): **1**, hardcoded in `tx/confirmation.py` — not configurable, per the design decision below. +Everything that spends money is written **before** it is broadcast and resolved against the chain afterwards; this is what makes the system recover without manual DB edits. `PendingTransaction.status`: `building` → `pending` → `confirmed`, or `failed`. -## What is PLM Lottery +- `building` is written first, UTXOs already marked `spent_txid`, and committed *before* the broadcast (`bets/service.py`, `withdrawals/service.py`, and `scheduler.py:_trigger_payout` — the same shape in four phases, so no DB session is ever held across a network call). A crash in that window leaves evidence, not coins spent on-chain with no record. +- A refused broadcast releases the UTXOs, restores the balance, removes the participant (or marks the withdrawal `failed`), audit-logs, and raises `broadcast_failed` → **502**, since the network refused it, not the caller. +- `tx/reconcile.py` asks the chain about anything still `building`/`pending`: present → promote; positively unknown → `failed` with a `failure_reason`, inputs released, domain row rolled back, `pending_tx_abandoned` logged. Grace differs by state (120s `building`, 6h `pending`, so the bumper gets its attempts first). A *transport* failure never abandons anything — only a server that positively doesn't know the tx, currently inferred by substring-matching the error text (fragile — B-41). -A periodic-round lottery system built on a Bitcoin-like coin (PLM, mainnet). Each user gets a dedicated P2WPKH address (server-side HD wallet); they deposit PLM to that address, place a fixed-cost bet to enter the current round, and when the round closes a winner is drawn who receives 70% of the prize pool (the remaining 30% goes to fees). +`UtxoEvent.spent_txid` must always equal the tx's *current* txid, so `bump_fee` retargets it along with `RoundParticipant.bet_txid`, `Withdrawal.txid` and `Round.payout_txid` on every bump. `broadcast_at` is the *first* broadcast and is never rewritten (the reconciler's abandon clock measures from it); `last_broadcast_at` is what a bump updates and `should_bump` reads. Confirmation handlers key off immutable ids (`round_id`/`user_id`, `withdrawal_id`), never the txid, which changes under them. -## Architecture (from the flowchart subgraphs) +**Payouts retry, and are guarded against paying twice.** Every tick re-examines a `paying_out` round: `_retry_payout_if_due` throttles to one attempt per 60s using the latest `payout_failed` audit entry as its clock (a build failure leaves no DB row to throttle on), and every early return in `_trigger_payout` writes one, so `/admin` shows *why* a round is stuck. Before building, `_trigger_payout` refuses if a `building`/`pending` payout already exists for the round, and `_reserved_payout_outpoints` excludes pool UTXOs claimed by any unresolved payout — without both, a retry would pay the winner twice. -The flow is organized into 5 phases (see [flowchart/platform-overview.mmd](flowchart/platform-overview.mmd) for the full-platform diagram, and [flowchart/round-lifecycle.mmd](flowchart/round-lifecycle.mmd) for the round/draw phase in detail): +**"At most one active round" is a DB invariant**, not a convention: `ix_rounds_single_active` (unique index over the constant `(1)`, restricted to the active statuses) makes a concurrent second insert fail cleanly, and `open_new_round_if_needed` recovers by adopting the winner's round (max 3 attempts). -- **REG (Registration)**: on signup the server derives a new P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from a master xprv **encrypted at rest**. This address is permanent and serves as both the deposit address and the address that receives winnings and withdrawals. -- **DEP (Balance top-up)**: an ElectrumClient/SPV subscribes to the user's address scripthash. Internal balance (DB) is credited after **1 confirmation only** — the reorg risk at 1-conf is knowingly accepted in v1, with no rollback logic. -- **PLAY (Bet)**: fixed cost per round, **at most one active bet per user at a time** in v1. The server builds a PSBT user-address → pool-address for the fixed amount, with a **change output back to the same user address** (the user's balance must never exactly equal the bet amount). Fee minimized (~1 sat/vB), **deducted from the bet amount**. If the tx doesn't confirm within a timeout, fee-bump (RBF) and rebroadcast. -- **DRAW (Periodic draw)**: configurable timer (default 10 minutes). The round's own deadline (`opened_at + round_duration_seconds`) is the authoritative "yellow light" cutoff for new bets — **not** the DB status transition. `place_bet` (`app/bets/service.py`) calls `rounds/service.round_accepts_bets(round_, round_duration_seconds)`, which rejects the bet once the deadline has passed even if `status` is still `"open"` in the DB (the `RoundScheduler` tick that flips it to `"closing"` runs every `_TICK_INTERVAL_SECONDS` = 5s and can lag a few seconds behind the deadline). This closes the race where a bet placed in that lag window would otherwise still be accepted. Once a round leaves `open` (closing/drawing/paying_out), **no new bets are accepted** for it either, and a new round can't open until the current one is fully `closed` (see round cooldown below). Round closing **waits for all already-broadcast bets to confirm** before proceeding (avoids losing bets at the round boundary) — this is the "yellow light" behavior: no new entries once the timer hits zero, but bets already in flight are still given time to confirm before the round actually closes and draws. The **next round only opens once the previous round's payout tx is confirmed** — rounds never overlap in v1. v1 draw algorithm (deliberately simple, meant to be replaced later): wait for the first block confirmed after round closing, use its hash as seed, `index = seed mod participant_count` over the participant list ordered by **broadcast timestamp** (this is also the tie-break when two bets confirm in the same block). Every participant has **equal probability regardless of bet amount** (consistent with the fixed bet amount). The payout (70% winner / 30% fees) is signed with the pool address key; the **payout fee is deducted from the winner's 70%**, the 30% fee share stays intact. Same timeout → RBF → rebroadcast pattern here too. The frontend shows a generic "drawing" status box (phase label, e.g. "Pagamento al vincitore in corso…") to **every** viewer on every dashboard for the whole closing/drawing/paying_out phase — this one is purely cosmetic status text, driven directly by `status`, no gating. Independently and *additively* (not instead of it), a personalized "Hai vinto!/Non hai vinto" box appears only for users where `GET /rounds/current`'s `user_played` field is true (computed via `app/auth/dependencies.py:get_optional_user`, since this endpoint is reachable logged-out too) — everyone else has nothing to reveal and never sees it. That reveal is additionally delayed by at least `draw_animation_seconds` (admin-configurable, default 20s) for cosmetic suspense, anchored to the round's server-provided `closes_at` timestamp rather than a client-side "first seen" time (so reloading the page can't reset the countdown), and decoupled from the real (and much longer, ~block-time) wait for `winner_user_id` to actually be set. Once revealed, the result is persisted in the browser's `localStorage` (`plm_persisted_result`) so it survives a page refresh even after the round moves past `paying_out` into `closed` — at which point `get_active_round` stops returning that round at all and `winner_user_id` disappears from `GET /rounds/current` entirely. `GET /users/me/last-round-result` (`app/api/routes/users.py`) is a durable, DB-backed backstop for a user who reloads on a browser/device that missed the live reveal window completely: it looks up the most recent *closed* round the user has a `RoundParticipant` row in. See `app/static/index.html`'s `refreshRound`/`checkLastRoundResult` for the full reveal logic. -- **WITHDRAW (Withdrawal)**: the only way to move funds out of the platform to an external address. PSBT user-address → external-address + change back to the user address, fee deducted from the withdrawn amount, same RBF retry pattern. +## Frontends -PLAY and WITHDRAW share a **per-user DB lock**: a user can never have a bet-build and a withdrawal-build in flight at the same time, since both would otherwise spend from the same UTXO set on the user's dedicated address. +Two static SPAs served directly by FastAPI (`main.py` mounts `app/static/` and adds routes for `/admin`, `/guida`, `/report-bug`) — no build step, no framework, no bundler, `Cache-Control: no-store`. -**Three separate on-chain confirmations, not one, between the timer hitting zero and the payout landing** — a common point of confusion, worth spelling out explicitly: -1. **Last bet's confirmation** (`scheduler.py`'s `_tick`, the `pending_count` check before `_close_and_draw`) — the round doesn't even flip to `"closing"` until every already-broadcast bet has its 1st confirmation. This can already have happened before the timer expired; it's the earliest of the three and not necessarily tied to the deadline at all. -2. **The draw block** (`_wait_for_next_block`, waits for `tip_height > tip_at_close`, where `tip_at_close` is recorded only once step 1 is done) — by construction this must be a **later, different block** than whichever one confirmed the last bet in step 1. -3. **Payout confirmation** — `_trigger_payout` broadcasts only after step 2's block is known, then registers a `PendingTransaction(kind="payout")` that the same generic `ConfirmationPoller` (`app/tx/confirmation.py`) waits on independently — this needs **yet another, later block** than step 2's, since the payout can't be built before the winner is known. +- **`/`** — end-user test UI: register/login, then a navbar dashboard with four panels (Deposito with a QR from `GET /qr/{address}`, Bet, Prelievo, Profilo — account info + self-service password change via `POST /users/me/change-password`), above a persistent round-status card and the chain-status bar with the language switcher. +- **`/admin`** — gated by a token screen (not a login: just `X-Admin-Token` vs `ADMIN_TOKEN`), then five sections each backed by its own `/admin/*` endpoint: Parametri (`RoundConfig` + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log; plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction. -So worst case (last bet confirms right at the deadline) is ~3 block times end-to-end; best case (all bets already confirmed before the timer hit zero) is ~2 (draw block + payout block). At PLM's 120s block time that's roughly 4–6 minutes worst case, 2–4 minutes best case — independent of `draw_animation_seconds`, which only sets a cosmetic minimum for the frontend animation. +Both talk to the same JSON API; there's no admin/user API split beyond `require_admin`. -## Internationalization (user-facing page only) +## Internationalization (`/` only) -`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is available everywhere. Language comes from `localStorage.plm_lang`, falling back to `navigator.language`, falling back to `en`; the switcher lives in the **chain-bar, not the navbar**, deliberately — the navbar is hidden until login, which would leave the landing page and the login form untranslatable for exactly the users who need the switch. +`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is always available. Language: `localStorage.plm_lang` → `navigator.language` → `en`. The switcher sits in the **chain-bar, not the navbar**, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it. -- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`), applied by `applyStaticTranslations(root?)` on `DOMContentLoaded` and on every switch. Anything rendered from server data is built with `t()` in `app.js` instead, and re-rendered by `onLanguageChange()` — an element must be in one camp or the other, never both, or the two mechanisms overwrite each other (this is why `#bet-btn` has no `data-i18n`: its label carries the admin-configurable bet amount, so `renderBetButton()` owns it). -- **Every language must have exactly the same key set.** There is no fallback beyond `en`, and a missing key renders as the raw key string. -- `/admin` is intentionally **not** translated (operator-facing, Italian only), and neither is `/guida` (serves `docs/guida-utente.md`). +- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`) via `applyStaticTranslations(root?)`; anything rendered from server data uses `t()` in `app.js` and is re-rendered by `onLanguageChange()`. An element belongs to one camp or the other, **never both**, or the two mechanisms overwrite each other — that's why `#bet-btn` has no `data-i18n`: its label carries the configurable bet amount, so `renderBetButton()` owns it. +- **Every language must have exactly the same key set.** There is no fallback beyond `en`; a missing key renders as the raw key string. +- `/admin` is intentionally untranslated (operator-facing, Italian), as is `/guida`. -**API error contract** (`app/api/errors.py`): the API is single-language by design. User-facing failures answer with a structured `detail` — `{"code", "message", "params"}` — where `message` is English for non-dashboard consumers and `code` is what the frontend maps onto `error.` in `i18n.js` (falling back to `message` for an unknown code). Domain exceptions (`BetError`, `WithdrawalError`) subclass `ApiError` and carry the code from where the failure actually happens; `str(exc)` is still the English message. When adding a user-facing error: give it a code, add `error.` to all 7 languages, and pass interpolated values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling automatically) rather than baking them into the English text. - -## Admin dashboard and test UI - -Two static single-page apps, served directly by FastAPI (`app/main.py` mounts `app/static/` and adds a dedicated `GET /admin` route) — no build step, no framework. Each page's HTML/CSS/JS are separate files (`index.html`/`style.css`/`app.js`, `admin.html`/`admin.css`/`admin.js`), served as plain static files (no bundler): - -- **`/` (`app/static/index.html`)**: the end-user test UI. Register/login, then a menu-driven dashboard (Deposito with a QR code of the address via `GET /qr/{address}`, Bet, Prelievo) with a persistent round-status card (`GET /rounds/current`: id/status/timer/participant count/jackpot) above the menu. -- **`/admin` (`app/static/admin.html`)**: gated by a token screen (not a real login — just checks `X-Admin-Token` against `ADMIN_TOKEN` from `.env`), then a navbar-driven dashboard with five sections, each backed by its own `/admin/*` endpoint (`app/api/routes/admin.py`): Parametri (`RoundConfig` CRUD), Utenti (list + per-user WIF privkey export, audit-logged), Round (history), Transazioni pendenti (in-flight RBF candidates), Audit log. **`/admin` is deliberately not linked from `/`** in either direction — reachable only by knowing the URL. - -Both pages talk to the same JSON API everything else uses; there's no separate "admin API" vs "user API" boundary beyond the `require_admin` dependency. +**API error contract** (`app/api/errors.py`) — the API is single-language by design. Failures answer with a structured `detail`: `{"code", "message", "params"}`, where `message` is English for non-dashboard consumers and `code` is what the frontend maps to `error.` in `i18n.js` (falling back to `message` for an unknown code). `BetError`/`WithdrawalError` subclass `ApiError` and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (`internal_error`), so clients never special-case unexpected errors, and the exception text stays in `logs/app.log`. Adding a user-facing error: give it a code, add `error.` to all 7 languages, and pass values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling) instead of baking them into English text. ## Non-obvious domain decisions -These choices were made explicitly during design (not derivable from reading a single file) and must be respected in any implementation: +Explicit design choices, not derivable from any single file — respect them: -- Private keys (xprv) are generated and held **server-side** — this is not a non-custodial system: the user never controls their own keys until they make an explicit withdrawal. -- The user's personal deposit address always doubles as the winnings-receiving address: there is no separate "winner address". -- 1 confirmation is the chosen threshold for all tx types (deposits, bets, payouts, withdrawals): don't introduce different thresholds (e.g. 3 or 6 confirmations) without an explicit decision. -- The draw algorithm (node R) is deliberately simple and should be treated as a replaceable/pluggable component, not the final design — don't architect around its current implementation. -- The admin panel can export any user's raw WIF private key (`GET /admin/users/{id}/privkey`, `app/wallet/hd.py:derive_user_wif`). This is intentional, not a vulnerability to fix: the server already holds the master key everything derives from (custodial by design, see above), so this only exposes through the API something an operator could already do via a script. Every access is written to `audit_log` (`admin_privkey_accessed`) — don't remove that logging when touching this endpoint. -- RBF fee bumps are paid by whoever's change output the tx pays back to — the user for bets/withdrawals, the pool for payouts — never by the fixed counterparty amount (recipient/winner/fee-address outputs are untouched; only the sender's own change shrinks). See `bump_fee` in `app/tx/broadcast.py`. - -## Transaction reconciliation and the tx lifecycle - -Everything that spends money is written **before** it is broadcast, and resolved against -the chain afterwards. This is what makes the system recover on its own instead of needing -manual DB edits (BUGS.md B-04/B-08). - -`PendingTransaction.status` is the lifecycle: `building` → `pending` → `confirmed`, or -`failed`. - -- `building` is written first, with the UTXOs already marked `spent_txid`, and committed - *before* the broadcast (`bets/service.py:place_bet`, `withdrawals/service.py`). A crash - in that window therefore leaves evidence rather than coins spent on-chain with no record. -- If the broadcast is refused, the service releases the reserved UTXOs, restores the - balance, removes the participant (or marks the withdrawal `failed`), audit-logs it, and - raises `broadcast_failed` — answered as **502**, since the network refused it, not the - caller. -- `app/tx/reconcile.py` (`PendingTransactionReconciler`, every 120s and once at startup) - asks the chain about anything still `building`/`pending`. Tx present → promote; tx gone → - mark `failed` with a `failure_reason`, release the inputs, roll the domain row back, - audit-log `pending_tx_abandoned`. Grace periods differ by state (120s for `building`, - 6h for `pending`, so the RBF bumper gets its attempts first), and a *transport* failure - never abandons anything — only a server that positively doesn't know the tx does. - -Because of this, `UtxoEvent.spent_txid` must always equal the *current* txid of the tx -reserving it: `bump_fee` retargets it (along with `RoundParticipant.bet_txid`, -`Withdrawal.txid` and `Round.payout_txid`) on every fee bump. Confirmation handlers -deliberately key off immutable ids (`round_id`/`user_id`, `withdrawal_id`) rather than the -txid, which changes under them. - -**At most one active round is a database invariant**, not just a code convention: -`ix_rounds_single_active` (a unique index over the constant expression `(1)`, restricted to -the active statuses) makes a concurrent second insert fail cleanly, and -`open_new_round_if_needed` recovers by using the winner's round. +- Keys are generated and held **server-side**: this is **custodial**. The user controls nothing until they withdraw. +- The deposit address *is* the winnings address — there is no separate "winner address". +- **1 confirmation** for every tx kind. Don't introduce differing thresholds (3, 6, …) without an explicit decision. +- The draw algorithm is a **replaceable component**, not the final design — don't architect around its current form. +- `GET /admin/users/{id}/privkey` exporting a raw WIF is **intentional**, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes `admin_privkey_accessed` — don't remove that logging. +- Argon2 hashing means **no password recovery, only reset**: `POST /admin/users/{id}/reset-password` sets a new random password, returns it once for the operator to relay, and logs `admin_password_reset`. No self-service reset exists (no email is ever collected); a logged-in user can only *change* their password by supplying the current one. +- RBF bumps are paid by whoever's change the tx pays back to — the user for bets/withdrawals, the pool for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only the sender's own change shrinks (`bump_fee`). ## Known gaps / TODO -Not blockers for reading the code, but must be addressed before this is production-ready. -The 24 findings of the 2026-07-26 full-codebase audit are **all fixed** — see -[BUGS.md](BUGS.md), which keeps each one's root cause, fix and regression test as the -record. What remains open: +Accepted **by design**. For actual bugs see [BUGS.md](BUGS.md) (18 open) — not duplicated here. -- **Scheduler doesn't resume mid-flight rounds after a restart.** `rounds/scheduler.py`'s `_tick()` only acts on rounds with `status == "open"`. If the process restarts while a round is `closing`/`drawing`/`paying_out`, it's permanently stuck — nothing re-enters `_wait_for_next_block` or retries `_trigger_payout`. Needs a startup routine that inspects in-progress rounds and resumes (or a periodic "unstick" check) before this can run unattended. Note this is *round*-level state: in-flight *transactions* do now recover on their own (see "Transaction reconciliation" below). -- **RBF bump only handles one case**: a single change output, paying back to the tx's own sender address, large enough to absorb the fee increase. No additional-input selection fallback — an exact-amount tx (no change) or a change output too small to absorb the bump raises `RbfError`. The consequence is no longer permanent, though: a tx that can't be bumped and never confirms is eventually abandoned and its UTXOs released (see "Transaction reconciliation"), so the funds come back instead of being frozen. -- **Payout retry**: if `_trigger_payout` fails (insufficient pool UTXOs, a bad `fee_address`, Electrum disconnected), it logs, writes a `payout_failed` audit entry, and returns — the round stays in `paying_out` with no automatic retry. The audit entry makes it visible in `/admin`; acting on it is still manual. -- **Withdrawal and RBF bump have never been exercised against a live broadcast** — only deposit and bet flow are verified end-to-end with real PLM. Both paths have unit coverage, including their failure and rollback branches, but unit tests are not a live network. -- **No general user-facing history endpoints** (list my own bets / withdrawals / past rounds) — `GET /users/me/last-round-result` covers exactly one case (the outcome of the most recent *closed* round the user played in, as a reveal-persistence backstop; see DRAW above), not a real history. The admin side has more (`/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`), but there's still no "my own full history" equivalent for a logged-in user. A failed withdrawal now leaves a `status="failed"` row the user cannot see anywhere — an argument for closing this gap. -- **Admin auth is a single shared bearer token** (`ADMIN_TOKEN`, `X-Admin-Token` header) — no per-admin identity: `audit_log` records *what* changed (config edits are now logged too, as `config_updated`, with before/after values) but never *which operator* did it. This token gates the user list, private key export and round/audit history, so its blast radius if leaked is large. -- **No rate limiting / abuse protection** on any endpoint (register, bet, withdrawal, admin). -- **`/guida` is not served in Docker.** `GET /guida` reads `docs/guida-utente.md`, and the `Dockerfile` deliberately does not `COPY docs` — the guide is pending a rewrite, so it isn't shipped yet. The endpoint answers a clean 404 (`guide_unavailable`, translated) and logs an error rather than crashing, but the navbar help link leads nowhere until `COPY docs ./docs` is added back. -- No automated integration tests against a live Electrum connection — all live-network verification so far has been manual (ad hoc scripts + real mainnet transactions), not part of the `pytest` suite. -- **Single-process assumptions**: the SSE broadcaster (`rounds/events.py`) and the per-user locks (`tx/locks.py`) are both in-process only. Fine for the current one-uvicorn-process deployment; a multi-worker one needs a shared channel and a DB/Redis lock. Note the round-uniqueness invariant is *not* in this category any more — it's enforced by a DB index (see below). +- **`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`. That wait is also unbounded and invisible in `/admin` (B-36) — 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. +- **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network). +- **No user-facing history.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this. +- **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius. +- **No rate limiting anywhere** (register, bet, withdrawal, admin, SSE). For login this is a blocker, not a gap — tracked as B-33. +- **`/guida` and `/report-bug` are placeholders** (`app/static/guida.html`, `report-bug.html`) — links work, content is "coming soon". +- **No integration tests against a live Electrum connection.** `tests/integration/` is empty; live verification has all been manual (`scripts/electrum_smoke_test.py`, ad hoc scripts, real mainnet txs). +- **Single-process assumptions**: the SSE broadcaster and the per-user locks are in-process only. A multi-worker deployment needs a shared channel and a DB/Redis lock. The round-uniqueness invariant is *not* in this category — it's a DB index. From 17c557b8a3a49aa0da9bf12c947b683448f4fe56 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 11:16:20 +0200 Subject: [PATCH 11/29] Meet BIP125's relay minimum on every RBF bump, and cap the fee rate (B-32) bump_fee computed fee_delta as new_fee - old_fee, falling back to a flat 1-satoshi bump whenever that came out zero or negative - which happened whenever old_fee (the actual fee paid, from real prevout amounts) already exceeded the naive target, e.g. because dust change had been folded into the original fee (psbt_builder.py's DUST_LIMIT_SATS handling). A 1-satoshi total increase is nowhere near BIP125 rule 4's minimum (the replacement must pay at least the incremental relay fee rate times its own vsize more than what it replaces), so the node rejected it every time - and since bump_fee raised before touching `pending`, the next tick retried with identical parameters every 30 seconds, forever. Separately, the fee rate climbed by 1 sat/vB every bump with no ceiling. fee_delta is now max(target_fee - old_fee, vsize * the incremental relay rate) - always at least the relay-mandated minimum regardless of what the naive arithmetic produces. pending.fee_rate_sat_vb is set to the actual resulting rate rather than the naive target, so a later bump's arithmetic starts from what's really being paid instead of drifting from it. Once a transaction reaches MAX_FEE_RATE_SAT_VB (a new constant, 10,000 sat/vB, shared with RoundConfig.fee_rate_sat_vb's existing admin-facing bound so the two can't drift apart - the same reason MIN_PASSWORD_LENGTH is shared elsewhere) bump_fee refuses to bump further; the reconciler abandons it if it never confirms (B-27) instead of this retrying forever. Suite grows from 185 to 187 tests. BUGS.md moves B-32 to Previously fixed. --- BUGS.md | 33 ++------- app/api/routes/admin.py | 3 +- app/tx/broadcast.py | 46 +++++++++--- app/wallet/psbt_builder.py | 8 +++ tests/unit/test_broadcast.py | 131 ++++++++++++++++++++++++++++++++++- 5 files changed, 183 insertions(+), 38 deletions(-) diff --git a/BUGS.md b/BUGS.md index 33e289a..6003d14 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-32 … B-49. B-25 through B-31 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 18 are High/Medium/Low. +7 medium, 8 low), listed below as B-33 … B-49. B-25 through B-32 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 17 are High/Medium/Low. 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 seven fixes so far brought the suite -from 139 to 185). +coverage — every fix lands with a regression test (the eight fixes so far brought the suite +from 139 to 187). 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. @@ -20,28 +20,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD ## High -### B-32 — `bump_fee` can loop forever on rebroadcasts the node always rejects - -`tx/broadcast.py:87-88` forces `fee_delta = 1` when `fee_delta <= 0`. A **one-satoshi** total -fee increase violates BIP125 rule 4 (a replacement must pay at least the incremental relay fee -times its own size), so the node rejects it. `bump_fee` raises before updating `pending`, so -`fee_rate_sat_vb` never advances and the next tick **retries with identical parameters, every -30 seconds, forever**. - -This triggers whenever the real fee exceeds the estimate — i.e. whenever dust change was -absorbed into the fee, which is an explicitly supported path -(`wallet/psbt_builder.py:105-107`). - -Related, same function: `new_fee_rate = pending.fee_rate_sat_vb + 1` on every bump, with **no -ceiling**. A transaction stuck for a day reaches ~96 sat/vB, eating the user's change, and it -ignores the `le=10_000` bound the admin panel enforces on the config field. - -**Proposed fix.** Compute the delta from the actual replacement vsize -(`fee_delta = max(new_fee - old_fee, ceil(vsize * incremental_relay_rate))`) so the bump is -always relay-valid. Cap `new_fee_rate` at the configured maximum and raise `RbfError` once -reached, so the transaction falls through to the reconciler (which, since B-27, correctly -abandons it) rather than being retried indefinitely. - ### B-33 — No brute-force protection on a custodial wallet `POST /auth/login` (`auth/routes.py:83`) has no rate limiting, no lockout, no delay and no @@ -248,9 +226,10 @@ already does. - **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-32** — an RBF bump's fee delta could fall below BIP125's relay-mandated minimum, so the node rejected it and the same tick retried identically forever; also had no ceiling on how high the fee rate could climb See git history for the fix-by-fix breakdown (commits `f13f685`, `50a43ae`, `933760e`, and the -B-28/B-29/B-30/B-31 fixes). Suite grew from 139 to 185 tests over the seven. +B-28/B-29/B-30/B-31/B-32 fixes). Suite grew from 139 to 187 tests over the eight. 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, diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 11b3db5..d4f036c 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -14,6 +14,7 @@ from app.db.session import get_session from app.rounds.config import get_round_config from app.wallet.address import is_valid_plm_address from app.wallet.hd import derive_user_wif +from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB router = APIRouter(prefix="/admin", tags=["admin"]) @@ -68,7 +69,7 @@ class RoundConfigUpdate(BaseModel): bet_amount_sats: int | None = Field(default=None, gt=0, le=100_000 * 100_000_000) round_duration_seconds: int | None = Field(default=None, ge=30, le=7 * 24 * 3600) round_cooldown_seconds: int | None = Field(default=None, ge=0, le=24 * 3600) - fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=10_000) + fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB) rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600) draw_animation_seconds: int | None = Field(default=None, ge=0, le=600) diff --git a/app/tx/broadcast.py b/app/tx/broadcast.py index d2fa708..c31ea60 100644 --- a/app/tx/broadcast.py +++ b/app/tx/broadcast.py @@ -14,12 +14,18 @@ from app.electrum.client import ElectrumClient from app.rounds.config import get_round_config from app.wallet.hd import derive_pool_key, derive_user_key from app.wallet.plm_network import PLM_MAINNET -from app.wallet.psbt_builder import RBF_SEQUENCE, estimate_vsize +from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize logger = logging.getLogger(__name__) _POLL_INTERVAL_SECONDS = 30 -_FEE_RATE_INCREMENT = 1 # minimum relay-policy-friendly bump per BIP125 +_FEE_RATE_INCREMENT = 1 # how much pending.fee_rate_sat_vb's *target* rises by per bump + +# BIP125 rule 4: a replacement transaction must pay at least this much more, in +# total, per vbyte of its own size, than the transaction it replaces — Bitcoin +# Core's default incremental relay fee. bump_fee's delta must never fall below +# this regardless of what the target-rate arithmetic comes out to (B-32). +_INCREMENTAL_RELAY_FEE_RATE_SAT_VB = 1 class RbfError(Exception): @@ -79,20 +85,38 @@ async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: Pendi tx's own sender address, large enough to absorb the increase. If there's no such output (e.g. an exact-amount bet with no change), this raises RbfError — bumping such a tx would require selecting additional inputs, which isn't - implemented for the MVP; it needs manual operator intervention. + implemented for the MVP; it needs manual operator intervention. Also raises + RbfError, rather than bumping, once `pending` is already at MAX_FEE_RATE_SAT_VB + (B-32) — the reconciler abandons it if it never confirms (B-27), instead of + this retrying an ever-higher fee forever. """ + if pending.fee_rate_sat_vb >= MAX_FEE_RATE_SAT_VB: + raise RbfError( + f"pending_transaction {pending.id}: already at the maximum fee rate " + f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further" + ) + old_tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex)) signing_key, own_script, own_address = await _signing_context(session, pending) input_amounts = [await _prevout_amount(client, vin) for vin in old_tx.vin] total_in = sum(input_amounts) old_fee = total_in - sum(o.value for o in old_tx.vout) + vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout)) - new_fee_rate = pending.fee_rate_sat_vb + _FEE_RATE_INCREMENT - new_fee = estimate_vsize(len(old_tx.vin), len(old_tx.vout)) * new_fee_rate - fee_delta = new_fee - old_fee - if fee_delta <= 0: - fee_delta = _FEE_RATE_INCREMENT # already above the new target vsize*rate; bump by a token amount + target_fee_rate = min(pending.fee_rate_sat_vb + _FEE_RATE_INCREMENT, MAX_FEE_RATE_SAT_VB) + target_fee = vsize * target_fee_rate + # BIP125 rule 4's minimum, in absolute sats for this tx's size — the floor + # `fee_delta` must never go below, no matter what `target_fee - old_fee` comes + # out to. That naive difference used to go to zero or negative whenever + # old_fee already exceeded target_fee (e.g. a dust change amount folded into + # the original fee — wallet/psbt_builder.py's DUST_LIMIT_SATS handling), and + # the previous fallback — a flat 1-satoshi total bump — was nowhere near this + # relay-mandated minimum, so the node rejected it every time. Because bump_fee + # raised before touching `pending`, the next tick retried with identical + # parameters every 30 seconds, forever (B-32). + min_valid_delta = vsize * _INCREMENTAL_RELAY_FEE_RATE_SAT_VB + fee_delta = max(target_fee - old_fee, min_valid_delta) change_index = _find_change_output(old_tx, own_address) if change_index is None or old_tx.vout[change_index].value <= fee_delta: @@ -124,7 +148,11 @@ async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: Pendi pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced pending.current_txid = new_txid pending.raw_tx_hex = raw_hex - pending.fee_rate_sat_vb = new_fee_rate + # The *actual* resulting rate, not target_fee_rate: when the BIP125-minimum + # floor above raised fee_delta past the naive target, the tx now pays more + # than target_fee_rate implied. Recording the true rate keeps the next bump's + # arithmetic honest instead of drifting from what's really being paid. + pending.fee_rate_sat_vb = (old_fee + fee_delta) // vsize pending.attempt_count += 1 # last_broadcast_at, not broadcast_at (B-27): broadcast_at must stay the *first* # broadcast, since reconcile.py's abandon-after-N-hours grace period is measured diff --git a/app/wallet/psbt_builder.py b/app/wallet/psbt_builder.py index 6c9ebf8..b822617 100644 --- a/app/wallet/psbt_builder.py +++ b/app/wallet/psbt_builder.py @@ -25,6 +25,14 @@ RBF_SEQUENCE = 0xFFFFFFFD # withdrawal fail at broadcast with an opaque error (B-06). DUST_LIMIT_SATS = 294 +# Sanity ceiling on any transaction's fee rate — shared by RoundConfig.fee_rate_sat_vb's +# admin-facing bound (app/api/routes/admin.py, so the two can't drift apart, the same +# reason MIN_PASSWORD_LENGTH is shared in auth/security.py) and tx/broadcast.py's RBF +# bump escalation, which refuses to bump a pending_transaction past this rate (B-32) — +# without a ceiling, a stuck transaction's fee climbed by 1 sat/vB every bump forever, +# eating further and further into the sender's change with no limit. +MAX_FEE_RATE_SAT_VB = 10_000 + class InsufficientFundsError(Exception): """`code` is the machine-readable identifier the API layer forwards to the diff --git a/tests/unit/test_broadcast.py b/tests/unit/test_broadcast.py index d7dee34..e4ea562 100644 --- a/tests/unit/test_broadcast.py +++ b/tests/unit/test_broadcast.py @@ -11,7 +11,7 @@ from app.db.base import Base from app.db.models import PendingTransaction, User from app.tx.broadcast import RbfError, bump_fee, should_bump from app.wallet.plm_network import PLM_MAINNET -from app.wallet.psbt_builder import Utxo, build_signed_transaction +from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, Utxo, build_signed_transaction, estimate_vsize def _key(seed_byte: int) -> HDKey: @@ -319,3 +319,132 @@ async def test_bump_fee_retargets_every_stored_txid(session_factory): utxo = (await session.scalars(select(UtxoEvent))).one() assert utxo.spent_txid == new_txid + + +# --- B-32: the bump delta must always meet BIP125's relay-mandated minimum, and +# escalation must stop at a ceiling instead of retrying forever. ------------------ + + +async def test_bump_fee_meets_bip125_minimum_when_old_fee_already_exceeds_target(session_factory): + """old_fee (as bump_fee computes it from the actual prevout amounts) can end + up higher than vsize * target_fee_rate — e.g. because dust change was folded + into the original fee (wallet/psbt_builder.py's DUST_LIMIT_SATS handling). + The naive `target_fee - old_fee` goes negative in that case; the previous + fallback was a flat 1-satoshi total bump, nowhere near BIP125 rule 4's + required minimum, so the node rejected it every time and — since bump_fee + raised before touching `pending` — the next tick retried identically every + 30 seconds, forever. Simulated here by reporting a prevout inflated beyond + what was actually spent, which has the same effect on old_fee as dust + absorption would.""" + from app.wallet.hd import derive_user_address, derive_user_key + + signer = derive_user_key(0) + my_address = derive_user_address(0) + from_script = script.p2wpkh(signer.to_public()) + to_address = script.p2wpkh(_key(96).to_public()).address(network=PLM_MAINNET) + + utxo_amount = 150_000_000 + utxo_txid = "55" * 32 + built = build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=[Utxo(utxo_txid, 0, utxo_amount)], + to_address=to_address, + amount_sats=10_000_000, + change_address=my_address, + fee_rate_sat_vb=1, + ) + + async with session_factory() as session: + user = User(username="dave", password_hash="x", derivation_index=0, address=my_address) + session.add(user) + await session.commit() + pending = PendingTransaction( + kind="bet", + user_id=user.id, + current_txid=built.txid, + fee_rate_sat_vb=1, + raw_tx_hex=built.raw_hex, + status="pending", + broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + ) + session.add(pending) + await session.commit() + pending_id = pending.id + + # Reports a prevout inflated well beyond what was actually spent — has the + # same effect on old_fee as dust absorption would have: old_fee ends up far + # above vsize * target_fee_rate (target_fee_rate = 2 here). + inflated_excess = 50_000 + client = FakeClient({utxo_txid: utxo_amount + inflated_excess}) + + async with session_factory() as session: + row = await session.get(PendingTransaction, pending_id) + new_txid = await bump_fee(session, client, row) + + assert client.broadcasted + new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0])) + old_tx = Transaction.parse(bytes.fromhex(built.raw_hex)) + old_change = next(o.value for o in old_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address) + new_change = next(o.value for o in new_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address) + + vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout)) + min_valid_delta = vsize * 1 # BIP125 rule 4's floor at a 1 sat/vB incremental relay fee + assert min_valid_delta > 1 # meaningfully more than the old flat "1 satoshi" fallback + assert old_change - new_change == min_valid_delta + + async with session_factory() as session: + row = await session.get(PendingTransaction, pending_id) + old_fee_as_bump_fee_computed_it = built.fee_sats + inflated_excess + expected_rate = (old_fee_as_bump_fee_computed_it + min_valid_delta) // vsize + assert row.fee_rate_sat_vb == expected_rate + assert row.fee_rate_sat_vb > 2 # the actual rate, not the naive (and too-low) target + + +async def test_bump_fee_refuses_once_at_the_max_fee_rate(session_factory): + """Without a ceiling, a stuck transaction's fee rate climbed by 1 sat/vB every + 30 seconds forever, eating further and further into the user's change.""" + from app.wallet.hd import derive_user_address, derive_user_key + + signer = derive_user_key(0) + my_address = derive_user_address(0) + from_script = script.p2wpkh(signer.to_public()) + to_address = script.p2wpkh(_key(95).to_public()).address(network=PLM_MAINNET) + + utxo_amount = 150_000_000 + utxo_txid = "66" * 32 + built = build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=[Utxo(utxo_txid, 0, utxo_amount)], + to_address=to_address, + amount_sats=10_000_000, + change_address=my_address, + fee_rate_sat_vb=1, + ) + + async with session_factory() as session: + user = User(username="erin", password_hash="x", derivation_index=0, address=my_address) + session.add(user) + await session.commit() + pending = PendingTransaction( + kind="bet", + user_id=user.id, + current_txid=built.txid, + fee_rate_sat_vb=MAX_FEE_RATE_SAT_VB, + raw_tx_hex=built.raw_hex, + status="pending", + broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + ) + session.add(pending) + await session.commit() + pending_id = pending.id + + client = FakeClient({utxo_txid: utxo_amount}) + + async with session_factory() as session: + row = await session.get(PendingTransaction, pending_id) + with pytest.raises(RbfError): + await bump_fee(session, client, row) + + assert not client.broadcasted From 16802cafb66852119313889754cca41967a4f1b0 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 11:51:26 +0200 Subject: [PATCH 12/29] Throttle login and registration with exponential backoff (B-33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /auth/login had no rate limiting, no lockout, no delay — a patient distributed attack could brute-force a password against an enumerable username list on a custodial wallet, where a guessed password means withdrawing someone's funds. Add per-username and per-IP throttling with exponential backoff (app/auth/rate_limit.py), keyed on app.state like UserLocks rather than a module global so each app instance gets isolated throttle state. Unknown-user and wrong-password already shared one response path, so no enumeration oracle there. Registration is throttled per-IP too, which also bounds how many accounts one IP can spin up (B-31). Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 27 ++------- app/auth/rate_limit.py | 75 ++++++++++++++++++++++++ app/auth/routes.py | 65 ++++++++++++++++++++- app/static/i18n.js | 7 +++ tests/unit/test_auth.py | 126 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 278 insertions(+), 22 deletions(-) create mode 100644 app/auth/rate_limit.py create mode 100644 tests/unit/test_auth.py diff --git a/BUGS.md b/BUGS.md index 6003d14..63a0639 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-33 … B-49. B-25 through B-32 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 17 are High/Medium/Low. +7 medium, 8 low), listed below as B-33 … B-49. B-25 through B-33 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 16 are High/Medium/Low. 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 eight fixes so far brought the suite -from 139 to 187). +coverage — every fix lands with a regression test (the nine fixes so far brought the suite +from 139 to 192). 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. @@ -20,22 +20,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD ## High -### B-33 — No brute-force protection on a custodial wallet - -`POST /auth/login` (`auth/routes.py:83`) has no rate limiting, no lockout, no delay and no -CAPTCHA, and the password minimum is 8 characters. Argon2 slows a single attempt but not a -patient distributed attack against an enumerable username list — and `409 username_taken` on -registration is a perfect enumeration oracle. - -"No rate limiting" is listed as a generic known gap; on a system where guessing a password -means **withdrawing someone's funds**, it deserves to be tracked separately and treated as a -blocker. - -**Proposed fix.** Per-username *and* per-IP throttling with exponential backoff on failed -logins (`slowapi`, or a small DB-backed counter — but note the in-process caveat if workers -are ever scaled). Return an identical response for unknown-user and wrong-password. Rate-limit -registration too, which also bounds B-31's attacker-controlled user count. - ### B-34 — Password change and admin reset do not invalidate existing sessions Neither `/users/me/change-password` nor `/admin/users/{id}/reset-password` invalidates @@ -227,9 +211,10 @@ already does. - **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-32** — an RBF bump's fee delta could fall below BIP125's relay-mandated minimum, so the node rejected it and the same tick retried identically forever; also had no ceiling on how high the fee rate could climb +- **B-33** — `POST /auth/login` had no rate limiting on a custodial wallet, so a patient distributed attack could brute-force a password against an enumerable username list; fixed with per-username *and* per-IP exponential backoff (`app/auth/rate_limit.py`), registration throttled per-IP too (also bounds B-31's attacker-controlled user count) 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 fixes). Suite grew from 139 to 187 tests over the eight. +B-28/B-29/B-30/B-31/B-32/B-33 fixes). Suite grew from 139 to 192 tests over the nine. 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, diff --git a/app/auth/rate_limit.py b/app/auth/rate_limit.py new file mode 100644 index 0000000..becd568 --- /dev/null +++ b/app/auth/rate_limit.py @@ -0,0 +1,75 @@ +import time +from dataclasses import dataclass + + +@dataclass +class _Bucket: + failures: int = 0 + locked_until: float = 0.0 + last_failure_at: float = 0.0 + + +class RateLimiter: + """In-process failed-attempt throttle with exponential backoff, keyed by an + arbitrary string (username, IP...). Single-process-only, like UserLocks + (app/tx/locks.py) — an accepted MVP constraint; a multi-worker deployment + would need a shared store (Redis) instead (B-33). + + Brute-forcing a login here isn't a spammy client to be capped at N req/s — + it's an attempt to withdraw someone else's funds — so failures are + penalized with a delay that doubles each time past `threshold` free + attempts, rather than a flat rate cap. `decay_seconds` ages a bucket back + to zero once failures stop, so a shared/NAT IP isn't punished forever for + someone else's earlier mistakes. + """ + + def __init__( + self, + threshold: int = 5, + base_delay: float = 2.0, + max_delay: float = 300.0, + decay_seconds: float = 900.0, + ) -> None: + self._threshold = threshold + self._base_delay = base_delay + self._max_delay = max_delay + self._decay_seconds = decay_seconds + self._buckets: dict[str, _Bucket] = {} + + def retry_after(self, key: str) -> float: + bucket = self._buckets.get(key) + if bucket is None: + return 0.0 + remaining = bucket.locked_until - time.monotonic() + return remaining if remaining > 0 else 0.0 + + def record_failure(self, key: str) -> None: + now = time.monotonic() + bucket = self._buckets.setdefault(key, _Bucket()) + if bucket.failures and now - bucket.last_failure_at > self._decay_seconds: + bucket.failures = 0 + bucket.failures += 1 + bucket.last_failure_at = now + if bucket.failures >= self._threshold: + delay = min(self._max_delay, self._base_delay * 2 ** (bucket.failures - self._threshold)) + bucket.locked_until = now + delay + + def record_success(self, key: str) -> None: + self._buckets.pop(key, None) + + +class AuthRateLimiters: + """The three throttles B-33 needs, bundled so they can live on `app.state` + (like `UserLocks`, see app/tx/locks.py) rather than as module globals. + + A module global would persist for the lifetime of the process — fine in + production (one app instance), but wrong in the test suite, where every + test builds its own FastAPI app against a fresh in-memory DB and expects a + clean slate; a shared global would leak failure counts between unrelated + tests. Per-`app.state` state gets a fresh instance per app automatically. + """ + + def __init__(self) -> None: + self.login = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0) + self.login_ip = RateLimiter(threshold=20, base_delay=2.0, max_delay=300.0) + self.register_ip = RateLimiter(threshold=5, base_delay=5.0, max_delay=600.0) diff --git a/app/auth/routes.py b/app/auth/routes.py index a744346..3abfbf4 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -5,6 +5,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import http_error +from app.auth.rate_limit import AuthRateLimiters from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password from app.db.models import User from app.db.session import get_session @@ -15,6 +16,43 @@ router = APIRouter(prefix="/auth", tags=["auth"]) _MAX_REGISTER_RETRIES = 5 +def _rate_limiters(request: Request) -> AuthRateLimiters: + # B-33: no rate limiting on login was a brute-forceable path to withdrawing + # someone else's funds. Keyed per-username *and* per-IP so an attacker can't + # dodge the throttle by spraying one password across many accounts, nor by + # routing one account's guesses through many IPs alone (the username key + # still catches that). The IP limiter's threshold is deliberately higher + # than the username one: a single account should lock out fast, but a + # shared/NAT IP hosting several genuine users shouldn't be punished for one + # of them mistyping a password a few times. Registration gets its own, + # coarser limiter, IP-only — no username exists yet to key on — mainly to + # bound how many accounts one IP can spin up (B-31), not to protect a + # secret. Lives on app.state (see AuthRateLimiters) rather than a module + # global so each app instance gets its own, isolated throttle state. + if not hasattr(request.app.state, "auth_rate_limiters"): + request.app.state.auth_rate_limiters = AuthRateLimiters() + return request.app.state.auth_rate_limiters + + +def _client_ip(request: Request) -> str: + # Caddy (see Caddyfile) reverse-proxies every request, so request.client.host + # is the proxy's address, not the caller's — fall back to it only if the + # header is somehow missing (e.g. hitting the app container directly). + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def _rate_limited_error(retry_after: float): + return http_error( + status.HTTP_429_TOO_MANY_REQUESTS, + "rate_limited", + "too many attempts, try again later", + retry_after_seconds=int(retry_after) + 1, + ) + + class RegisterRequest(BaseModel): """Registration used to accept an empty username and a one-character password, while /users/me/change-password demanded 8 characters — an odd place to be @@ -34,6 +72,13 @@ class TokenResponse(BaseModel): async def register( body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session) ) -> TokenResponse: + limiters = _rate_limiters(request) + ip_key = f"ip:{_client_ip(request)}" + retry_after = limiters.register_ip.retry_after(ip_key) + if retry_after > 0: + raise _rate_limited_error(retry_after) + limiters.register_ip.record_failure(ip_key) + existing = await session.scalar(select(User).where(User.username == body.username)) if existing is not None: raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken") @@ -81,8 +126,26 @@ class LoginRequest(BaseModel): @router.post("/login", response_model=TokenResponse) -async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)) -> TokenResponse: +async def login( + body: LoginRequest, request: Request, session: AsyncSession = Depends(get_session) +) -> TokenResponse: + limiters = _rate_limiters(request) + username_key = f"user:{body.username.lower()}" + ip_key = f"ip:{_client_ip(request)}" + retry_after = max(limiters.login.retry_after(username_key), limiters.login_ip.retry_after(ip_key)) + if retry_after > 0: + raise _rate_limited_error(retry_after) + user = await session.scalar(select(User).where(User.username == body.username)) if user is None or not verify_password(body.password, user.password_hash): + # Same code path (and therefore the same response) whether the username + # doesn't exist or the password is wrong — no enumeration oracle here. + limiters.login.record_failure(username_key) + limiters.login_ip.record_failure(ip_key) raise http_error(status.HTTP_401_UNAUTHORIZED, "invalid_credentials", "invalid credentials") + + # Only the username bucket resets on success — the IP bucket is left to decay + # on its own, so one correct login can't be used to wipe out an IP's failure + # count while it's mid-attack against other accounts. + limiters.login.record_success(username_key) return TokenResponse(access_token=create_access_token(user.id), address=user.address) diff --git a/app/static/i18n.js b/app/static/i18n.js index d83645f..96bfa9f 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -137,6 +137,7 @@ const TRANSLATIONS = { '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.', + 'error.rate_limited': 'Too many attempts, please try again in {retry_after_seconds} seconds.', 'loading.creating': 'Creating…', 'loading.loggingIn': 'Logging in…', @@ -274,6 +275,7 @@ const TRANSLATIONS = { '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.', + 'error.rate_limited': 'Troppi tentativi, riprova tra {retry_after_seconds} secondi.', 'loading.creating': 'Creazione…', 'loading.loggingIn': 'Accesso…', @@ -411,6 +413,7 @@ const TRANSLATIONS = { '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.', + 'error.rate_limited': 'Demasiados intentos, inténtalo de nuevo en {retry_after_seconds} segundos.', 'loading.creating': 'Creando…', 'loading.loggingIn': 'Entrando…', @@ -548,6 +551,7 @@ const TRANSLATIONS = { '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.", + 'error.rate_limited': 'Trop de tentatives, réessayez dans {retry_after_seconds} secondes.', 'loading.creating': 'Création…', 'loading.loggingIn': 'Connexion…', @@ -685,6 +689,7 @@ const TRANSLATIONS = { '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.', + 'error.rate_limited': 'Zu viele Versuche, bitte versuche es in {retry_after_seconds} Sekunden erneut.', 'loading.creating': 'Wird erstellt…', 'loading.loggingIn': 'Anmeldung…', @@ -822,6 +827,7 @@ const TRANSLATIONS = { 'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.', 'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.', 'error.guide_unavailable': 'Руководство сейчас недоступно.', + 'error.rate_limited': 'Слишком много попыток, повторите через {retry_after_seconds} сек.', 'loading.creating': 'Создание…', 'loading.loggingIn': 'Вход…', @@ -959,6 +965,7 @@ const TRANSLATIONS = { 'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。', 'error.internal_error': '服务器发生意外错误,请稍后重试。', 'error.guide_unavailable': '指南当前不可用。', + 'error.rate_limited': '尝试次数过多,请在 {retry_after_seconds} 秒后重试。', 'loading.creating': '正在创建…', 'loading.loggingIn': '正在登录…', diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000..e1d33e1 --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,126 @@ +import pytest +from cryptography.fernet import Fernet +from httpx import ASGITransport, AsyncClient + +from app.config import settings + + +@pytest.fixture +async def client(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db") + monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret") + monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode()) + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + + import app.wallet.hd as hd + + hd._account_key = None + hd.generate_master_key() + + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app.db import base as db_base + + import app.db.models # noqa: F401 + + db_base.engine = create_async_engine(settings.database_url) + db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False) + + from app.db import session as db_session + + db_session.AsyncSessionLocal = db_base.AsyncSessionLocal + + async with db_base.engine.begin() as conn: + await conn.run_sync(db_base.Base.metadata.create_all) + + from fastapi import FastAPI + + from app.auth.routes import router as auth_router + from app.electrum.listener import ElectrumListener + + app = FastAPI() + app.include_router(auth_router) + app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + await db_base.engine.dispose() + + +async def _register(client, username="alice", password="original-password"): + resp = await client.post("/auth/register", json={"username": username, "password": password}) + assert resp.status_code == 201 + return resp.json()["access_token"] + + +async def test_login_locks_out_after_repeated_failures(client): + await _register(client) + + for _ in range(5): + resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"}) + assert resp.status_code == 401 + + resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"}) + assert resp.status_code == 429 + assert resp.json()["detail"]["code"] == "rate_limited" + + # Even the *correct* password is refused while locked out — the throttle + # protects against a lucky guess landing inside the backoff window too. + resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"}) + assert resp.status_code == 429 + + +async def test_unknown_username_and_wrong_password_share_a_bucket_and_response(client): + await _register(client, username="bob") + + for _ in range(5): + resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"}) + assert resp.status_code == 401 + assert resp.json()["detail"]["code"] == "invalid_credentials" + + resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"}) + assert resp.status_code == 429 + + +async def test_login_failures_against_one_account_do_not_lock_out_another(client): + await _register(client, username="alice") + await _register(client, username="carol", password="carols-password") + + for _ in range(6): + await client.post("/auth/login", json={"username": "alice", "password": "wrong"}) + + # Different username, but same IP (the test client always looks the same) — + # only the per-username bucket should be exhausted, not the whole IP, since + # the per-username threshold (5) is hit well before the shared IP bucket's. + resp = await client.post("/auth/login", json={"username": "carol", "password": "carols-password"}) + assert resp.status_code == 200 + + +async def test_successful_login_resets_the_username_bucket(client): + await _register(client) + + for _ in range(4): + resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"}) + assert resp.status_code == 401 + + resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"}) + assert resp.status_code == 200 + + resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"}) + assert resp.status_code == 200 + + +async def test_registration_is_rate_limited_per_ip(client): + for i in range(5): + resp = await client.post( + "/auth/register", json={"username": f"user{i}", "password": "a-strong-password"} + ) + assert resp.status_code == 201 + + resp = await client.post( + "/auth/register", json={"username": "user5", "password": "a-strong-password"} + ) + assert resp.status_code == 429 + assert resp.json()["detail"]["code"] == "rate_limited" From 739fc9fed217ec6008e353db5afe355f350b7e12 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 12:02:23 +0200 Subject: [PATCH 13/29] Invalidate existing sessions on password change/reset (B-34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither self-service password change nor the admin reset invalidated already-issued JWTs — a 24h-lifetime token stayed valid regardless, so a stolen token (or an attacker who already had the old password) kept working past a password change meant to lock them out. The admin reset exists precisely for the "account compromised" case and didn't evict the attacker at all. Add User.token_version (migration 943dbd74d983), embedded in every JWT as a "tv" claim and checked against the DB on every request in get_current_user/get_optional_user; a mismatch reads as session_expired. Both change-password and the admin reset bump it. change-password hands back a freshly minted token so the caller's own session keeps working instead of being logged out by its own request; the admin reset does not, since that session isn't the one making the call. Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 26 +++--------- app/api/routes/admin.py | 5 +++ app/api/routes/users.py | 18 +++++++-- app/auth/dependencies.py | 15 +++++-- app/auth/routes.py | 8 +++- app/auth/security.py | 16 ++++++-- app/db/models.py | 6 +++ app/static/app.js | 7 +++- ...943dbd74d983_add_token_version_to_users.py | 40 +++++++++++++++++++ tests/unit/test_admin.py | 4 ++ tests/unit/test_security.py | 17 +++++++- tests/unit/test_users.py | 33 ++++++++++++++- 12 files changed, 159 insertions(+), 36 deletions(-) create mode 100644 migrations/versions/943dbd74d983_add_token_version_to_users.py diff --git a/BUGS.md b/BUGS.md index 63a0639..4fd8552 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-33 … B-49. B-25 through B-33 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 16 are High/Medium/Low. +7 medium, 8 low), listed below as B-33 … B-49. B-25 through B-34 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 15 are High/Medium/Low. 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 nine fixes so far brought the suite -from 139 to 192). +coverage — every fix lands with a regression test (the ten fixes so far brought the suite +from 139 to 194). 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. @@ -18,21 +18,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD --- -## High - -### B-34 — Password change and admin reset do not invalidate existing sessions - -Neither `/users/me/change-password` nor `/admin/users/{id}/reset-password` invalidates -already-issued JWTs (24h default lifetime, no revocation, no `token_version` on the user). The -admin reset exists precisely for the "account compromised" case and **does not evict the -attacker**. - -**Proposed fix.** Add a `token_version` (or `password_changed_at`) column on `User`, embed it -in the JWT claims, and reject any token whose value is stale in -`auth/dependencies.py:get_current_user`. Bump it on both endpoints. - ---- - ## Medium ### B-35 — Every API timestamp is naive, so the frontend renders it in the wrong timezone @@ -212,9 +197,10 @@ already does. - **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-32** — an RBF bump's fee delta could fall below BIP125's relay-mandated minimum, so the node rejected it and the same tick retried identically forever; also had no ceiling on how high the fee rate could climb - **B-33** — `POST /auth/login` had no rate limiting on a custodial wallet, so a patient distributed attack could brute-force a password against an enumerable username list; fixed with per-username *and* per-IP exponential backoff (`app/auth/rate_limit.py`), registration throttled per-IP too (also bounds B-31's attacker-controlled user count) +- **B-34** — neither self-service password change nor the admin reset invalidated already-issued JWTs, so a stolen token (or an attacker's own session) survived a password change meant to lock it out; fixed with a `User.token_version` column embedded in every JWT (`"tv"` claim) and checked on every request in `get_current_user`/`get_optional_user`, bumped on both endpoints — change-password hands back a fresh token so the caller's own session keeps working, the admin reset does not 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 fixes). Suite grew from 139 to 192 tests over the nine. +B-28/B-29/B-30/B-31/B-32/B-33/B-34 fixes). Suite grew from 139 to 194 tests over the ten. 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, diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index d4f036c..246b624 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -215,6 +215,11 @@ async def reset_user_password( new_password = secrets.token_urlsafe(12) user.password_hash = hash_password(new_password) + # B-34: this endpoint exists precisely for the "account compromised" case — + # without bumping token_version, whoever was already logged in (the + # attacker, if that's who prompted the reset) stayed logged in on their + # existing token until it naturally expired, unaffected by the reset. + user.token_version += 1 await write_audit_log(session, "admin_password_reset", {"user_id": user_id}, user_id=user_id) await session.commit() return AdminPasswordResetResponse(username=user.username, new_password=new_password) diff --git a/app/api/routes/users.py b/app/api/routes/users.py index f467f92..8188569 100644 --- a/app/api/routes/users.py +++ b/app/api/routes/users.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import http_error from app.auth.dependencies import get_current_user -from app.auth.security import MIN_PASSWORD_LENGTH, hash_password, verify_password +from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password from app.db.models import Round, RoundParticipant, User from app.db.session import get_session from app.wallet.balance import compute_pending_balance @@ -45,12 +45,16 @@ class ChangePasswordRequest(BaseModel): new_password: str -@router.post("/me/change-password", status_code=status.HTTP_204_NO_CONTENT) +class ChangePasswordResponse(BaseModel): + access_token: str + + +@router.post("/me/change-password", response_model=ChangePasswordResponse) async def change_password( body: ChangePasswordRequest, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), -) -> None: +) -> ChangePasswordResponse: """Self-service password change — requires the current password, unlike the admin-only /admin/users/{id}/reset-password (which is for a user who's actually locked out and can't provide it).""" @@ -67,7 +71,15 @@ async def change_password( ) user.password_hash = hash_password(body.new_password) + # B-34: bumping token_version invalidates every token issued before this + # point — including this very request's own bearer token, and any an + # attacker who knew the old password might be holding. A fresh token is + # handed back so *this* session keeps working without forcing a re-login; + # every other open session (this user's other devices, or an attacker's) + # gets "session_expired" on its next request. + user.token_version += 1 await session.commit() + return ChangePasswordResponse(access_token=create_access_token(user.id, user.token_version)) class LastRoundResultResponse(BaseModel): diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index 04503f4..a44a15a 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -16,13 +16,19 @@ async def get_current_user( session: AsyncSession = Depends(get_session), ) -> User: try: - user_id = decode_access_token(credentials.credentials) + user_id, token_version = decode_access_token(credentials.credentials) except Exception as exc: raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "invalid token") from exc user = await session.scalar(select(User).where(User.id == user_id)) if user is None: raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "user not found") + if user.token_version != token_version: + # B-34: a password change (self-service or admin reset) bumps + # token_version, so a token issued before it — including one an + # attacker who had the old password is still holding — reads as + # expired rather than staying valid until it naturally times out. + raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "token has been superseded") return user @@ -37,7 +43,10 @@ async def get_optional_user( if not auth_header.startswith("Bearer "): return None try: - user_id = decode_access_token(auth_header.removeprefix("Bearer ")) + user_id, token_version = decode_access_token(auth_header.removeprefix("Bearer ")) except Exception: return None - return await session.scalar(select(User).where(User.id == user_id)) + user = await session.scalar(select(User).where(User.id == user_id)) + if user is None or user.token_version != token_version: + return None + return user diff --git a/app/auth/routes.py b/app/auth/routes.py index 3abfbf4..8564e29 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -111,7 +111,9 @@ async def register( continue await session.refresh(user) request.app.state.electrum_listener.address_for_new_user(user.id, user.address) - return TokenResponse(access_token=create_access_token(user.id), address=user.address) + return TokenResponse( + access_token=create_access_token(user.id, user.token_version), address=user.address + ) raise http_error( status.HTTP_409_CONFLICT, @@ -148,4 +150,6 @@ async def login( # on its own, so one correct login can't be used to wipe out an IP's failure # count while it's mid-attack against other accounts. limiters.login.record_success(username_key) - return TokenResponse(access_token=create_access_token(user.id), address=user.address) + return TokenResponse( + access_token=create_access_token(user.id, user.token_version), address=user.address + ) diff --git a/app/auth/security.py b/app/auth/security.py index 30deb23..6b83de4 100644 --- a/app/auth/security.py +++ b/app/auth/security.py @@ -39,12 +39,20 @@ def verify_password(password: str, password_hash: str) -> bool: return False -def create_access_token(user_id: int) -> str: +def create_access_token(user_id: int, token_version: int = 0) -> str: expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) - payload = {"sub": str(user_id), "exp": expires_at} + # "tv" lets get_current_user (app/auth/dependencies.py) reject a token issued + # before the account's password was last changed (B-34): change-password and + # the admin reset both bump User.token_version, so every token that still + # carries the old value stops working immediately instead of staying valid + # for up to jwt_expire_minutes after a compromise is supposedly handled. + payload = {"sub": str(user_id), "tv": token_version, "exp": expires_at} return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) -def decode_access_token(token: str) -> int: +def decode_access_token(token: str) -> tuple[int, int]: payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) - return int(payload["sub"]) + # .get(..., 0) covers tokens issued before "tv" existed (pre-B-34 deploy) — + # they carry no claim at all, and 0 is what a freshly migrated user's + # token_version starts at, so those sessions keep working across the deploy. + return int(payload["sub"]), int(payload.get("tv", 0)) diff --git a/app/db/models.py b/app/db/models.py index 2ceb766..47c4d7c 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -21,6 +21,12 @@ class User(Base): # Read cache only; must always be written in the same transaction as the # utxo_events rows it summarizes. Source of truth is utxo_events. cached_balance_sats: Mapped[int] = mapped_column(BigInteger, default=0) + # Embedded in every issued JWT (app/auth/security.py) and checked on every + # request (app/auth/dependencies.py:get_current_user). Bumped on a + # self-service or admin password change so every token issued before that + # point stops working immediately, instead of staying valid for up to + # jwt_expire_minutes after a compromised account's password is reset (B-34). + token_version: Mapped[int] = mapped_column(default=0, server_default="0") created_at: Mapped[datetime] = mapped_column(default=utcnow) diff --git a/app/static/app.js b/app/static/app.js index 3bba472..de2ee3a 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -749,10 +749,15 @@ async function changePassword() { await withLoading(btn, t('loading.updating'), async () => { try { - await call('POST', '/users/me/change-password', { + const data = await call('POST', '/users/me/change-password', { current_password: currentPassword, new_password: newPassword, }); + // The server just invalidated every previously issued token (B-34) — + // including the one this very request was authenticated with — and + // handed back a fresh one so this tab doesn't get logged out too. + token = data.access_token; + localStorage.setItem('plm_token', token); document.getElementById('settings-current-password').value = ''; document.getElementById('settings-new-password').value = ''; document.getElementById('settings-new-password-confirm').value = ''; diff --git a/migrations/versions/943dbd74d983_add_token_version_to_users.py b/migrations/versions/943dbd74d983_add_token_version_to_users.py new file mode 100644 index 0000000..7f9063c --- /dev/null +++ b/migrations/versions/943dbd74d983_add_token_version_to_users.py @@ -0,0 +1,40 @@ +"""add token_version to users + +Revision ID: 943dbd74d983 +Revises: 861e76aaf34c +Create Date: 2026-07-27 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '943dbd74d983' +down_revision: Union[str, Sequence[str], None] = '861e76aaf34c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # server_default backfills every existing user to 0 (their current sessions + # stay valid, since 0 also matches what already-issued tokens carry + # implicitly — see the "sub"-only tokens issued before this migration); + # dropped right after so new rows go through the ORM default instead of a + # stale constant. + op.add_column( + 'users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0') + ) + with op.batch_alter_table('users') as batch_op: + batch_op.alter_column('token_version', server_default=None) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'token_version') + # ### end Alembic commands ### diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py index 6690027..bdf8b95 100644 --- a/tests/unit/test_admin.py +++ b/tests/unit/test_admin.py @@ -205,6 +205,10 @@ async def test_admin_resets_user_password(client): assert refreshed.password_hash != old_hash assert verify_password(new_password, refreshed.password_hash) assert not verify_password("original-password", refreshed.password_hash) + # B-34: the reset must bump token_version so a session opened before + # the reset (e.g. an attacker who had the old password) is evicted + # immediately rather than staying valid until the JWT naturally expires. + assert refreshed.token_version == 1 async def test_admin_reset_password_requires_token(client): diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py index 67291a5..fa2b073 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -9,8 +9,21 @@ def test_password_hash_roundtrip(): def test_jwt_roundtrip(monkeypatch): monkeypatch.setattr(security.settings, "jwt_secret", "test-secret") - token = security.create_access_token(user_id=42) - assert security.decode_access_token(token) == 42 + token = security.create_access_token(user_id=42, token_version=3) + assert security.decode_access_token(token) == (42, 3) + + +def test_jwt_decode_defaults_token_version_for_tokens_issued_before_it_existed(monkeypatch): + """B-34: a token minted before the "tv" claim existed has no such key at + all. It must still decode — as token_version 0, matching a freshly + migrated user's starting value — rather than raising or being treated as + permanently stale.""" + import jwt as pyjwt + + monkeypatch.setattr(security.settings, "jwt_secret", "test-secret") + payload = {"sub": "42"} + token = pyjwt.encode(payload, "test-secret", algorithm=security.settings.jwt_algorithm) + assert security.decode_access_token(token) == (42, 0) def test_verify_password_returns_false_for_an_unparseable_hash(): diff --git a/tests/unit/test_users.py b/tests/unit/test_users.py index 994469d..475474a 100644 --- a/tests/unit/test_users.py +++ b/tests/unit/test_users.py @@ -81,7 +81,8 @@ async def test_change_password_updates_login(client): headers=headers, json={"current_password": "original-password", "new_password": "brand-new-password"}, ) - assert resp.status_code == 204 + assert resp.status_code == 200 + assert resp.json()["access_token"] resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"}) assert resp.status_code == 401 @@ -90,6 +91,36 @@ async def test_change_password_updates_login(client): assert resp.status_code == 200 +async def test_change_password_invalidates_the_old_token_but_not_the_new_one(client): + """B-34: neither self-service change-password nor the admin reset used to + invalidate already-issued JWTs, so a stolen token (or an attacker who + already had the old password) stayed logged in until the token's natural + 24h expiry — even past a password change meant to lock them out.""" + old_token = await _register(client) + old_headers = {"Authorization": f"Bearer {old_token}"} + + resp = await client.post( + "/users/me/change-password", + headers=old_headers, + json={"current_password": "original-password", "new_password": "brand-new-password"}, + ) + assert resp.status_code == 200 + new_token = resp.json()["access_token"] + assert new_token != old_token + + # The old token (what an attacker holding the old password would still + # have) is now rejected... + resp = await client.get("/users/me", headers=old_headers) + assert resp.status_code == 401 + assert resp.json()["detail"]["code"] == "session_expired" + + # ...but the freshly issued one keeps this same session working, so the + # user who just changed their own password isn't logged out too. + new_headers = {"Authorization": f"Bearer {new_token}"} + resp = await client.get("/users/me", headers=new_headers) + assert resp.status_code == 200 + + async def test_change_password_rejects_too_short(client): token = await _register(client) headers = {"Authorization": f"Bearer {token}"} From bb8b71278aa4015cbee2f538beabb562bf737ee4 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 12:20:22 +0200 Subject: [PATCH 14/29] Stamp UTC on naive API timestamps before serializing (B-35) SQLite/aiosqlite returns DateTime columns as naive even though every value is written in UTC, so a bare .isoformat() dropped the offset and the frontend's new Date() parsed it as local time. Add a shared isoformat_utc() helper and use it at every call site that was missing the fix already applied ad hoc in rounds.py. Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 32 +++++++++----------------------- CLAUDE.md | 2 +- app/api/routes/admin.py | 11 ++++++----- app/api/routes/users.py | 3 ++- app/api/timeutil.py | 17 +++++++++++++++++ tests/unit/test_api_timeutil.py | 21 +++++++++++++++++++++ tests/unit/test_users.py | 14 ++++++++++++++ 7 files changed, 70 insertions(+), 30 deletions(-) create mode 100644 app/api/timeutil.py create mode 100644 tests/unit/test_api_timeutil.py diff --git a/BUGS.md b/BUGS.md index 4fd8552..bc78a33 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-33 … B-49. B-25 through B-34 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 15 are High/Medium/Low. +7 medium, 8 low), listed below as B-33 … B-49. B-25 through B-35 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 14 are High/Medium/Low. 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 ten fixes so far brought the suite -from 139 to 194). +coverage — every fix lands with a regression test (the eleven fixes so far brought the suite +from 139 to 198). 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. @@ -20,21 +20,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD ## Medium -### B-35 — Every API timestamp is naive, so the frontend renders it in the wrong timezone - -Verified empirically: the `DateTime` columns carry no timezone, so SQLite returns naive -datetimes and `.isoformat()` produces `2026-07-27T06:56:47.489110` — **no `Z`**. JavaScript's -`new Date()` parses that as **local time**, so every date in `/admin` (rounds, pending -transactions, audit log, via `fmtDate` in `app/static/admin.js:50`) and `created_at` in -`/users/me` display two hours off in Italy. - -The codebase knows about this — `api/routes/rounds.py` calls `.replace(tzinfo=timezone.utc)` -on `opened_at` explicitly — but the fix was never applied systematically. - -**Proposed fix.** Make the columns `DateTime(timezone=True)` (Alembic migration) so the value -round-trips as aware, rather than patching each call site. Until then, at minimum a shared -serialization helper that stamps UTC, used by every `.isoformat()` in the API layer. - ### B-36 — `_wait_for_next_block` waits forever, with no timeout and no visibility `rounds/scheduler.py:158-161` loops until a higher block arrives. No timeout, no log, no audit @@ -195,12 +180,13 @@ already does. - **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-32** — an RBF bump's fee delta could fall below BIP125's relay-mandated minimum, so the node rejected it and the same tick retried identically forever; also had no ceiling on how high the fee rate could climb -- **B-33** — `POST /auth/login` had no rate limiting on a custodial wallet, so a patient distributed attack could brute-force a password against an enumerable username list; fixed with per-username *and* per-IP exponential backoff (`app/auth/rate_limit.py`), registration throttled per-IP too (also bounds B-31's attacker-controlled user count) -- **B-34** — neither self-service password change nor the admin reset invalidated already-issued JWTs, so a stolen token (or an attacker's own session) survived a password change meant to lock it out; fixed with a `User.token_version` column embedded in every JWT (`"tv"` claim) and checked on every request in `get_current_user`/`get_optional_user`, bumped on both endpoints — change-password hands back a fresh token so the caller's own session keeps working, the admin reset does not +- **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 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 fixes). Suite grew from 139 to 194 tests over the ten. +B-28/B-29/B-30/B-31/B-32/B-33/B-34/B-35 fixes). Suite grew from 139 to 198 tests over the eleven. 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, diff --git a/CLAUDE.md b/CLAUDE.md index 3e821ed..0f85ee1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ All 10 stages of the original build order are code-complete and unit-tested — 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-32 … B-49), of which **18 are still open** — no Critical, but High covers an RBF bump that can loop forever on a replacement the node always rejects (B-32), no brute-force protection on login (B-33), and password change/reset not invalidating existing JWTs (B-34). 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 **14 are still open** — no Critical or High remains, only Medium/Low: `_wait_for_next_block` has no timeout or visibility if a round gets stuck in `drawing` (B-36), no WAL/`busy_timeout` under five concurrent SQLite writer tasks (B-39), among others. 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 .mmd` after editing either. diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 246b624..9dde0b0 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.timeutil import isoformat_utc from app.audit.log import write_audit_log from app.auth.security import hash_password from app.config import settings @@ -163,7 +164,7 @@ async def list_users(session: AsyncSession = Depends(get_session)) -> list[Admin username=u.username, address=u.address, balance_sats=u.cached_balance_sats, - created_at=u.created_at.isoformat(), + created_at=isoformat_utc(u.created_at), ) for u in users ] @@ -253,8 +254,8 @@ async def list_rounds(session: AsyncSession = Depends(get_session), limit: int = AdminRoundResponse( id=r.id, status=r.status, - opened_at=r.opened_at.isoformat(), - closed_at=r.closed_at.isoformat() if r.closed_at else None, + opened_at=isoformat_utc(r.opened_at), + closed_at=isoformat_utc(r.closed_at), draw_block_height=r.draw_block_height, draw_block_hash=r.draw_block_hash, winner_user_id=r.winner_user_id, @@ -291,7 +292,7 @@ async def list_audit_log( payload=json.loads(e.payload_json), user_id=e.user_id, round_id=e.round_id, - created_at=e.created_at.isoformat(), + created_at=isoformat_utc(e.created_at), ) for e in entries ] @@ -333,7 +334,7 @@ async def list_pending_transactions( current_txid=p.current_txid, fee_rate_sat_vb=p.fee_rate_sat_vb, attempt_count=p.attempt_count, - broadcast_at=p.broadcast_at.isoformat(), + broadcast_at=isoformat_utc(p.broadcast_at), replaced_by_txid=p.replaced_by_txid, ) for p in entries diff --git a/app/api/routes/users.py b/app/api/routes/users.py index 8188569..a5d3758 100644 --- a/app/api/routes/users.py +++ b/app/api/routes/users.py @@ -4,6 +4,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import http_error +from app.api.timeutil import isoformat_utc from app.auth.dependencies import get_current_user from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password from app.db.models import Round, RoundParticipant, User @@ -36,7 +37,7 @@ async def me( balance_sats=user.cached_balance_sats, pending_balance_sats=pending_balance_sats, has_pending=has_pending, - created_at=user.created_at.isoformat(), + created_at=isoformat_utc(user.created_at), ) diff --git a/app/api/timeutil.py b/app/api/timeutil.py new file mode 100644 index 0000000..8f17e81 --- /dev/null +++ b/app/api/timeutil.py @@ -0,0 +1,17 @@ +from datetime import datetime, timezone + + +def isoformat_utc(dt: datetime | None) -> str | None: + """Serialize a datetime for API responses, stamping it UTC first. + + Every DateTime column is written via app.db.models.utcnow() but SQLite/aiosqlite + round-trips it as a naive datetime, so a bare .isoformat() drops the "Z"/offset + and JavaScript's `new Date()` on the frontend parses the result as local time + instead of UTC (B-35). All stored values are UTC in practice, so a naive value + can be safely stamped rather than converted. + """ + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.isoformat() diff --git a/tests/unit/test_api_timeutil.py b/tests/unit/test_api_timeutil.py new file mode 100644 index 0000000..157ec90 --- /dev/null +++ b/tests/unit/test_api_timeutil.py @@ -0,0 +1,21 @@ +from datetime import datetime, timezone + +from app.api.timeutil import isoformat_utc + + +def test_naive_datetime_is_stamped_utc(): + # SQLite/aiosqlite round-trips DateTime columns as naive even though every + # value written is UTC (app.db.models.utcnow) — this is the exact shape + # returned by the ORM after a read (B-35). + naive = datetime(2026, 7, 27, 6, 56, 47, 489110) + result = isoformat_utc(naive) + assert result == "2026-07-27T06:56:47.489110+00:00" + + +def test_aware_datetime_is_left_unchanged(): + aware = datetime(2026, 7, 27, 6, 56, 47, tzinfo=timezone.utc) + assert isoformat_utc(aware) == aware.isoformat() + + +def test_none_passes_through(): + assert isoformat_utc(None) is None diff --git a/tests/unit/test_users.py b/tests/unit/test_users.py index 475474a..03cb8e1 100644 --- a/tests/unit/test_users.py +++ b/tests/unit/test_users.py @@ -162,3 +162,17 @@ async def test_register_rejects_weak_credentials(client, payload): async def test_register_accepts_valid_credentials(client): resp = await client.post("/auth/register", json={"username": "goodname", "password": "longenough1"}) assert resp.status_code == 201 + + +async def test_me_created_at_is_utc_stamped(client): + """B-35: SQLite/aiosqlite returns DateTime columns as naive, even though every + value written is UTC (app.db.models.utcnow). A bare .isoformat() on that naive + value has no "Z"/offset, and JavaScript's `new Date()` then parses it as local + time instead of UTC.""" + token = await _register(client) + headers = {"Authorization": f"Bearer {token}"} + + resp = await client.get("/users/me", headers=headers) + assert resp.status_code == 200 + created_at = resp.json()["created_at"] + assert created_at.endswith("+00:00") or created_at.endswith("Z") From 7fa26df104ddd87ad3aba4398287c7df47eb49de Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 14:12:27 +0200 Subject: [PATCH 15/29] Make a stalled draw wait observable (B-36) _wait_for_next_block had no timeout, no log, and no audit entry: a connection that stopped advancing the tip left a round silently frozen in "drawing" with nothing in /admin to explain why. Log progress periodically, write a draw_stalled audit entry past a threshold (a few block-time multiples), and surface the wait via a new Round.drawing_started_at column, exposed as draw_waiting_since in GET /rounds/current. Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 28 +++------ CLAUDE.md | 4 +- app/api/routes/rounds.py | 6 ++ app/db/models.py | 5 ++ app/rounds/scheduler.py | 52 ++++++++++++++++- ...1509f7_add_drawing_started_at_to_rounds.py | 32 ++++++++++ tests/unit/test_rounds_route.py | 30 ++++++++++ tests/unit/test_scheduler.py | 58 ++++++++++++++++++- 8 files changed, 189 insertions(+), 26 deletions(-) create mode 100644 migrations/versions/9ef6a51509f7_add_drawing_started_at_to_rounds.py diff --git a/BUGS.md b/BUGS.md index bc78a33..7aeeda4 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,16 +1,17 @@ # 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-33 … B-49. B-25 through B-35 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 14 are High/Medium/Low. +7 medium, 8 low), listed below as B-33 … B-49. B-25 through B-36 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 13 are High/Medium/Low. 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 eleven fixes so far brought the suite -from 139 to 198). +coverage — every fix lands with a regression test (the twelve fixes so far brought the suite +from 139 to 200). 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) still has -no equivalent resume-after-restart or stall visibility (B-36). +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). For limitations that are accepted by design rather than bugs (single-shared-token admin auth, single-process assumptions, no user-facing history, etc.), see "Known gaps / TODO" in @@ -20,18 +21,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD ## Medium -### B-36 — `_wait_for_next_block` waits forever, with no timeout and no visibility - -`rounds/scheduler.py:158-161` loops until a higher block arrives. No timeout, no log, no audit -entry. If the connection dies in a way that stops the tip advancing, the round sits in -`drawing` indefinitely and **the admin panel shows nothing at all** — just a frozen state with -no explanation. - -**Proposed fix.** Log progress periodically while waiting, and past a threshold (a few -multiples of the 120s block time) write a `draw_stalled` audit entry so it surfaces in -`/admin`. Surface the wait in `GET /rounds/current` too (it already returns -`chain_tip_height`; `draw_waiting_since` would make the stall self-evident to users). - ### B-37 — Displayed balance and spendable balance diverge, and the error does not explain it After a bet the change is unconfirmed, so `cached_balance_sats` ≈ 0 while the UI shows @@ -184,9 +173,10 @@ already does. - **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` 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 fixes). Suite grew from 139 to 198 tests over the eleven. +B-28/B-29/B-30/B-31/B-32/B-33/B-34/B-35/B-36 fixes). Suite grew from 139 to 200 tests over the twelve. 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, diff --git a/CLAUDE.md b/CLAUDE.md index 0f85ee1..a18b7fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ All 10 stages of the original build order are code-complete and unit-tested — 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 **14 are still open** — no Critical or High remains, only Medium/Low: `_wait_for_next_block` has no timeout or visibility if a round gets stuck in `drawing` (B-36), no WAL/`busy_timeout` under five concurrent SQLite writer tasks (B-39), among others. 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 **13 are still open** — no Critical or High remains, only Medium/Low: no WAL/`busy_timeout` under five concurrent SQLite writer tasks (B-39), a 500-subscriber SSE cap that doubles as a cheap DoS of the realtime feature (B-38), among others. 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 .mmd` after editing either. @@ -235,7 +235,7 @@ Explicit design choices, not derivable from any single file — respect them: Accepted **by design**. For actual bugs see [BUGS.md](BUGS.md) (18 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`. That wait is also unbounded and invisible in `/admin` (B-36) — 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. - **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network). - **No user-facing history.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this. diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index 7445dea..eef8727 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -8,6 +8,7 @@ from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.timeutil import isoformat_utc from app.auth.dependencies import get_optional_user from app.db.models import RoundParticipant, User from app.db.session import get_session @@ -90,6 +91,10 @@ class CurrentRoundResponse(BaseModel): winner_amount_sats: int | None = None draw_block_height: int | None = None draw_block_hash: str | None = None + # B-36: set only while status == "drawing", so the frontend can show "still + # waiting for a block" rather than a countdown implying a bounded wait — this + # phase has no timeout, only draw_animation_seconds' cosmetic minimum. + draw_waiting_since: str | None = None chain_tip_height: int | None = None lottery_paused: bool = False user_played: bool = False @@ -168,6 +173,7 @@ async def current_round( winner_amount_sats=round_.winner_amount_sats, draw_block_height=round_.draw_block_height, draw_block_hash=round_.draw_block_hash, + draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None, chain_tip_height=chain_tip_height, lottery_paused=config.paused, user_played=user_played, diff --git a/app/db/models.py b/app/db/models.py index 47c4d7c..7750d47 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -72,6 +72,11 @@ class Round(Base): status: Mapped[str] = mapped_column(String(16), default="open") opened_at: Mapped[datetime] = mapped_column(default=utcnow) closed_at: Mapped[datetime | None] = mapped_column(default=None) + # Set once, when status flips to "drawing" (rounds/scheduler.py:_close_and_draw). + # Lets both the audit log (B-36's draw_stalled entries) and GET /rounds/current + # (draw_waiting_since) measure how long a round has been waiting on a block, + # since that wait has no timeout of its own — see _wait_for_next_block. + drawing_started_at: Mapped[datetime | None] = mapped_column(default=None) draw_block_height: Mapped[int | None] = mapped_column(default=None) draw_block_hash: Mapped[str | None] = mapped_column(String(64), default=None) seed_int: Mapped[str | None] = mapped_column(String(128), default=None) diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index e71fdb6..f7f93d4 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -30,6 +30,13 @@ _TICK_INTERVAL_SECONDS = 5 # (a dropped Electrum connection, a momentarily-empty pool) self-heals quickly. _PAYOUT_RETRY_INTERVAL_SECONDS = 60 +# B-36: _wait_for_next_block has no timeout of its own — a round can legitimately +# wait several PLM blocks (120s each) for its draw entropy, and re-waits on a +# corroboration failure. These only make an already-long wait *observable*, they +# never cut it short. +_DRAW_PROGRESS_LOG_INTERVAL_SECONDS = 60 +_DRAW_STALL_THRESHOLD_SECONDS = 360 # a few multiples of PLM's 120s block time + class RoundScheduler: """Background task implementing flowchart.mmd's DRAW subgraph: closes the @@ -138,11 +145,13 @@ class RoundScheduler: user_by_address[user.address] = user.id round_.status = "drawing" + drawing_started_at = datetime.now(timezone.utc) + round_.drawing_started_at = drawing_started_at await session.commit() broadcaster.publish() tip_at_close = self._listener.tip_height - block_height, block_hash = await self._wait_for_next_block(round_id, tip_at_close) + block_height, block_hash = await self._wait_for_next_block(round_id, tip_at_close, drawing_started_at) winner_address = draw_winner(addresses, block_hash) async with self._session_factory() as session: @@ -172,7 +181,9 @@ class RoundScheduler: logger.info("round %s: winner=%s pool=%s", round_id, winner_address, pool_amount) await self._trigger_payout(round_id) - async def _wait_for_next_block(self, round_id: int, tip_at_close: int) -> tuple[int, str]: + async def _wait_for_next_block( + self, round_id: int, tip_at_close: int, waiting_since: datetime + ) -> tuple[int, str]: """Waits for a block after tip_at_close and, before handing it back as the draw's entropy source, requires it to be corroborated by the other configured Electrum servers (B-28) — our own active connection is exactly @@ -180,9 +191,44 @@ class RoundScheduler: not enough to seed a payout. A candidate that fails corroboration is never used: this keeps waiting for a further block and tries corroborating that one instead, logging why every time so a stuck draw is visible in - /admin's audit log rather than a silent, unexplained wait.""" + /admin's audit log rather than a silent, unexplained wait. + + This wait has no timeout — it can't, since the draw's entropy genuinely + depends on a future block. B-36: what it lacked was *visibility*, so a + connection that stopped advancing the tip left the round silently frozen + in "drawing" with nothing in the logs or /admin to explain why. Progress + is now logged periodically, and past _DRAW_STALL_THRESHOLD_SECONDS a + draw_stalled audit entry is written (and re-written every threshold + interval for as long as the stall continues) so the wait shows up next + to the draw_header_corroboration_failed entries above. + """ + next_progress_log_at = waiting_since + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS) + next_stall_audit_at = waiting_since + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS) while True: while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex: + now = datetime.now(timezone.utc) + if now >= next_progress_log_at: + logger.info( + "round %s: still waiting for a block past height %s (%.0fs since drawing started)", + round_id, + tip_at_close, + (now - waiting_since).total_seconds(), + ) + next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS) + if now >= next_stall_audit_at: + async with self._session_factory() as session: + await write_audit_log( + session, + "draw_stalled", + { + "tip_at_close": tip_at_close, + "current_tip_height": self._listener.tip_height, + "elapsed_seconds": int((now - waiting_since).total_seconds()), + }, + round_id=round_id, + ) + await session.commit() + next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS) await asyncio.sleep(_TICK_INTERVAL_SECONDS) height = self._listener.tip_height block_hash = header_hex_to_block_hash(self._listener.tip_header_hex) diff --git a/migrations/versions/9ef6a51509f7_add_drawing_started_at_to_rounds.py b/migrations/versions/9ef6a51509f7_add_drawing_started_at_to_rounds.py new file mode 100644 index 0000000..3b6c0f6 --- /dev/null +++ b/migrations/versions/9ef6a51509f7_add_drawing_started_at_to_rounds.py @@ -0,0 +1,32 @@ +"""add drawing_started_at to rounds + +Revision ID: 9ef6a51509f7 +Revises: 943dbd74d983 +Create Date: 2026-07-27 12:31:09.907682 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9ef6a51509f7' +down_revision: Union[str, Sequence[str], None] = '943dbd74d983' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('rounds', sa.Column('drawing_started_at', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('rounds', 'drawing_started_at') + # ### end Alembic commands ### diff --git a/tests/unit/test_rounds_route.py b/tests/unit/test_rounds_route.py index 6a675b8..6deedad 100644 --- a/tests/unit/test_rounds_route.py +++ b/tests/unit/test_rounds_route.py @@ -134,6 +134,36 @@ async def test_jackpot_comes_from_the_participants_actual_bets(client): assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100 +async def test_draw_waiting_since_is_exposed_only_while_drawing(client): + """B-36: the "drawing" wait on a future block has no timeout, so the frontend + needs draw_waiting_since to show "still waiting" instead of implying a bounded + countdown. It must not leak for any other status, where it's meaningless.""" + from datetime import datetime, timezone + + from app.db.models import Round, RoundConfig + + ac, session_factory = client + + started_at = datetime(2026, 7, 27, 10, 0, 0) + async with session_factory() as session: + session.add(RoundConfig(fee_address="")) + session.add(Round(id=60, status="drawing", drawing_started_at=started_at)) + await session.commit() + + body = (await ac.get("/rounds/current")).json() + assert body["draw_waiting_since"] == "2026-07-27T10:00:00+00:00" + + async with session_factory() as session: + from sqlalchemy import select + + round_ = (await session.scalars(select(Round).where(Round.id == 60))).one() + round_.status = "paying_out" + await session.commit() + + body = (await ac.get("/rounds/current")).json() + assert body["draw_waiting_since"] is None + + async def test_unhandled_errors_use_the_structured_detail_shape(client): """B-24: the catch-all handler answered with a bare-string `detail`, while app/api/errors.py documents detail as {"code", "message", "params"}. Clients then diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 1d3dc97..79990b7 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -381,7 +381,9 @@ async def test_wait_for_next_block_accepts_an_immediately_corroborated_block(ses listener = CorroboratingListener(responses={101: True}) scheduler = RoundScheduler(session_factory, listener) - height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100) + height, block_hash = await scheduler._wait_for_next_block( + round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc) + ) assert height == 101 assert listener.corroboration_calls == [101] @@ -395,7 +397,9 @@ async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_ ) scheduler = RoundScheduler(session_factory, listener) - height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100) + height, block_hash = await scheduler._wait_for_next_block( + round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc) + ) assert height == 102 assert listener.corroboration_calls == [101, 102] @@ -403,3 +407,53 @@ async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_ async with session_factory() as session: events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] assert events == ["draw_header_corroboration_failed"] + + +# --- B-36: a stalled draw must be visible, not a silent frozen wait -------------- + + +class StallingListener: + """A tip that never advances until the test decides it should — used to drive + _wait_for_next_block's stall-detection past _DRAW_STALL_THRESHOLD_SECONDS + without a real 6-minute wait.""" + + def __init__(self): + self.tip_height = 100 + self.tip_header_hex = None + + async def corroborate_header(self, height: int, expected_hash: str) -> bool: + return True + + +async def test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold(session_factory, monkeypatch): + import app.rounds.scheduler as scheduler_module + + listener = StallingListener() + scheduler = RoundScheduler(session_factory, listener) + start = datetime.now(timezone.utc) + + class _FakeClock: + now = start + + def fake_now(tz=None): + return _FakeClock.now + + async def fake_sleep(seconds: float) -> None: + _FakeClock.now += timedelta(seconds=seconds) + # Past the stall threshold, but before it would repeat: unblock the wait + # by making a (corroborated) block appear, so the test terminates. + if _FakeClock.now >= start + timedelta(seconds=scheduler_module._DRAW_STALL_THRESHOLD_SECONDS + 30): + listener.tip_height = 101 + listener.tip_header_hex = "aa" + + monkeypatch.setattr(scheduler_module, "datetime", type("_D", (), {"now": staticmethod(fake_now)})) + monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep) + + height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100, waiting_since=start) + + assert height == 101 + + async with session_factory() as session: + entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all() + assert len(entries) == 1 + assert entries[0].round_id == 1 From 0b44fe632ec5cecb1348cd08a794bf728164a0b7 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 14:23:00 +0200 Subject: [PATCH 16/29] Distinguish a pending-only balance from a truly insufficient one (B-37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit request_withdrawal validated against confirmed UTXOs only and answered a flat insufficient_balance even when the requested amount was covered by the pending-inclusive balance the UI actually shows (unconfirmed change from a recent bet/withdrawal) — contradicting what the user was looking at on screen. Raise balance_pending_confirmation instead when compute_pending_balance covers the amount, carrying the pending sats in params, with its error.* string in all 7 languages. Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 34 ++++++++-------------------------- CLAUDE.md | 4 ++-- app/static/i18n.js | 7 +++++++ app/withdrawals/service.py | 19 +++++++++++++++++-- tests/unit/test_withdrawals.py | 30 ++++++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 30 deletions(-) diff --git a/BUGS.md b/BUGS.md index 7aeeda4..e90ff3a 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,42 +1,23 @@ # 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-33 … B-49. B-25 through B-36 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 13 are High/Medium/Low. +7 medium, 8 low), listed below as B-33 … B-49. B-25 through B-37 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 12 are High/Medium/Low. 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 twelve fixes so far brought the suite -from 139 to 200). +coverage — every fix lands with a regression test (the thirteen fixes so far brought the suite +from 139 to 201). 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). - -For limitations that are accepted by design rather than bugs (single-shared-token admin auth, -single-process assumptions, no user-facing history, etc.), see "Known gaps / TODO" in -[CLAUDE.md](CLAUDE.md). +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. --- ## Medium -### B-37 — Displayed balance and spendable balance diverge, and the error does not explain it - -After a bet the change is unconfirmed, so `cached_balance_sats` ≈ 0 while the UI shows -`pending_balance_sats` (the real figure). A withdrawal attempted right after validates against -**confirmed** UTXOs (`withdrawals/service.py:54-60`) and answers `insufficient_balance`. - -The user sees "1.000 PLM" on screen and is told they have no funds. The mechanism is a -documented design decision, but the error does not distinguish "you don't have the money" from -"your money is waiting to confirm" — two very different situations for whoever reads it. - -**Proposed fix.** A distinct error code (e.g. `balance_pending_confirmation`) raised when the -requested amount is covered by `pending_balance_sats` but not by the confirmed balance, -carrying the pending amount in `params`, plus its `error.*` entry in all 7 languages. The -withdrawal form should also cap/hint the max against the confirmed balance rather than the -displayed one. - ### B-38 — The 500-subscriber SSE cap is a zero-cost DoS of the realtime feature `GET /rounds/stream` requires no authentication and each connection takes a slot on a @@ -174,9 +155,10 @@ already does. - **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 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 fixes). Suite grew from 139 to 200 tests over the twelve. +B-28/B-29/B-30/B-31/B-32/B-33/B-34/B-35/B-36/B-37 fixes). Suite grew from 139 to 201 tests over the thirteen. 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, diff --git a/CLAUDE.md b/CLAUDE.md index a18b7fc..16ae980 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ All 10 stages of the original build order are code-complete and unit-tested — 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 **13 are still open** — no Critical or High remains, only Medium/Low: no WAL/`busy_timeout` under five concurrent SQLite writer tasks (B-39), a 500-subscriber SSE cap that doubles as a cheap DoS of the realtime feature (B-38), among others. 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 **12 are still open** — no Critical or High remains, only Medium/Low: no WAL/`busy_timeout` under five concurrent SQLite writer tasks (B-39), a 500-subscriber SSE cap that doubles as a cheap DoS of the realtime feature (B-38), among others. 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 .mmd` after editing either. @@ -174,7 +174,7 @@ At 120s blocks that's ~4–6 min worst case (last bet confirms right at the dead `place_bet`/`request_withdrawal` select whole UTXOs (`select_utxos`, largest-first) and mark each `spent_txid` at broadcast time, long before any confirmation. `cached_balance_sats` (`recompute_balance`) sums only confirmed, unspent UTXOs, so right after a bet it understates the real balance by the whole unconfirmed change — often far more than the amount actually moving. -`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without changing what's spendable: it decodes the raw tx of every in-flight (`pending`) bet/withdrawal for the user and adds back the outputs paying to the user's own address. `GET /users/me` returns both — `balance_sats` (confirmed only; still what withdrawal-max and spend logic use, since only confirmed UTXOs are spendable) and `pending_balance_sats` + `has_pending` (what the UI shows: green when settled, amber while pending). The gap is user-visible and currently under-explained in errors (B-37). +`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without changing what's spendable: it decodes the raw tx of every in-flight (`pending`) bet/withdrawal for the user and adds back the outputs paying to the user's own address. `GET /users/me` returns both — `balance_sats` (confirmed only; still what withdrawal-max and spend logic use, since only confirmed UTXOs are spendable) and `pending_balance_sats` + `has_pending` (what the UI shows: green when settled, amber while pending). A withdrawal whose amount is covered by the pending-inclusive balance but not the confirmed one gets `balance_pending_confirmation` instead of a flat `insufficient_balance` (B-37), so the error doesn't contradict what the user is looking at. ## Real-time updates (SSE) diff --git a/app/static/i18n.js b/app/static/i18n.js index 96bfa9f..ae25689 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -121,6 +121,7 @@ const TRANSLATIONS = { 'error.round_closing': 'The current round is closing, please try again 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.', 'error.amount_below_network_fee': 'The amount is too small to cover the network fee.', 'error.invalid_address': 'Not a valid PLM address (it must start with plm1q…).', 'error.amount_below_minimum': 'The minimum withdrawal amount is {minimum_plm} PLM.', @@ -259,6 +260,7 @@ const TRANSLATIONS = { 'error.round_closing': 'Il round corrente si sta chiudendo, riprova 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.', 'error.amount_below_network_fee': "L'importo è troppo basso per coprire la fee di rete.", 'error.invalid_address': 'Indirizzo PLM non valido (deve iniziare con plm1q…).', 'error.amount_below_minimum': "L'importo minimo di prelievo è {minimum_plm} PLM.", @@ -397,6 +399,7 @@ const TRANSLATIONS = { 'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.', '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.', 'error.amount_below_network_fee': 'El importe es demasiado pequeño para cubrir la comisión de red.', 'error.invalid_address': 'Dirección PLM no válida (debe empezar por plm1q…).', 'error.amount_below_minimum': 'El importe mínimo de retiro es {minimum_plm} PLM.', @@ -535,6 +538,7 @@ const TRANSLATIONS = { 'error.round_closing': 'Le round en cours est en train de se fermer, réessayez 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.', 'error.amount_below_network_fee': 'Le montant est trop faible pour couvrir les frais de réseau.', 'error.invalid_address': 'Adresse PLM invalide (elle doit commencer par plm1q…).', 'error.amount_below_minimum': 'Le montant minimum de retrait est de {minimum_plm} PLM.', @@ -673,6 +677,7 @@ const TRANSLATIONS = { 'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.', '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.', 'error.amount_below_network_fee': 'Der Betrag ist zu klein, um die Netzwerkgebühr zu decken.', 'error.invalid_address': 'Keine gültige PLM-Adresse (sie muss mit plm1q… beginnen).', 'error.amount_below_minimum': 'Der Mindestauszahlungsbetrag beträgt {minimum_plm} PLM.', @@ -811,6 +816,7 @@ const TRANSLATIONS = { 'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.', 'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.', 'error.insufficient_balance': 'Недостаточно средств.', + 'error.balance_pending_confirmation': 'У вас есть {pending_plm} PLM, ожидающих подтверждения — они пока недоступны для расходования.', 'error.amount_below_network_fee': 'Сумма слишком мала, чтобы покрыть комиссию сети.', 'error.invalid_address': 'Некорректный адрес PLM (он должен начинаться с plm1q…).', 'error.amount_below_minimum': 'Минимальная сумма вывода — {minimum_plm} PLM.', @@ -949,6 +955,7 @@ const TRANSLATIONS = { 'error.round_closing': '当前回合正在结束,请稍后重试。', 'error.already_betting': '你在当前回合已有一笔有效下注。', 'error.insufficient_balance': '余额不足。', + 'error.balance_pending_confirmation': '您有 {pending_plm} PLM 待确认 —— 尚不可用于支出。', 'error.amount_below_network_fee': '金额太小,不足以支付网络手续费。', 'error.invalid_address': 'PLM 地址无效(必须以 plm1q… 开头)。', 'error.amount_below_minimum': '最低提现金额为 {minimum_plm} PLM。', diff --git a/app/withdrawals/service.py b/app/withdrawals/service.py index ff520d6..84a7b6c 100644 --- a/app/withdrawals/service.py +++ b/app/withdrawals/service.py @@ -9,7 +9,7 @@ from app.electrum.client import ElectrumClient from app.rounds.config import get_round_config from app.rounds.events import broadcaster from app.wallet.address import is_valid_plm_address -from app.wallet.balance import recompute_balance +from app.wallet.balance import compute_pending_balance, recompute_balance from app.wallet.hd import derive_user_key from app.wallet.psbt_builder import ( BuiltTransaction, @@ -56,7 +56,22 @@ async def request_withdrawal( select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None)) ) ).all() - if sum(u.amount_sats for u in unspent) < amount_sats: + confirmed_sats = sum(u.amount_sats for u in unspent) + if confirmed_sats < amount_sats: + # B-37: cached_balance_sats (== confirmed_sats here) can understate the real + # balance by a whole unconfirmed change output right after a bet/withdrawal — + # the UI shows pending_balance_sats instead (compute_pending_balance), which + # can cover an amount this check would otherwise reject as flatly + # "insufficient". Distinguish "you don't have the money" from "your money + # hasn't confirmed yet" so the error doesn't contradict what the user is + # looking at on screen. + pending_inclusive_sats, has_pending = await compute_pending_balance(session, user) + if has_pending and pending_inclusive_sats >= amount_sats: + raise WithdrawalError( + "balance_pending_confirmation", + "the requested amount is covered by your pending balance, which has not confirmed yet", + pending_sats=pending_inclusive_sats - confirmed_sats, + ) raise WithdrawalError("insufficient_balance", "insufficient balance", required_sats=amount_sats) user_key = derive_user_key(user.derivation_index) diff --git a/tests/unit/test_withdrawals.py b/tests/unit/test_withdrawals.py index e882cc2..975a1ba 100644 --- a/tests/unit/test_withdrawals.py +++ b/tests/unit/test_withdrawals.py @@ -2,6 +2,7 @@ import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +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 @@ -99,6 +100,35 @@ async def test_withdrawal_rejects_insufficient_balance(session_factory): await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS) +async def test_withdrawal_distinguishes_pending_from_truly_insufficient_balance(session_factory): + """B-37: right after a bet, cached_balance_sats is ~0 because the whole funding + UTXO was spent as input and the change hasn't confirmed yet — but the UI shows + the pending-inclusive balance (compute_pending_balance), which does cover a + withdrawal of this size. The error must say "not confirmed yet", not flatly + "insufficient balance", or it contradicts what the user is looking at.""" + user_id = await _make_funded_user(session_factory, 4, 3_000_000_000) + bet_client = FakeElectrumClient() + + async with session_factory() as session: + user = await session.get(User, user_id) + await place_bet(session, bet_client, user) + + async with session_factory() as session: + user = await session.get(User, user_id) + assert user.cached_balance_sats == 0 # the whole funding UTXO was spent as input + + withdraw_client = FakeElectrumClient() + with pytest.raises(WithdrawalError) as exc_info: + # Above the withdrawal minimum (BET_AMOUNT_SATS) and covered by the + # unconfirmed change (~1_999_800_000 sats), but not by the (zero) + # confirmed balance. + await request_withdrawal(session, withdraw_client, user, EXTERNAL_ADDRESS, 1_500_000_000) + + assert exc_info.value.code == "balance_pending_confirmation" + assert exc_info.value.params["pending_sats"] > 0 + assert not withdraw_client.broadcasted + + @pytest.mark.parametrize( "address", [ From 702b37b319cbe79764a7456130e0c38c4900fa6f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 14:44:15 +0200 Subject: [PATCH 17/29] Cap SSE subscribers per client IP instead of only globally (B-38) GET /rounds/stream capped concurrent subscribers with one global counter (MAX_SUBSCRIBERS=500): anyone opening 500 connections degraded every other user to polling. The comment called it a defensive cap; it was actually the vector, since nothing stopped a single source from exhausting it alone. RoundEventBroadcaster now also tracks subscribers per client IP, capped much lower (MAX_SUBSCRIBERS_PER_IP=5). Past that cap, opening one more stream evicts that same IP's own oldest connection (woken via a new EVICTED sentinel so the SSE generator closes it promptly) rather than refusing the new one or letting one abusive IP crowd out unrelated clients under the old global-only cap. The global cap stays as a backstop against overall resource exhaustion regardless of source. Extracted client_ip() (X-Forwarded-For, since Caddy reverse-proxies every request) out of auth/routes.py into app/api/client_ip.py so the login/registration throttles (B-33) and this new per-IP cap share one definition instead of two that could drift apart. Not implemented: enforcing the connection cap at the Caddy layer itself, which the proposed fix also suggested - the standard Caddy image this project uses has no such directive without a third-party module, and building a custom image felt like a bigger, separate change than this fix warranted. Suite grows from 201 to 211 tests. BUGS.md moves B-38 to Previously fixed. --- BUGS.md | 23 +++-------- app/api/client_ip.py | 17 ++++++++ app/api/routes/rounds.py | 14 +++++-- app/auth/routes.py | 11 +----- app/rounds/events.py | 68 +++++++++++++++++++++++++------- tests/unit/test_client_ip.py | 43 ++++++++++++++++++++ tests/unit/test_round_events.py | 70 ++++++++++++++++++++++++++++++++- 7 files changed, 201 insertions(+), 45 deletions(-) create mode 100644 app/api/client_ip.py create mode 100644 tests/unit/test_client_ip.py diff --git a/BUGS.md b/BUGS.md index e90ff3a..ee2101d 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-33 … B-49. B-25 through B-37 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 12 are High/Medium/Low. +7 medium, 8 low), listed below as B-39 … B-49. B-25 through B-38 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 11 are Medium/Low. 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 thirteen fixes so far brought the suite -from 139 to 201). +coverage — every fix lands with a regression test (the fourteen fixes so far brought the suite +from 139 to 211). 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. @@ -18,18 +18,6 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume ## Medium -### B-38 — The 500-subscriber SSE cap is a zero-cost DoS of the realtime feature - -`GET /rounds/stream` requires no authentication and each connection takes a slot on a -**global** counter (`rounds/events.py:33-38`). Anyone opening 500 connections degrades every -real user to polling. The comment describes it as a defensive cap; it is in fact the vector, -not the defence. - -**Proposed fix.** Cap per client IP (and, once available, per authenticated user) rather than -globally, and evict the oldest idle subscriber instead of refusing new ones. The reverse proxy -is the right place for the connection-count limit — Caddy can enforce it before the request -reaches the app. - ### B-39 — SQLite with no WAL, no `busy_timeout`, and five concurrent writer tasks `db/base.py:6` calls `create_async_engine(settings.database_url)` with no `connect_args`, and @@ -156,9 +144,10 @@ already does. - **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 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 fixes). Suite grew from 139 to 201 tests over the thirteen. +B-28/B-29/B-30/B-31/B-32/B-33/B-34/B-35/B-36/B-37/B-38 fixes). Suite grew from 139 to 211 tests over the fourteen. 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, diff --git a/app/api/client_ip.py b/app/api/client_ip.py new file mode 100644 index 0000000..0d64756 --- /dev/null +++ b/app/api/client_ip.py @@ -0,0 +1,17 @@ +from fastapi import Request + + +def client_ip(request: Request) -> str: + """The caller's real IP, from Caddy's X-Forwarded-For (see Caddyfile) — + request.client.host would otherwise be the reverse proxy's own address, not + the caller's. Falls back to request.client.host only if the header is + somehow missing (e.g. the app container hit directly, bypassing Caddy). + + Shared by the login/registration throttles (B-33) and the SSE per-IP + subscriber cap (B-38) so the two can't drift into different notions of + "the client's IP". + """ + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index eef8727..69caf80 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -8,12 +8,13 @@ from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.client_ip import client_ip from app.api.timeutil import isoformat_utc from app.auth.dependencies import get_optional_user from app.db.models import RoundParticipant, User from app.db.session import get_session from app.rounds.config import get_round_config -from app.rounds.events import RoundEventCapacityError, broadcaster +from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster from app.rounds.service import get_active_round router = APIRouter(prefix="/rounds", tags=["rounds"]) @@ -46,9 +47,14 @@ async def round_stream(request: Request) -> Response: That's also what happens past MAX_SUBSCRIBERS (app/rounds/events.py): this returns 503 rather than opening a stream, and the browser's EventSource just retries later while the frontend keeps working off polling meanwhile. + + Concurrent streams are additionally capped per client IP (B-38): past + MAX_SUBSCRIBERS_PER_IP, opening one more evicts that IP's own oldest + connection rather than refusing the new one or letting a single source + exhaust the global cap and degrade every other user. """ try: - queue = broadcaster.subscribe() + queue = broadcaster.subscribe(client_ip(request)) except RoundEventCapacityError: return JSONResponse(status_code=503, content={"detail": "too many concurrent update streams"}) @@ -59,7 +65,9 @@ async def round_stream(request: Request) -> Response: if await request.is_disconnected(): break try: - await asyncio.wait_for(queue.get(), timeout=_SSE_DISCONNECT_CHECK_SECONDS) + item = await asyncio.wait_for(queue.get(), timeout=_SSE_DISCONNECT_CHECK_SECONDS) + if item is EVICTED: + break # this IP opened another stream past its per-IP cap yield "event: update\ndata: {}\n\n".format(json.dumps({})) ticks_since_keepalive = 0 except asyncio.TimeoutError: diff --git a/app/auth/routes.py b/app/auth/routes.py index 8564e29..4fd2cd9 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -4,6 +4,7 @@ from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from app.api.client_ip import client_ip as _client_ip from app.api.errors import http_error from app.auth.rate_limit import AuthRateLimiters from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password @@ -34,16 +35,6 @@ def _rate_limiters(request: Request) -> AuthRateLimiters: return request.app.state.auth_rate_limiters -def _client_ip(request: Request) -> str: - # Caddy (see Caddyfile) reverse-proxies every request, so request.client.host - # is the proxy's address, not the caller's — fall back to it only if the - # header is somehow missing (e.g. hitting the app container directly). - forwarded = request.headers.get("x-forwarded-for") - if forwarded: - return forwarded.split(",")[0].strip() - return request.client.host if request.client else "unknown" - - def _rate_limited_error(retry_after: float): return http_error( status.HTTP_429_TOO_MANY_REQUESTS, diff --git a/app/rounds/events.py b/app/rounds/events.py index d648e4a..c60cbab 100644 --- a/app/rounds/events.py +++ b/app/rounds/events.py @@ -1,16 +1,31 @@ import asyncio +from collections import defaultdict -# Defensive cap on concurrent SSE subscribers. Expected load is on the order of -# ~100 concurrent users; this is set well above that so it never engages under -# normal use — it exists purely so a runaway/DoS-y number of open connections -# degrades (new connections fall back to polling, see round_stream()) instead -# of growing the in-memory subscriber set without bound. Revisit this number if -# expected concurrency grows well past it. +# Defensive backstop on concurrent SSE subscribers overall, regardless of source +# — expected load is on the order of ~100 concurrent users, so this is set well +# above that. The real defense against a single abusive source is the per-IP cap +# below (B-38): a global-only cap was trivially exhausted by one client opening +# MAX_SUBSCRIBERS connections, degrading every other user to polling — the +# comment used to call it "defensive"; it was actually the vector. MAX_SUBSCRIBERS = 500 +# How many concurrent streams a single client IP may hold. Deliberately small — +# a real browser tab needs at most one, occasionally two briefly across a +# reload — since this bounds one source's share of the global capacity, not a +# legitimate per-user concurrency limit. +MAX_SUBSCRIBERS_PER_IP = 5 + +# Put on a to-be-evicted subscriber's queue (B-38) to wake its generator +# (app/api/routes/rounds.py:round_stream) promptly so it closes the connection +# instead of lingering, silently uncounted, until the client's own network +# timeout or the next keep-alive tick. +EVICTED = object() + class RoundEventCapacityError(Exception): - """Raised by subscribe() when MAX_SUBSCRIBERS is already reached.""" + """Raised by subscribe() when MAX_SUBSCRIBERS — the global backstop — is + already reached. The per-IP cap never raises this; it evicts instead (see + subscribe()).""" class RoundEventBroadcaster: @@ -26,22 +41,47 @@ class RoundEventBroadcaster: would need a shared channel (e.g. Redis pub/sub) instead. """ - def __init__(self, max_subscribers: int = MAX_SUBSCRIBERS): - self._subscribers: set[asyncio.Queue] = set() + def __init__(self, max_subscribers: int = MAX_SUBSCRIBERS, max_per_ip: int = MAX_SUBSCRIBERS_PER_IP): + self._ip_by_queue: dict[asyncio.Queue, str] = {} + self._queues_by_ip: dict[str, list[asyncio.Queue]] = defaultdict(list) self.max_subscribers = max_subscribers + self.max_per_ip = max_per_ip - def subscribe(self) -> asyncio.Queue: - if len(self._subscribers) >= self.max_subscribers: + def subscribe(self, client_ip: str = "unknown") -> asyncio.Queue: + if len(self._ip_by_queue) >= self.max_subscribers: raise RoundEventCapacityError(f"already at the {self.max_subscribers}-subscriber cap") + + ip_queues = self._queues_by_ip[client_ip] + if len(ip_queues) >= self.max_per_ip: + # B-38: evict this IP's own oldest connection rather than refusing + # the new one — bounds one source's footprint without turning a + # legitimate reconnect storm (a flaky network retrying EventSource) + # into an outright block, and without letting one abusive IP crowd + # out unrelated clients the way the old global-only cap did. + oldest = ip_queues.pop(0) + self._ip_by_queue.pop(oldest, None) + if not oldest.full(): + oldest.put_nowait(EVICTED) + queue: asyncio.Queue = asyncio.Queue(maxsize=1) - self._subscribers.add(queue) + self._ip_by_queue[queue] = client_ip + ip_queues.append(queue) return queue def unsubscribe(self, queue: asyncio.Queue) -> None: - self._subscribers.discard(queue) + client_ip = self._ip_by_queue.pop(queue, None) + if client_ip is None: + return + ip_queues = self._queues_by_ip.get(client_ip) + if ip_queues is None: + return + if queue in ip_queues: + ip_queues.remove(queue) + if not ip_queues: + self._queues_by_ip.pop(client_ip, None) def publish(self) -> None: - for queue in self._subscribers: + for queue in self._ip_by_queue: if queue.full(): continue # a not-yet-delivered notification already covers this one queue.put_nowait(None) diff --git a/tests/unit/test_client_ip.py b/tests/unit/test_client_ip.py new file mode 100644 index 0000000..acabfa4 --- /dev/null +++ b/tests/unit/test_client_ip.py @@ -0,0 +1,43 @@ +"""app.api.client_ip is shared by the login/registration throttles (B-33) and +the SSE per-IP subscriber cap (B-38) — both depend on it correctly preferring +X-Forwarded-For (Caddy reverse-proxies every request, see Caddyfile) over +request.client.host, which would otherwise be the proxy's own address.""" + +from starlette.requests import Request + +from app.api.client_ip import client_ip + + +def _request(*, forwarded: str | None = None, client_host: str | None = "127.0.0.1") -> Request: + headers = [(b"x-forwarded-for", forwarded.encode())] if forwarded else [] + scope = { + "type": "http", + "headers": headers, + "client": (client_host, 12345) if client_host else None, + } + return Request(scope) + + +def test_client_ip_prefers_x_forwarded_for(): + request = _request(forwarded="5.6.7.8", client_host="10.0.0.1") + assert client_ip(request) == "5.6.7.8" + + +def test_client_ip_takes_the_first_hop_of_a_forwarded_chain(): + request = _request(forwarded="5.6.7.8, 10.0.0.1, 172.17.0.1") + assert client_ip(request) == "5.6.7.8" + + +def test_client_ip_strips_whitespace(): + request = _request(forwarded=" 5.6.7.8 , 10.0.0.1") + assert client_ip(request) == "5.6.7.8" + + +def test_client_ip_falls_back_to_request_client_without_the_header(): + request = _request(forwarded=None, client_host="10.0.0.1") + assert client_ip(request) == "10.0.0.1" + + +def test_client_ip_falls_back_to_unknown_with_neither(): + request = _request(forwarded=None, client_host=None) + assert client_ip(request) == "unknown" diff --git a/tests/unit/test_round_events.py b/tests/unit/test_round_events.py index 6425d06..7607fe8 100644 --- a/tests/unit/test_round_events.py +++ b/tests/unit/test_round_events.py @@ -2,7 +2,7 @@ import asyncio import pytest -from app.rounds.events import RoundEventBroadcaster, RoundEventCapacityError +from app.rounds.events import EVICTED, RoundEventBroadcaster, RoundEventCapacityError async def test_publish_wakes_up_subscriber(): @@ -60,3 +60,71 @@ async def test_unsubscribe_frees_a_capacity_slot(): broadcaster.unsubscribe(queue) broadcaster.subscribe() # no longer at capacity + + +# --- B-38: a single IP must not be able to exhaust the global cap and degrade +# every other user to polling. ---------------------------------------------------- + + +async def test_subscribe_evicts_the_same_ips_oldest_connection_past_its_cap(): + """Past MAX_SUBSCRIBERS_PER_IP, one more stream from the *same* IP evicts + that IP's own oldest connection rather than being refused — bounds one + source's footprint without an outright block.""" + broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=2) + first = broadcaster.subscribe("1.2.3.4") + second = broadcaster.subscribe("1.2.3.4") + + third = broadcaster.subscribe("1.2.3.4") # past the per-IP cap of 2 + + assert await asyncio.wait_for(first.get(), timeout=1) is EVICTED + assert second.empty() # untouched — only the oldest was evicted + assert third is not None + + +async def test_subscribe_does_not_evict_across_different_ips(): + """A different IP hitting its own cap must never evict an unrelated IP's + connection — that would let one abusive source crowd out real users, which + is exactly what the global-only cap used to allow.""" + broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=1) + other_ip_queue = broadcaster.subscribe("9.9.9.9") + + broadcaster.subscribe("1.2.3.4") + broadcaster.subscribe("1.2.3.4") # evicts 1.2.3.4's own oldest, not 9.9.9.9's + + assert other_ip_queue.empty() + + +async def test_subscribe_still_enforces_the_global_cap_across_many_ips(): + """The per-IP cap doesn't replace the global backstop — spreading across + enough distinct IPs must still eventually hit MAX_SUBSCRIBERS.""" + broadcaster = RoundEventBroadcaster(max_subscribers=3, max_per_ip=1) + broadcaster.subscribe("1.1.1.1") + broadcaster.subscribe("2.2.2.2") + broadcaster.subscribe("3.3.3.3") + + with pytest.raises(RoundEventCapacityError): + broadcaster.subscribe("4.4.4.4") + + +async def test_unsubscribe_clears_the_per_ip_tracking_too(): + """Regression guard: unsubscribe must forget the queue's IP association, or + a churned-through connection would keep counting against that IP's cap + forever.""" + broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=1) + queue = broadcaster.subscribe("1.2.3.4") + broadcaster.unsubscribe(queue) + + broadcaster.subscribe("1.2.3.4") # must not evict anything — nothing left to evict + assert queue.empty() + + +async def test_subscribe_defaults_to_a_shared_ip_when_none_given(): + """Existing callers (and most tests) that don't care about IP isolation + still share one implicit bucket rather than needing every call updated.""" + broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=2) + first = broadcaster.subscribe() + broadcaster.subscribe() + + broadcaster.subscribe() # past the default bucket's cap of 2 — evicts, doesn't raise + + assert await asyncio.wait_for(first.get(), timeout=1) is EVICTED From 97545ad91fd117e302144bbbbc50ee8564c2fab2 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 14:53:22 +0200 Subject: [PATCH 18/29] Run SQLite in WAL mode with a busy_timeout (B-39) create_async_engine had no connect_args and there was no PRAGMA anywhere in the repo. SQLite's default rollback-journal mode lets a writer block every reader for the duration of its transaction, and a second writer arriving while one is already active fails immediately with "database is locked" rather than waiting at all - realistic given five concurrent background tasks (scheduler, confirmation poller, RBF bumper, two reconcilers) plus every HTTP handler share one file, and nothing previously handled that error. app/db/base.py now registers a "connect" event on the engine that sets journal_mode=WAL, synchronous=NORMAL and a 5-second busy_timeout on every new DBAPI connection - applied only when the dialect is sqlite, so a future PostgreSQL DATABASE_URL is unaffected. WAL lets readers and writers proceed without blocking each other, and busy_timeout gives a second writer a real window to wait instead of failing instantly. Left out: an explicit application-level retry wrapper for "database is locked" in the background loops, the other half of the proposed fix - busy_timeout already gives SQLite itself several seconds to resolve writer-vs-writer contention before ever raising, and every background loop already catches and logs an unhandled exception before its next scheduled tick, which is itself a retry, just not an immediate one. Suite grows from 211 to 214 tests. BUGS.md moves B-39 to Previously fixed. --- BUGS.md | 24 ++++----------- app/db/base.py | 37 ++++++++++++++++++++++- tests/unit/test_db_base.py | 61 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 19 deletions(-) create mode 100644 tests/unit/test_db_base.py diff --git a/BUGS.md b/BUGS.md index ee2101d..e738c01 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-39 … B-49. B-25 through B-38 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 11 are Medium/Low. +7 medium, 8 low), listed below as B-40 … B-49. B-25 through B-39 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 10 are Medium/Low. 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 fourteen fixes so far brought the suite -from 139 to 211). +coverage — every fix lands with a regression test (the fifteen fixes so far brought the suite +from 139 to 214). 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. @@ -18,19 +18,6 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume ## Medium -### B-39 — SQLite with no WAL, no `busy_timeout`, and five concurrent writer tasks - -`db/base.py:6` calls `create_async_engine(settings.database_url)` with no `connect_args`, and -there is no `PRAGMA` anywhere in the repo (verified by grep). Without `journal_mode=WAL` -readers block writers, and the concurrent writers are five background tasks plus every HTTP -handler. `database is locked` under load is realistic, and nothing handles it. - -**Proposed fix.** Set `journal_mode=WAL`, `synchronous=NORMAL` and a `busy_timeout` of a few -seconds on connect (a `connect` event listener on the engine, applied only for the SQLite -dialect), and retry `OperationalError: database is locked` in the background loops. Longer -term this is an argument for PostgreSQL, which the single-process constraints in CLAUDE.md -also point at. - ### B-40 — `bump_fee` holds a DB session open across N network calls `tx/broadcast.py:80` issues one `get_transaction` **per input** (up to 15s each) and then a @@ -145,9 +132,10 @@ already does. - **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 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 fixes). Suite grew from 139 to 211 tests over the fourteen. +B-28/B-29/B-30/B-31/B-32/B-33/B-34/B-35/B-36/B-37/B-38/B-39 fixes). Suite grew from 139 to 214 tests over the fifteen. 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, diff --git a/app/db/base.py b/app/db/base.py index cac5738..cbe56bc 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -1,9 +1,44 @@ -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase from app.config import settings +# How long a writer waits for a lock held by another writer before SQLite raises +# "database is locked" (B-39). A few seconds is enough to ride out this app's own +# five concurrent background tasks (scheduler, confirmation poller, RBF bumper, +# two reconcilers) plus HTTP handlers briefly overlapping a write. +_SQLITE_BUSY_TIMEOUT_MS = 5000 + + +def _register_sqlite_pragmas(target_engine: AsyncEngine) -> None: + """Without WAL, SQLite's default (rollback-journal) mode lets a writer block + every reader for the duration of its transaction, and a second writer arriving + while one is already active fails immediately rather than waiting at all — + realistic under this app's concurrency, and nothing previously handled it. + WAL lets readers and writers proceed without blocking each other, and + busy_timeout gives a second writer a real window to wait for the first + instead of an instant `OperationalError`. + + No-op for any dialect other than sqlite (e.g. a future PostgreSQL + DATABASE_URL), which neither needs nor understands these pragmas. + """ + if target_engine.dialect.name != "sqlite": + return + + @event.listens_for(target_engine.sync_engine, "connect") + def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None: + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}") + finally: + cursor.close() + + engine = create_async_engine(settings.database_url) +_register_sqlite_pragmas(engine) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) diff --git a/tests/unit/test_db_base.py b/tests/unit/test_db_base.py new file mode 100644 index 0000000..3a172c8 --- /dev/null +++ b/tests/unit/test_db_base.py @@ -0,0 +1,61 @@ +"""Regression tests for B-39: SQLite must run in WAL mode with a busy_timeout, +since this app has five concurrent background tasks plus every HTTP handler +sharing one database file, and the default rollback-journal mode lets a writer +block every reader and fails a second writer immediately instead of waiting.""" + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine + +from app.db.base import _SQLITE_BUSY_TIMEOUT_MS, _register_sqlite_pragmas + + +@pytest.fixture +async def sqlite_engine(tmp_path): + # WAL needs a real file (it writes a companion -wal/-shm file alongside it) — + # ":memory:" wouldn't exercise the same path. + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/test.db") + yield engine + await engine.dispose() + + +async def _pragma(engine, name: str): + async with engine.connect() as conn: + result = await conn.exec_driver_sql(f"PRAGMA {name}") + return result.fetchone()[0] + + +async def test_register_sqlite_pragmas_enables_wal_and_busy_timeout(sqlite_engine): + _register_sqlite_pragmas(sqlite_engine) + + assert (await _pragma(sqlite_engine, "journal_mode")).lower() == "wal" + assert await _pragma(sqlite_engine, "busy_timeout") == _SQLITE_BUSY_TIMEOUT_MS + assert await _pragma(sqlite_engine, "synchronous") == 1 # NORMAL + + +async def test_register_sqlite_pragmas_applies_to_every_new_connection(sqlite_engine): + """The pool can open more than one underlying DBAPI connection over the + engine's lifetime — the pragmas must be re-applied to each one, not just + the first, or a later connection would silently fall back to SQLite's + defaults.""" + _register_sqlite_pragmas(sqlite_engine) + + async with sqlite_engine.connect() as first: + await first.exec_driver_sql("PRAGMA journal_mode") + + async with sqlite_engine.connect() as second: + result = await second.exec_driver_sql("PRAGMA busy_timeout") + assert result.fetchone()[0] == _SQLITE_BUSY_TIMEOUT_MS + + +def test_register_sqlite_pragmas_is_a_noop_for_other_dialects(): + """Must not touch (or crash on) a non-sqlite engine — e.g. a future + PostgreSQL DATABASE_URL, which neither needs nor understands these + pragmas.""" + + class _FakeDialect: + name = "postgresql" + + class _FakeEngine: + dialect = _FakeDialect() + + _register_sqlite_pragmas(_FakeEngine()) # must not raise From 08c566d54787380b6cc5891d93c850206040f85e Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 15:14:58 +0200 Subject: [PATCH 19/29] Restructure bump_fee into three phases, drop float fee math (B-40) bump_fee issued one get_transaction per input (up to 15s each) and then a broadcast, all with the caller's DB session held open - exactly the pattern already fixed elsewhere for the same reason (B-18's _trigger_payout, B-31's refresh_user). Also, _prevout_amount computed a prevout's satoshi value via round(value_coins * 100_000_000) on a float the server reported, in a codebase that is otherwise strictly integer-satoshi. bump_fee now takes a session_factory and a pending_id instead of a live session and row, with three phases: read what's needed (the signing key, current fee rate, raw tx) and close the session before any network call; do the chain reads, signing and broadcast with no session open; reopen a session only to persist the outcome. _prevout_amount now asks for the raw (non-verbose) transaction and reads embit's parsed TransactionOutput.value directly - already an exact integer, no float conversion involved at all. A pending_transaction that's no longer "pending" by the time bump_fee actually runs (it confirmed in the meantime, a normal race) is now a quiet no-op returning None, rather than being folded into RbfBumper's error-logging path. Suite grows from 214 to 217 tests. BUGS.md moves B-40 to Previously fixed, and trims its own now-stale claim that bump_fee still depended on verbose=True (B-41) - it no longer does. --- BUGS.md | 40 +++------ app/tx/broadcast.py | 128 ++++++++++++++++----------- tests/unit/test_broadcast.py | 162 ++++++++++++++++++++++++++++++----- 3 files changed, 231 insertions(+), 99 deletions(-) diff --git a/BUGS.md b/BUGS.md index e738c01..628ad08 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-40 … B-49. B-25 through B-39 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 10 are Medium/Low. +7 medium, 8 low), listed below as B-41 … B-49. B-25 through B-40 are fixed (see "Previously +fixed" below) — no Critical-severity finding remains open; the other 9 are Medium/Low. 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 fifteen fixes so far brought the suite -from 139 to 214). +coverage — every fix lands with a regression test (the sixteen fixes so far brought the suite +from 139 to 217). 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. @@ -18,28 +18,15 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume ## Medium -### B-40 — `bump_fee` holds a DB session open across N network calls +### B-41 — Confirmation/reconciliation logic depends on `verbose=True`, which is not universally supported -`tx/broadcast.py:80` issues one `get_transaction` **per input** (up to 15s each) and then a -`broadcast`, all with the session open. This is precisely the pattern B-18 removed from -`_trigger_payout` via its three-phase structure; it survives here. - -Side note in the same function: `_prevout_amount` does `round(value_coins * 100_000_000)` on a -float from the server — acceptable at these magnitudes, but it is floating-point money -arithmetic in a codebase that is otherwise strictly integer-satoshi. - -**Proposed fix.** Restructure into the same three phases: read what is needed and close the -session, do the chain work, then reopen to persist. For the float: prefer the raw (non-verbose) -transaction and parse the output value as an integer with `embit`, which is what -`reconcile.py:_release_inputs` already does for inputs. - -### B-41 — All confirmation logic depends on `verbose=True`, which is not universally supported - -`poll_once`, `reconcile._tx_exists_on_chain` and `bump_fee` all call -`blockchain.transaction.get(txid, True)`. Several Electrum server implementations and versions -reject the verbose flag ("verbose transactions are currently unsupported"). Falling back onto -such a server means **no confirmations, no reconciliation, no bumps** — and the code would read -that as a transport error and stay silent. +`poll_once` and `reconcile._tx_exists_on_chain` call `blockchain.transaction.get(txid, True)`. +Several Electrum server implementations and versions reject the verbose flag ("verbose +transactions are currently unsupported"). Falling back onto such a server means **no +confirmations, no reconciliation** — and the code would read that as a transport error and stay +silent. (`bump_fee`'s own `verbose=True` call was removed as part of the B-40 fix — it now reads +the raw transaction and parses the output value with `embit` instead, so bumps are unaffected by +this finding.) Related: `reconcile.py:83` decides whether to **abandon a transaction** by substring-matching the error text (`"missing"`, `"not found"`, `"no such"`, `"unknown"`). It works against @@ -133,9 +120,10 @@ already does. - **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 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 fixes). Suite grew from 139 to 214 tests over the fifteen. +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 fixes). Suite grew from 139 to 217 tests over the sixteen. 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, diff --git a/app/tx/broadcast.py b/app/tx/broadcast.py index c31ea60..4540a99 100644 --- a/app/tx/broadcast.py +++ b/app/tx/broadcast.py @@ -48,14 +48,14 @@ def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds) -async def _signing_context(session: AsyncSession, pending: PendingTransaction) -> tuple: +async def _signing_context(session: AsyncSession, kind: str, user_id: int | None) -> tuple: """Returns (signing_key, own_script, own_address) for the single sender that controls every input of this tx — a user for bet/withdrawal, the pool for payout. All our builders only ever spend one address's UTXOs per tx.""" - if pending.kind == "payout": + if kind == "payout": key = derive_pool_key() else: - user = await session.get(User, pending.user_id) + user = await session.get(User, user_id) key = derive_user_key(user.derivation_index) own_script = script.p2wpkh(key.to_public()) own_address = own_script.address(network=PLM_MAINNET) @@ -63,10 +63,19 @@ async def _signing_context(session: AsyncSession, pending: PendingTransaction) - async def _prevout_amount(client: ElectrumClient, vin: TransactionInput) -> int: + """The exact integer satoshi value of the output this input spends. + + Parsed directly from the raw transaction via embit rather than asking the + server for its own float, whole-coin-denominated "value" field (verbose=True) + and converting with `* 100_000_000` — embit's TransactionOutput.value is + already an integer number of satoshis straight from the tx's binary + encoding, so this never touches floating point in a codebase that is + otherwise strictly integer-satoshi (B-40). + """ txid_hex = vin.txid.hex() - tx = await client.get_transaction(txid_hex, verbose=True) - value_coins = tx["vout"][vin.vout]["value"] - return round(value_coins * 100_000_000) + raw_hex = await client.get_transaction(txid_hex, verbose=False) + prevout_tx = Transaction.parse(bytes.fromhex(raw_hex)) + return prevout_tx.vout[vin.vout].value def _find_change_output(tx: Transaction, change_address: str) -> int | None: @@ -76,35 +85,55 @@ def _find_change_output(tx: Transaction, change_address: str) -> int | None: return None -async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: PendingTransaction) -> str: - """Rebuild `pending`'s transaction with a higher fee (same inputs, same - recipient outputs, the extra fee taken from the change output) and - rebroadcast. Returns the new txid. +async def bump_fee( + session_factory: async_sessionmaker, client: ElectrumClient, pending_id: int +) -> str | None: + """Rebuild pending_transaction `pending_id`'s transaction with a higher fee + (same inputs, same recipient outputs, the extra fee taken from the change + output) and rebroadcast. Returns the new txid, or None if there was nothing + to do (the row is gone or already left "pending" — a normal race with + confirmation, not an error). + + Three phases, so no DB session is held across the network calls this needs + (one get_transaction per input, then a broadcast) — the same shape used + elsewhere for exactly this reason (B-18, rounds/scheduler.py:_trigger_payout; + B-31, electrum/listener.py:refresh_user) and now here too (B-40): read what's + needed and close the session, do the chain work, then reopen to persist. Only handles the common case: exactly one change output paying back to the tx's own sender address, large enough to absorb the increase. If there's no such output (e.g. an exact-amount bet with no change), this raises RbfError — bumping such a tx would require selecting additional inputs, which isn't implemented for the MVP; it needs manual operator intervention. Also raises - RbfError, rather than bumping, once `pending` is already at MAX_FEE_RATE_SAT_VB + RbfError, rather than bumping, once the row is already at MAX_FEE_RATE_SAT_VB (B-32) — the reconciler abandons it if it never confirms (B-27), instead of this retrying an ever-higher fee forever. """ - if pending.fee_rate_sat_vb >= MAX_FEE_RATE_SAT_VB: - raise RbfError( - f"pending_transaction {pending.id}: already at the maximum fee rate " - f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further" - ) + # --- Phase 1: read what's needed, close the session before any network call --- + async with session_factory() as session: + pending = await session.get(PendingTransaction, pending_id) + if pending is None or pending.status != "pending": + logger.info("pending_transaction %s no longer pending; skipping bump", pending_id) + return None + if pending.fee_rate_sat_vb >= MAX_FEE_RATE_SAT_VB: + raise RbfError( + f"pending_transaction {pending_id}: already at the maximum fee rate " + f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further" + ) - old_tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex)) - signing_key, own_script, own_address = await _signing_context(session, pending) + kind = pending.kind + current_fee_rate = pending.fee_rate_sat_vb + raw_tx_hex = pending.raw_tx_hex + signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id) + # --- Phase 2: chain reads, signing, and the broadcast — no DB session open ---- + old_tx = Transaction.parse(bytes.fromhex(raw_tx_hex)) input_amounts = [await _prevout_amount(client, vin) for vin in old_tx.vin] total_in = sum(input_amounts) old_fee = total_in - sum(o.value for o in old_tx.vout) vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout)) - target_fee_rate = min(pending.fee_rate_sat_vb + _FEE_RATE_INCREMENT, MAX_FEE_RATE_SAT_VB) + target_fee_rate = min(current_fee_rate + _FEE_RATE_INCREMENT, MAX_FEE_RATE_SAT_VB) target_fee = vsize * target_fee_rate # BIP125 rule 4's minimum, in absolute sats for this tx's size — the floor # `fee_delta` must never go below, no matter what `target_fee - old_fee` comes @@ -120,7 +149,7 @@ async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: Pendi change_index = _find_change_output(old_tx, own_address) if change_index is None or old_tx.vout[change_index].value <= fee_delta: - raise RbfError(f"pending_transaction {pending.id}: no change output large enough to absorb a fee bump") + raise RbfError(f"pending_transaction {pending_id}: no change output large enough to absorb a fee bump") new_vout = list(old_tx.vout) bumped_change = new_vout[change_index].value - fee_delta @@ -144,25 +173,28 @@ async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: Pendi new_txid = final_tx.txid().hex() await client.broadcast(raw_hex) - old_txid = pending.current_txid - pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced - pending.current_txid = new_txid - pending.raw_tx_hex = raw_hex - # The *actual* resulting rate, not target_fee_rate: when the BIP125-minimum - # floor above raised fee_delta past the naive target, the tx now pays more - # than target_fee_rate implied. Recording the true rate keeps the next bump's - # arithmetic honest instead of drifting from what's really being paid. - pending.fee_rate_sat_vb = (old_fee + fee_delta) // vsize - pending.attempt_count += 1 - # last_broadcast_at, not broadcast_at (B-27): broadcast_at must stay the *first* - # broadcast, since reconcile.py's abandon-after-N-hours grace period is measured - # from it — overwriting it here used to reset that clock on every bump, so a - # repeatedly-bumped-but-never-mined tx was never abandoned. - pending.last_broadcast_at = datetime.now(timezone.utc) - await _retarget_txid_references(session, pending, old_txid, new_txid) - await session.commit() + # --- Phase 3: persist the outcome ---------------------------------------------- + async with session_factory() as session: + pending = await session.get(PendingTransaction, pending_id) + old_txid = pending.current_txid + pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced + pending.current_txid = new_txid + pending.raw_tx_hex = raw_hex + # The *actual* resulting rate, not target_fee_rate: when the BIP125-minimum + # floor above raised fee_delta past the naive target, the tx now pays more + # than target_fee_rate implied. Recording the true rate keeps the next bump's + # arithmetic honest instead of drifting from what's really being paid. + pending.fee_rate_sat_vb = (old_fee + fee_delta) // vsize + pending.attempt_count += 1 + # last_broadcast_at, not broadcast_at (B-27): broadcast_at must stay the *first* + # broadcast, since reconcile.py's abandon-after-N-hours grace period is measured + # from it — overwriting it here used to reset that clock on every bump, so a + # repeatedly-bumped-but-never-mined tx was never abandoned. + pending.last_broadcast_at = datetime.now(timezone.utc) + await _retarget_txid_references(session, pending, old_txid, new_txid) + await session.commit() - logger.info("bumped %s pending_transaction %s: %s -> %s", pending.kind, pending.id, old_txid, new_txid) + logger.info("bumped %s pending_transaction %s: %s -> %s", kind, pending_id, old_txid, new_txid) return new_txid @@ -230,16 +262,12 @@ class RbfBumper: candidates = ( await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending")) ).all() - due = [p for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)] + due_ids = [p.id for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)] - for pending in due: - async with self._session_factory() as session: - row = await session.get(PendingTransaction, pending.id) - if row is None or row.status != "pending": - continue - try: - await bump_fee(session, client, row) - except RbfError: - logger.exception("could not bump pending_transaction %s", row.id) - except Exception: - logger.exception("unexpected error bumping pending_transaction %s", row.id) + for pending_id in due_ids: + try: + await bump_fee(self._session_factory, client, pending_id) + except RbfError: + logger.exception("could not bump pending_transaction %s", pending_id) + except Exception: + logger.exception("unexpected error bumping pending_transaction %s", pending_id) diff --git a/tests/unit/test_broadcast.py b/tests/unit/test_broadcast.py index e4ea562..73bc59f 100644 --- a/tests/unit/test_broadcast.py +++ b/tests/unit/test_broadcast.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone import pytest from embit import script from embit.bip32 import HDKey -from embit.transaction import Transaction +from embit.transaction import Transaction, TransactionInput, TransactionOutput from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.config import settings @@ -58,12 +58,23 @@ def test_should_bump_measures_from_last_broadcast_not_first(monkeypatch): class FakeClient: + """B-40: _prevout_amount now asks for the raw (non-verbose) transaction and + reads its output value as an integer via embit, rather than a verbose reply's + float "value" field — so this fake must hand back a real, parseable raw tx + whose vout[0] carries the requested amount (every test here spends vout 0 of + its fixture UTXO).""" + def __init__(self, prevout_values: dict[str, int]): self._prevout_values = prevout_values self.broadcasted: list[str] = [] - async def get_transaction(self, txid: str, verbose: bool = False) -> dict: - return {"vout": {0: {"value": self._prevout_values[txid] / 100_000_000}}} + async def get_transaction(self, txid: str, verbose: bool = False) -> str: + assert verbose is False + fake_prevout_tx = Transaction( + vin=[TransactionInput(b"\x00" * 32, 0)], + vout=[TransactionOutput(self._prevout_values[txid], script.Script(b"\x00\x14" + b"\x00" * 20))], + ) + return fake_prevout_tx.serialize().hex() async def broadcast(self, raw_tx_hex: str) -> str: self.broadcasted.append(raw_tx_hex) @@ -130,9 +141,7 @@ async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory): client = FakeClient({utxo_txid: utxo_amount}) - async with session_factory() as session: - row = await session.get(PendingTransaction, pending_id) - new_txid = await bump_fee(session, client, row) + new_txid = await bump_fee(session_factory, client, pending_id) assert client.broadcasted assert new_txid != built.txid @@ -197,9 +206,7 @@ async def test_bump_fee_leaves_broadcast_at_untouched(session_factory): client = FakeClient({utxo_txid: utxo_amount}) before_bump = datetime.now(timezone.utc) - async with session_factory() as session: - row = await session.get(PendingTransaction, pending_id) - await bump_fee(session, client, row) + await bump_fee(session_factory, client, pending_id) async with session_factory() as session: row = await session.get(PendingTransaction, pending_id) @@ -246,10 +253,8 @@ async def test_bump_fee_raises_when_no_change_output(session_factory): client = FakeClient({utxo_txid: utxo_amount}) - async with session_factory() as session: - row = await session.get(PendingTransaction, pending_id) - with pytest.raises(RbfError): - await bump_fee(session, client, row) + with pytest.raises(RbfError): + await bump_fee(session_factory, client, pending_id) async def test_bump_fee_retargets_every_stored_txid(session_factory): @@ -303,9 +308,7 @@ async def test_bump_fee_retargets_every_stored_txid(session_factory): await session.commit() pending_id = pending.id - async with session_factory() as session: - row = await session.get(PendingTransaction, pending_id) - new_txid = await bump_fee(session, FakeClient({utxo_txid: utxo_amount}), row) + new_txid = await bump_fee(session_factory, FakeClient({utxo_txid: utxo_amount}), pending_id) async with session_factory() as session: from sqlalchemy import select @@ -378,9 +381,7 @@ async def test_bump_fee_meets_bip125_minimum_when_old_fee_already_exceeds_target inflated_excess = 50_000 client = FakeClient({utxo_txid: utxo_amount + inflated_excess}) - async with session_factory() as session: - row = await session.get(PendingTransaction, pending_id) - new_txid = await bump_fee(session, client, row) + new_txid = await bump_fee(session_factory, client, pending_id) assert client.broadcasted new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0])) @@ -442,9 +443,124 @@ async def test_bump_fee_refuses_once_at_the_max_fee_rate(session_factory): client = FakeClient({utxo_txid: utxo_amount}) - async with session_factory() as session: - row = await session.get(PendingTransaction, pending_id) - with pytest.raises(RbfError): - await bump_fee(session, client, row) + with pytest.raises(RbfError): + await bump_fee(session_factory, client, pending_id) assert not client.broadcasted + + +# --- B-40: bump_fee must not hold a DB session open across its network calls, +# and a row that's no longer pending by the time it runs is a quiet no-op. ------- + + +async def test_bump_fee_holds_no_session_open_during_network_calls(session_factory): + """The get_transaction-per-input reads and the broadcast must happen with no + DB session held open — the same shape used elsewhere for this reason (B-18, + electrum/listener.py's refresh_user for B-31) — otherwise a session sits + idle in the pool for the whole duration of what can be several slow network + round-trips.""" + from app.wallet.hd import derive_user_address, derive_user_key + + signer = derive_user_key(0) + my_address = derive_user_address(0) + from_script = script.p2wpkh(signer.to_public()) + to_address = script.p2wpkh(_key(94).to_public()).address(network=PLM_MAINNET) + + utxo_amount = 150_000_000 + utxo_txid = "77" * 32 + built = build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=[Utxo(utxo_txid, 0, utxo_amount)], + to_address=to_address, + amount_sats=10_000_000, + change_address=my_address, + fee_rate_sat_vb=1, + ) + + async with session_factory() as session: + user = User(username="frank", password_hash="x", derivation_index=0, address=my_address) + session.add(user) + await session.commit() + pending = PendingTransaction( + kind="bet", + user_id=user.id, + current_txid=built.txid, + fee_rate_sat_vb=1, + raw_tx_hex=built.raw_hex, + status="pending", + broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + ) + session.add(pending) + await session.commit() + pending_id = pending.id + + open_count = {"n": 0} + + class _TrackedSession: + def __init__(self, inner): + self._inner = inner + + async def __aenter__(self): + result = await self._inner.__aenter__() + open_count["n"] += 1 + return result + + async def __aexit__(self, *exc): + open_count["n"] -= 1 + return await self._inner.__aexit__(*exc) + + def tracking_session_factory(): + return _TrackedSession(session_factory()) + + class TrackingClient(FakeClient): + async def get_transaction(self, txid, verbose=False): + assert open_count["n"] == 0, "a session was held open during a network call" + return await super().get_transaction(txid, verbose) + + async def broadcast(self, raw_tx_hex): + assert open_count["n"] == 0, "a session was held open during the broadcast" + return await super().broadcast(raw_tx_hex) + + client = TrackingClient({utxo_txid: utxo_amount}) + await bump_fee(tracking_session_factory, client, pending_id) + + assert client.broadcasted + assert open_count["n"] == 0 # nothing left open afterwards either + + +async def test_bump_fee_is_a_noop_when_no_longer_pending(session_factory): + """A row can legitimately confirm (or otherwise leave "pending") between + being read as due and RbfBumper actually attempting the bump — a normal + race, not an error. Must return quietly rather than raising or touching + the network.""" + async with session_factory() as session: + user = User(username="grace", password_hash="x", derivation_index=0, address="plm1qxxx") + session.add(user) + await session.commit() + pending = PendingTransaction( + kind="bet", + user_id=user.id, + current_txid="already-confirmed-txid", + fee_rate_sat_vb=1, + raw_tx_hex="00", + status="confirmed", + broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + ) + session.add(pending) + await session.commit() + pending_id = pending.id + + client = FakeClient({}) + + result = await bump_fee(session_factory, client, pending_id) + + assert result is None + assert not client.broadcasted + + +async def test_bump_fee_is_a_noop_when_the_row_is_gone(session_factory): + client = FakeClient({}) + result = await bump_fee(session_factory, client, 999_999) + assert result is None + assert not client.broadcasted From 4124dc08e62c3bf7aee60fb5814d9016abba5fb9 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 15:27:58 +0200 Subject: [PATCH 20/29] Check confirmation/existence via scripthash history, not verbose replies (B-41) poll_once and reconcile.py's existence check both called blockchain.transaction.get(txid, verbose=True). Several Electrum server implementations and versions reject the verbose flag outright ("verbose transactions are currently unsupported"), which would have meant no confirmations and no reconciliation ever running against such a server, read as a plain transport error. reconcile.py additionally decided whether to abandon a transaction - releasing its funds - by substring-matching the error text ("missing", "not found", ...), which only works against ElectrumX's specific wording. Both now ask blockchain.scripthash.get_history for the address that owns every input of the transaction (a user's own address for a bet/withdrawal, the pool address for a payout) and look for the txid in the result: present with height > 0 means confirmed, present with height <= 0 means still in the mempool, absent means the server doesn't know it. get_history is a plain, universally-supported Electrum method, and "not in the list" replaces the old substring-matching entirely - no more guessing at error wording to decide whether to release funds. History is cached per scripthash within one pass, since every "payout" row shares the same pool address. New app/tx/pending_address.py factors out own_address_for (the address derivation was previously duplicated informally inside tx/broadcast.py's signing context) so confirmation.py and reconcile.py share one definition instead of two that could compute different addresses for the same row. tests/unit/test_confirmation.py and test_reconcile.py needed real User rows and a master-key bootstrap they didn't have before, since address derivation is now exercised for real rather than assumed. Suite grows from 217 to 222 tests. BUGS.md moves B-41 to Previously fixed - no Medium-severity finding remains open. --- BUGS.md | 34 ++---- app/electrum/client.py | 11 ++ app/tx/confirmation.py | 48 +++++++-- app/tx/pending_address.py | 22 ++++ app/tx/reconcile.py | 54 ++++++---- tests/unit/test_confirmation.py | 168 ++++++++++++++++++++++------- tests/unit/test_pending_address.py | 55 ++++++++++ tests/unit/test_reconcile.py | 99 +++++++++++++++-- 8 files changed, 385 insertions(+), 106 deletions(-) create mode 100644 app/tx/pending_address.py create mode 100644 tests/unit/test_pending_address.py diff --git a/BUGS.md b/BUGS.md index 628ad08..32ef023 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-41 … B-49. B-25 through B-40 are fixed (see "Previously -fixed" below) — no Critical-severity finding remains open; the other 9 are Medium/Low. +7 medium, 8 low), listed below as B-42 … B-49. B-25 through B-41 are fixed (see "Previously +fixed" below) — no Critical- or Medium-severity finding remains open; the other 8 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 sixteen fixes so far brought the suite -from 139 to 217). +coverage — every fix lands with a regression test (the seventeen fixes so far brought the suite +from 139 to 222). 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. @@ -16,29 +16,6 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume --- -## Medium - -### B-41 — Confirmation/reconciliation logic depends on `verbose=True`, which is not universally supported - -`poll_once` and `reconcile._tx_exists_on_chain` call `blockchain.transaction.get(txid, True)`. -Several Electrum server implementations and versions reject the verbose flag ("verbose -transactions are currently unsupported"). Falling back onto such a server means **no -confirmations, no reconciliation** — and the code would read that as a transport error and stay -silent. (`bump_fee`'s own `verbose=True` call was removed as part of the B-40 fix — it now reads -the raw transaction and parses the output value with `embit` instead, so bumps are unaffected by -this finding.) - -Related: `reconcile.py:83` decides whether to **abandon a transaction** by substring-matching -the error text (`"missing"`, `"not found"`, `"no such"`, `"unknown"`). It works against -ElectrumX; it is fragile as the basis for a decision that releases funds. - -**Proposed fix.** Use `blockchain.transaction.get_merkle` (or the scripthash history) for -confirmation and existence checks — both are portable and give the confirming height directly. -Probe verbose support once at connect time and record it on the client, so an unsupported -server is detected loudly at session start rather than silently mid-operation. - ---- - ## Low / hygiene ### B-42 — `/docs` exposed in production @@ -121,9 +98,10 @@ already does. - **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 fixes). Suite grew from 139 to 217 tests over the sixteen. +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 fixes). Suite grew from 139 to 222 tests over the seventeen. 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, diff --git a/app/electrum/client.py b/app/electrum/client.py index b72b28a..db4242b 100644 --- a/app/electrum/client.py +++ b/app/electrum/client.py @@ -169,6 +169,17 @@ class ElectrumClient: async def listunspent(self, scripthash: str) -> list[dict]: return await self.request("blockchain.scripthash.listunspent", [scripthash]) + async def get_history(self, scripthash: str) -> list[dict]: + """Every transaction touching `scripthash`, each as {"tx_hash", "height"} — + height > 0 means confirmed at that height, height <= 0 means still in the + mempool. Used instead of blockchain.transaction.get's verbose=True mode + for confirmation/existence checks (B-41): several Electrum server + implementations and versions reject the verbose flag outright ("verbose + transactions are currently unsupported"), while get_history is a plain, + universally-supported method every server must implement. + """ + return await self.request("blockchain.scripthash.get_history", [scripthash]) + async def broadcast(self, raw_tx_hex: str) -> str: return await self.request("blockchain.transaction.broadcast", [raw_tx_hex]) diff --git a/app/tx/confirmation.py b/app/tx/confirmation.py index a7fdb91..ab9a53a 100644 --- a/app/tx/confirmation.py +++ b/app/tx/confirmation.py @@ -7,7 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.db.models import PendingTransaction from app.electrum.client import ElectrumClient +from app.electrum.scripthash import address_to_scripthash from app.rounds.events import broadcaster +from app.tx.pending_address import own_address_for logger = logging.getLogger(__name__) @@ -31,24 +33,52 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient) candidates = ( await session.execute( select( - PendingTransaction.id, PendingTransaction.current_txid, PendingTransaction.kind + PendingTransaction.id, + PendingTransaction.current_txid, + PendingTransaction.kind, + PendingTransaction.user_id, ).where(PendingTransaction.status == "pending") ) ).all() + # Resolved once per candidate while the session is still open, and cached + # by scripthash below — every "payout" row shares the same pool address, + # so this also avoids asking the server the same history twice per tick. + scripthash_by_id: dict[int, str] = {} + for pending_id, _txid, kind, user_id in candidates: + try: + address = await own_address_for(session, kind, user_id) + scripthash_by_id[pending_id] = address_to_scripthash(address) + except Exception: + logger.exception("could not derive the address for pending_transaction %s", pending_id) + confirmed = 0 - for pending_id, txid, kind in candidates: + history_cache: dict[str, list[dict]] = {} + for pending_id, txid, kind, _user_id in candidates: + scripthash = scripthash_by_id.get(pending_id) + if scripthash is None: + continue # address derivation failed above; already logged + try: - tx = await client.get_transaction(txid, verbose=True) + if scripthash not in history_cache: + history_cache[scripthash] = await client.get_history(scripthash) except Exception: - # One unresolvable txid must not stop the others: a tx the server no - # longer knows (dropped from the mempool, replaced) used to abort the - # whole pass, so nothing confirmed again until an operator intervened - # (B-03). Abandoning such a row is app/tx/reconcile.py's job, not ours. - logger.warning("could not check pending_transaction %s (txid %s)", pending_id, txid, exc_info=True) + # One unresolvable scripthash must not stop the others: a tx the server + # no longer knows about (dropped from the mempool, replaced) used to + # abort the whole pass via a verbose blockchain.transaction.get call + # that some servers reject outright (B-41), so nothing confirmed again + # until an operator intervened (B-03). Abandoning such a row is + # app/tx/reconcile.py's job, not ours. + logger.warning("could not fetch history for pending_transaction %s (txid %s)", pending_id, txid, exc_info=True) continue - if not tx or tx.get("confirmations", 0) < 1: + + entry = next((e for e in history_cache[scripthash] if e.get("tx_hash") == txid), None) + # height > 0 means confirmed at that height; 0 or absent means still in + # the mempool (or the server doesn't know this txid at all yet) — either + # way, not confirmed, so keep waiting. + if entry is None or entry.get("height", 0) <= 0: continue + async with session_factory() as session: row = await session.get(PendingTransaction, pending_id) if row is None or row.status != "pending": diff --git a/app/tx/pending_address.py b/app/tx/pending_address.py new file mode 100644 index 0000000..6cc2cff --- /dev/null +++ b/app/tx/pending_address.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import User +from app.wallet.hd import derive_pool_address, derive_user_address + + +async def own_address_for(session: AsyncSession, kind: str, user_id: int | None) -> str: + """The address that owns every input of a PendingTransaction of this kind — + a user's own address for a bet/withdrawal, the pool address for a payout. + All our builders only ever spend one address's UTXOs per tx (see + tx/broadcast.py:_signing_context, which derives the same address alongside + the signing key it also needs). + + Shared by tx/confirmation.py and tx/reconcile.py (B-41): both now check + blockchain.scripthash.get_history for this address instead of asking + blockchain.transaction.get for a verbose reply, so the two can't derive + different addresses for the same row. + """ + if kind == "payout": + return derive_pool_address() + user = await session.get(User, user_id) + return derive_user_address(user.derivation_index) diff --git a/app/tx/reconcile.py b/app/tx/reconcile.py index 26683ce..fcf4776 100644 --- a/app/tx/reconcile.py +++ b/app/tx/reconcile.py @@ -36,7 +36,9 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.audit.log import write_audit_log from app.db.models import PendingTransaction, Round, RoundParticipant, UtxoEvent, Withdrawal from app.electrum.client import ElectrumClient +from app.electrum.scripthash import address_to_scripthash from app.rounds.events import broadcaster +from app.tx.pending_address import own_address_for from app.wallet.balance import recompute_balance logger = logging.getLogger(__name__) @@ -72,22 +74,21 @@ class PendingTransactionReconciler: await asyncio.sleep(_POLL_INTERVAL_SECONDS) -async def _tx_exists_on_chain(client: ElectrumClient, txid: str) -> bool: - """True if the server knows this txid at all (mempool or mined). An error reply - means "unknown", which is the answer we're looking for; a transport failure is - *not* — that raises, and the caller leaves the row alone until next time.""" - try: - tx = await client.get_transaction(txid, verbose=True) - except Exception as exc: - message = str(exc).lower() - if "missing" in message or "not found" in message or "no such" in message or "unknown" in message: - return False - raise - return bool(tx) - - async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int: - """Returns how many rows were resolved (promoted or abandoned).""" + """Returns how many rows were resolved (promoted or abandoned). + + Existence is decided by checking whether a row's own address's history + (blockchain.scripthash.get_history) includes its txid at all — mempool or + mined — rather than asking blockchain.transaction.get for a verbose reply + (B-41): several Electrum server implementations and versions reject the + verbose flag outright, and the previous substring-matching on the error + text (looking for "missing", "not found", ...) was fragile as the basis for + a decision that releases funds. A transport failure fetching history still + raises and leaves the row alone until next time — get_history not + returning our txid is the only thing that means "gone". History is cached + per scripthash within one pass, since every "payout" row shares the same + pool address. + """ now = datetime.now(timezone.utc) async with session_factory() as session: rows = ( @@ -95,16 +96,25 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl select(PendingTransaction).where(PendingTransaction.status.in_(("building", "pending"))) ) ).all() - candidates = [ - (row.id, row.status, row.current_txid) - for row in rows - if _is_due(row, now) - ] + candidates = [] + for row in rows: + if not _is_due(row, now): + continue + try: + address = await own_address_for(session, row.kind, row.user_id) + scripthash = address_to_scripthash(address) + except Exception: + logger.exception("could not derive the address for pending_transaction %s", row.id) + continue + candidates.append((row.id, row.status, row.current_txid, scripthash)) resolved = 0 - for row_id, status, txid in candidates: + history_cache: dict[str, list[dict]] = {} + for row_id, status, txid, scripthash in candidates: try: - exists = await _tx_exists_on_chain(client, txid) + if scripthash not in history_cache: + history_cache[scripthash] = await client.get_history(scripthash) + exists = any(entry.get("tx_hash") == txid for entry in history_cache[scripthash]) except Exception: # Transport/server problem — say nothing about this tx and try again on # the next pass rather than abandoning a tx that may be perfectly alive. diff --git a/tests/unit/test_confirmation.py b/tests/unit/test_confirmation.py index 056f3d4..2e9129e 100644 --- a/tests/unit/test_confirmation.py +++ b/tests/unit/test_confirmation.py @@ -1,41 +1,83 @@ import pytest -from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine import app.bets.confirmation # noqa: F401 (registers the "bet" handler) import app.rounds.confirmation # noqa: F401 (registers the "payout" handler) +from sqlalchemy import select + +from app.config import settings from app.db.base import Base -from app.db.models import PendingTransaction, Round, RoundParticipant +from app.db.models import PendingTransaction, Round, RoundParticipant, User +from app.electrum.scripthash import address_to_scripthash from app.tx.confirmation import poll_once +from app.wallet.hd import derive_user_address class FakeClient: - def __init__(self, confirmations_by_txid: dict[str, int]): - self._confirmations = confirmations_by_txid + """B-41: poll_once now asks blockchain.scripthash.get_history rather than a + verbose blockchain.transaction.get, so this hands back a flat history — + height > 0 means confirmed at that height, 0 (or absent) means still in the + mempool. The scripthash argument is ignored: every candidate's derived + address is looked up against the same known universe of txids, which is + fine since matching happens on tx_hash, not on which address asked.""" - async def get_transaction(self, txid: str, verbose: bool = False) -> dict: - return {"confirmations": self._confirmations.get(txid, 0)} + def __init__(self, heights_by_txid: dict[str, int]): + self._heights = heights_by_txid + + async def get_history(self, scripthash: str) -> list[dict]: + return [{"tx_hash": txid, "height": height} for txid, height in self._heights.items()] @pytest.fixture -async def session_factory(): +async def session_factory(tmp_path, monkeypatch): + # own_address_for (B-41) derives each row's address via the HD wallet, so + # poll_once now needs a real master key — same bootstrap test_broadcast.py + # and test_reconcile.py use. + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + monkeypatch.setattr( + settings, + "xprv_encryption_key", + __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(), + ) + from app.wallet import hd + + hd._account_key = None + hd.generate_master_key() + engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield async_sessionmaker(engine, expire_on_commit=False) await engine.dispose() + hd._account_key = None + + +async def _make_user(session, derivation_index: int) -> User: + user = User( + username=f"user{derivation_index}", + password_hash="x", + derivation_index=derivation_index, + address=derive_user_address(derivation_index), + ) + session.add(user) + await session.flush() + return user async def test_bet_confirmation_marks_participant_confirmed(session_factory): async with session_factory() as session: + user = await _make_user(session, 0) session.add(Round(id=1, status="open")) session.add( RoundParticipant( - round_id=1, user_id=1, bet_amount_sats=1_000, bet_txid="tx1", status="broadcast" + round_id=1, user_id=user.id, bet_amount_sats=1_000, bet_txid="tx1", status="broadcast" ) ) session.add( - PendingTransaction(kind="bet", round_id=1, user_id=1, current_txid="tx1", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending") + PendingTransaction( + kind="bet", round_id=1, user_id=user.id, current_txid="tx1", fee_rate_sat_vb=1, + raw_tx_hex="00", status="pending", + ) ) await session.commit() @@ -53,9 +95,17 @@ async def test_bet_confirmation_marks_participant_confirmed(session_factory): async def test_unconfirmed_tx_is_left_pending(session_factory): async with session_factory() as session: + user = await _make_user(session, 0) session.add(Round(id=2, status="open")) - session.add(RoundParticipant(round_id=2, user_id=1, bet_amount_sats=1_000, bet_txid="tx2", status="broadcast")) - session.add(PendingTransaction(kind="bet", round_id=2, user_id=1, current_txid="tx2", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending")) + session.add( + RoundParticipant(round_id=2, user_id=user.id, bet_amount_sats=1_000, bet_txid="tx2", status="broadcast") + ) + session.add( + PendingTransaction( + kind="bet", round_id=2, user_id=user.id, current_txid="tx2", fee_rate_sat_vb=1, + raw_tx_hex="00", status="pending", + ) + ) await session.commit() client = FakeClient({"tx2": 0}) @@ -70,7 +120,11 @@ async def test_unconfirmed_tx_is_left_pending(session_factory): async def test_payout_confirmation_closes_round(session_factory): async with session_factory() as session: session.add(Round(id=3, status="paying_out", payout_txid="tx3")) - session.add(PendingTransaction(kind="payout", round_id=3, current_txid="tx3", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending")) + session.add( + PendingTransaction( + kind="payout", round_id=3, current_txid="tx3", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending" + ) + ) await session.commit() client = FakeClient({"tx3": 2}) @@ -83,43 +137,52 @@ async def test_payout_confirmation_closes_round(session_factory): class ExplodingClient: - """Answers for one txid and raises for the other — a tx the server no longer - knows (dropped from the mempool, replaced by a bump).""" + """Answers for one address's history and raises for the other's — the + get_history equivalent of a server that no longer knows a particular tx + (dropped from the mempool, replaced by a bump).""" - def __init__(self, known: dict[str, int], exploding_txid: str): - self._known = known - self._exploding = exploding_txid + def __init__(self, heights_by_txid: dict[str, int], exploding_scripthash: str): + self._heights = heights_by_txid + self._exploding = exploding_scripthash - async def get_transaction(self, txid: str, verbose: bool = False) -> dict: - if txid == self._exploding: - raise RuntimeError("missing transaction") - return {"confirmations": self._known.get(txid, 0)} + async def get_history(self, scripthash: str) -> list[dict]: + if scripthash == self._exploding: + raise RuntimeError("server error") + return [{"tx_hash": txid, "height": height} for txid, height in self._heights.items()] -async def test_one_unresolvable_txid_does_not_block_the_others(session_factory): - """B-03: the lookup used to be unguarded, so a single unknown txid aborted the - whole pass — nothing confirmed again until an operator intervened, which in turn - meant no round could ever close.""" +async def test_one_unresolvable_candidate_does_not_block_the_others(session_factory): + """B-03: the lookup used to be unguarded, so a single failing candidate aborted + the whole pass — nothing confirmed again until an operator intervened, which in + turn meant no round could ever close. B-41 changed the failure unit from "one + txid" to "one address's history", but the isolation guarantee is the same.""" async with session_factory() as session: + good_user = await _make_user(session, 0) + gone_user = await _make_user(session, 1) session.add(Round(id=10, status="open")) session.add( - RoundParticipant(round_id=10, user_id=1, bet_amount_sats=1_000, bet_txid="good", status="broadcast") - ) - session.add( - PendingTransaction( - kind="bet", round_id=10, user_id=2, current_txid="gone", fee_rate_sat_vb=1, raw_tx_hex="00", - status="pending", + RoundParticipant( + round_id=10, user_id=good_user.id, bet_amount_sats=1_000, bet_txid="good", status="broadcast" ) ) session.add( PendingTransaction( - kind="bet", round_id=10, user_id=1, current_txid="good", fee_rate_sat_vb=1, raw_tx_hex="00", - status="pending", + kind="bet", round_id=10, user_id=gone_user.id, current_txid="gone", fee_rate_sat_vb=1, + raw_tx_hex="00", status="pending", + ) + ) + session.add( + PendingTransaction( + kind="bet", round_id=10, user_id=good_user.id, current_txid="good", fee_rate_sat_vb=1, + raw_tx_hex="00", status="pending", ) ) await session.commit() - confirmed = await poll_once(session_factory, ExplodingClient({"good": 1}, exploding_txid="gone")) + exploding_scripthash = address_to_scripthash(derive_user_address(1)) + confirmed = await poll_once( + session_factory, ExplodingClient({"good": 1}, exploding_scripthash=exploding_scripthash) + ) assert confirmed == 1 # the healthy one still got processed async with session_factory() as session: @@ -135,15 +198,16 @@ async def test_bet_confirms_after_an_rbf_bump_changed_the_txid(session_factory): a txid no participant carried — the participant stayed "broadcast" forever and the round could never close. It now resolves by (round_id, user_id).""" async with session_factory() as session: + user = await _make_user(session, 0) session.add(Round(id=11, status="open")) session.add( RoundParticipant( - round_id=11, user_id=7, bet_amount_sats=1_000, bet_txid="old-txid", status="broadcast" + round_id=11, user_id=user.id, bet_amount_sats=1_000, bet_txid="old-txid", status="broadcast" ) ) session.add( PendingTransaction( - kind="bet", round_id=11, user_id=7, current_txid="bumped-txid", fee_rate_sat_vb=2, + kind="bet", round_id=11, user_id=user.id, current_txid="bumped-txid", fee_rate_sat_vb=2, raw_tx_hex="00", status="pending", replaced_by_txid="old-txid", ) ) @@ -171,3 +235,35 @@ async def test_payout_confirms_after_an_rbf_bump_changed_the_txid(session_factor async with session_factory() as session: assert (await session.get(Round, 12)).status == "closed" + + +async def test_poll_once_caches_history_per_scripthash(session_factory): + """Two pending bets from the same user share one address — fetching its + history twice in one pass would be wasteful.""" + async with session_factory() as session: + user = await _make_user(session, 0) + session.add(Round(id=20, status="open")) + session.add( + PendingTransaction( + kind="bet", round_id=20, user_id=user.id, current_txid="tx-a", fee_rate_sat_vb=1, + raw_tx_hex="00", status="pending", + ) + ) + session.add( + PendingTransaction( + kind="withdrawal", user_id=user.id, current_txid="tx-b", fee_rate_sat_vb=1, + raw_tx_hex="00", status="pending", + ) + ) + await session.commit() + + call_count = {"n": 0} + + class CountingClient: + async def get_history(self, scripthash: str) -> list[dict]: + call_count["n"] += 1 + return [{"tx_hash": "tx-a", "height": 0}, {"tx_hash": "tx-b", "height": 0}] + + await poll_once(session_factory, CountingClient()) + + assert call_count["n"] == 1 diff --git a/tests/unit/test_pending_address.py b/tests/unit/test_pending_address.py new file mode 100644 index 0000000..a807f7f --- /dev/null +++ b/tests/unit/test_pending_address.py @@ -0,0 +1,55 @@ +"""B-41: own_address_for is the single place tx/confirmation.py and +tx/reconcile.py derive a PendingTransaction's own address from — a payout's +address must always be the pool's, everything else the actual user's.""" + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.config import settings +from app.db.base import Base +from app.db.models import User +from app.tx.pending_address import own_address_for + + +@pytest.fixture +async def session_factory(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + monkeypatch.setattr( + settings, + "xprv_encryption_key", + __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(), + ) + from app.wallet import hd + + hd._account_key = None + hd.generate_master_key() + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield async_sessionmaker(engine, expire_on_commit=False) + await engine.dispose() + hd._account_key = None + + +async def test_payout_uses_the_pool_address_regardless_of_user_id(session_factory): + from app.wallet.hd import derive_pool_address + + async with session_factory() as session: + address = await own_address_for(session, "payout", None) + + assert address == derive_pool_address() + + +@pytest.mark.parametrize("kind", ["bet", "withdrawal"]) +async def test_bet_and_withdrawal_use_the_users_own_address(session_factory, kind): + from app.wallet.hd import derive_user_address + + async with session_factory() as session: + user = User(username="alice", password_hash="x", derivation_index=3, address=derive_user_address(3)) + session.add(user) + await session.flush() + + address = await own_address_for(session, kind, user.id) + + assert address == derive_user_address(3) diff --git a/tests/unit/test_reconcile.py b/tests/unit/test_reconcile.py index 2785a7b..2a47e49 100644 --- a/tests/unit/test_reconcile.py +++ b/tests/unit/test_reconcile.py @@ -1,5 +1,10 @@ """Regression tests for B-04 (and the "building" half of B-08): a transaction that -never made it onto the chain must give the coins back instead of freezing them.""" +never made it onto the chain must give the coins back instead of freezing them. + +Also covers B-41: existence/reconciliation checks go through +blockchain.scripthash.get_history rather than a verbose blockchain.transaction.get +reply, so the fake clients below implement get_history. +""" import pytest from embit import script @@ -7,37 +12,60 @@ from embit.transaction import Transaction, TransactionInput, TransactionOutput from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from app.config import settings from app.db.base import Base from app.db.models import AuditLog, PendingTransaction, RoundParticipant, User, UtxoEvent, Withdrawal from app.tx.reconcile import reconcile_once class UnknownTxClient: - """A server that doesn't know any of the txids it's asked about.""" + """A server whose history for any address never includes our txid.""" - async def get_transaction(self, txid: str, verbose: bool = False): - raise RuntimeError(f"missing transaction {txid}") + async def get_history(self, scripthash: str) -> list[dict]: + return [] class KnownTxClient: - async def get_transaction(self, txid: str, verbose: bool = False): - return {"txid": txid, "confirmations": 0} + """A server whose history for the address includes our txid — mined or + still in the mempool doesn't matter for existence, only for confirmation + (which is tx/confirmation.py's concern, not reconcile.py's).""" + + def __init__(self, txid: str = "betxid"): + self._txid = txid + + async def get_history(self, scripthash: str) -> list[dict]: + return [{"tx_hash": self._txid, "height": 100}] class BrokenClient: """A transport failure — says nothing about whether the tx exists.""" - async def get_transaction(self, txid: str, verbose: bool = False): + async def get_history(self, scripthash: str) -> list[dict]: raise ConnectionResetError("connection reset") @pytest.fixture -async def session_factory(): +async def session_factory(tmp_path, monkeypatch): + # own_address_for (B-41) derives each row's address via the HD wallet rather + # than trusting the DB's address column, so reconcile_once now needs a real + # master key set up — same bootstrap test_broadcast.py uses. + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + monkeypatch.setattr( + settings, + "xprv_encryption_key", + __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(), + ) + from app.wallet import hd + + hd._account_key = None + hd.generate_master_key() + engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield async_sessionmaker(engine, expire_on_commit=False) await engine.dispose() + hd._account_key = None # A real (unsigned) transaction spending one input, built rather than hand-written @@ -66,11 +94,19 @@ async def _seed_bet( participant_status: str, age_seconds: int, last_broadcast_age_seconds: int | None = None, + derivation_index: int = 0, ): from datetime import datetime, timedelta, timezone + from app.wallet.hd import derive_user_address + async with session_factory() as session: - user = User(username="u", password_hash="x", derivation_index=0, address="plm1qtest") + user = User( + username="u", + password_hash="x", + derivation_index=derivation_index, + address=derive_user_address(derivation_index), + ) session.add(user) await session.flush() session.add( @@ -145,7 +181,7 @@ async def test_promotes_a_building_row_whose_tx_did_reach_the_chain(session_fact session_factory, pending_status="building", participant_status="building", age_seconds=300 ) - resolved = await reconcile_once(session_factory, KnownTxClient()) + resolved = await reconcile_once(session_factory, KnownTxClient("betxid")) assert resolved == 1 async with session_factory() as session: @@ -219,8 +255,10 @@ async def test_abandoned_withdrawal_is_marked_failed_and_kept(session_factory): they can see it didn't go through.""" from datetime import datetime, timedelta, timezone + from app.wallet.hd import derive_user_address + async with session_factory() as session: - user = User(username="w", password_hash="x", derivation_index=1, address="plm1qtest2") + user = User(username="w", password_hash="x", derivation_index=1, address=derive_user_address(1)) session.add(user) await session.flush() session.add( @@ -264,3 +302,42 @@ async def test_abandoned_withdrawal_is_marked_failed_and_kept(session_factory): assert withdrawal.status == "failed" assert withdrawal.txid is None assert (await session.scalars(select(UtxoEvent))).one().spent_txid is None + + +# --- B-41: existence checks now use get_history and share it across candidates +# sharing the same address, instead of a per-tx verbose blockchain.transaction.get. -- + + +async def test_reconcile_once_caches_history_per_scripthash(session_factory): + """Two payout PendingTransaction rows always share the same pool address — + fetching its history twice in one pass would be wasteful and, at scale + across many candidates on one address, needlessly slow the whole tick.""" + from datetime import datetime, timedelta, timezone + + async with session_factory() as session: + old = datetime.now(timezone.utc) - timedelta(hours=7) + session.add( + PendingTransaction( + kind="payout", round_id=1, current_txid="payout-a", fee_rate_sat_vb=1, + raw_tx_hex=_RAW_TX, status="pending", broadcast_at=old, last_broadcast_at=old, + ) + ) + session.add( + PendingTransaction( + kind="payout", round_id=2, current_txid="payout-b", fee_rate_sat_vb=1, + raw_tx_hex=_RAW_TX, status="pending", broadcast_at=old, last_broadcast_at=old, + ) + ) + await session.commit() + + call_count = {"n": 0} + + class CountingClient: + async def get_history(self, scripthash: str) -> list[dict]: + call_count["n"] += 1 + return [{"tx_hash": "payout-a", "height": 100}, {"tx_hash": "payout-b", "height": 100}] + + resolved = await reconcile_once(session_factory, CountingClient()) + + assert resolved == 0 # both exist — nothing to abandon or promote (already "pending") + assert call_count["n"] == 1 # one call covered both rows sharing the pool address From 22e3cfb2beda9802673f9e384770f81fbd1361c5 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 15:34:49 +0200 Subject: [PATCH 21/29] Disable Swagger/ReDoc/OpenAPI JSON by default (B-42) They enumerate the entire API surface, admin endpoints included, to anyone who requests them. Gate them behind a new ENABLE_API_DOCS setting (off by default) and update README/docs and BUGS.md/CLAUDE.md open-bug counts accordingly. Co-Authored-By: Claude Sonnet 5 --- .env.example | 5 +++++ BUGS.md | 20 +++++++------------- CLAUDE.md | 4 ++-- README.md | 6 ++++-- app/config.py | 5 +++++ app/main.py | 11 ++++++++++- docs/guida-admin.md | 5 +++-- docs/running-the-server.md | 3 +++ docs/setup.md | 5 +++++ tests/unit/test_docs_disabled.py | 28 ++++++++++++++++++++++++++++ 10 files changed, 72 insertions(+), 20 deletions(-) create mode 100644 tests/unit/test_docs_disabled.py diff --git a/.env.example b/.env.example index 48c8e71..f65bfc3 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,8 @@ ADMIN_TOKEN= # Every business/round parameter (bet amount, round duration/cooldown, min # amount, fee rate, RBF timeout, fee address) is configured live from the # admin panel (/admin) instead of here — see docs/guida-admin.md. + +# Swagger/ReDoc/the raw OpenAPI JSON expose the entire API surface — admin +# endpoints included — to anyone who requests them. Off by default; set to +# true only for local development, never in production. +ENABLE_API_DOCS=false diff --git a/BUGS.md b/BUGS.md index 32ef023..ed2b963 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-42 … B-49. B-25 through B-41 are fixed (see "Previously -fixed" below) — no Critical- or Medium-severity finding remains open; the other 8 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 seventeen fixes so far brought the suite -from 139 to 222). +7 medium, 8 low), listed below as B-43 … B-49. B-25 through B-42 are fixed (see "Previously +fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 7 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 eighteen fixes so far brought +the suite from 139 to 224). 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. @@ -18,13 +18,6 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume ## Low / hygiene -### B-42 — `/docs` exposed in production - -FastAPI mounts Swagger by default, so the entire API surface — `/admin` included — is publicly -enumerable. The README advertises it. -**Fix:** `docs_url=None, redoc_url=None, openapi_url=None` in production (env-gated), or place -them behind `require_admin`. - ### B-43 — No HTTP security headers The [Caddyfile](Caddyfile) sets no CSP, no `X-Frame-Options`/`frame-ancestors`, and no HSTS @@ -89,6 +82,7 @@ already does. - **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-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 @@ -101,7 +95,7 @@ already does. - **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 fixes). Suite grew from 139 to 222 tests over the seventeen. +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 fixes). Suite grew from 139 to 224 tests over the eighteen. 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, diff --git a/CLAUDE.md b/CLAUDE.md index 16ae980..b916550 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ All 10 stages of the original build order are code-complete and unit-tested — 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 **12 are still open** — no Critical or High remains, only Medium/Low: no WAL/`busy_timeout` under five concurrent SQLite writer tasks (B-39), a 500-subscriber SSE cap that doubles as a cheap DoS of the realtime feature (B-38), among others. 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 **7 are still open** — no Critical, High or Medium remains, only Low/hygiene: an admin list endpoint with no pagination bound (B-45), unbounded `String` columns for large text (B-47), among others. 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 .mmd` after editing either. @@ -233,7 +233,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) (18 open) — not duplicated here. +Accepted **by design**. For actual bugs see [BUGS.md](BUGS.md) (7 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. - **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. diff --git a/README.md b/README.md index f710ec9..bce79a4 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,10 @@ uvicorn app.main:app --reload --port 8123 ``` Open `http://127.0.0.1:8123/` for the test UI, `http://127.0.0.1:8123/admin` -for the admin dashboard, `http://127.0.0.1:8123/docs` for the interactive API -docs. +for the admin dashboard. The interactive API docs at `/docs` are disabled by +default (they'd otherwise expose the whole API surface, admin endpoints +included) — set `ENABLE_API_DOCS=true` in `.env` for local development to +enable them. Or run the whole stack (app + Caddy reverse proxy with automatic TLS) via Docker: diff --git a/app/config.py b/app/config.py index 58e0f78..1314ea1 100644 --- a/app/config.py +++ b/app/config.py @@ -29,6 +29,11 @@ class Settings(BaseSettings): jwt_expire_minutes: int = 60 * 24 admin_token: str = "" + # Swagger/ReDoc/OpenAPI JSON expose the entire API surface (admin endpoints + # included) to anyone who requests them. Off by default (B-42) — set to true + # only for local development, never in production. + enable_api_docs: bool = False + # Every business/round parameter (bet amount, round duration/cooldown, # min amount, fee rate, RBF timeout, fee address) lives in the round_config # DB table instead (app/db/models.py RoundConfig, app/rounds/config.py) — diff --git a/app/main.py b/app/main.py index cc5a5b5..ff547d0 100644 --- a/app/main.py +++ b/app/main.py @@ -84,7 +84,16 @@ async def lifespan(app: FastAPI): await listener.client.close() -app = FastAPI(title="PLM Lottery", lifespan=lifespan) +# Swagger/ReDoc/the raw OpenAPI JSON enumerate the entire API surface, admin +# endpoints included, to anyone who requests them (B-42) — disabled unless +# ENABLE_API_DOCS is explicitly set, which should only happen in development. +app = FastAPI( + title="PLM Lottery", + lifespan=lifespan, + docs_url="/docs" if settings.enable_api_docs else None, + redoc_url="/redoc" if settings.enable_api_docs else None, + openapi_url="/openapi.json" if settings.enable_api_docs else None, +) app.include_router(auth_router) app.include_router(users_router) app.include_router(bets_router) diff --git a/docs/guida-admin.md b/docs/guida-admin.md index be07fc9..abcb212 100644 --- a/docs/guida-admin.md +++ b/docs/guida-admin.md @@ -121,8 +121,9 @@ Eventi a cui vale la pena prestare attenzione: ## Alternative all'interfaccia grafica Le stesse operazioni si possono fare da terminale o da Swagger UI -(`https:///docs`, sezione `admin`), sempre passando `ADMIN_TOKEN` -nell'header `X-Admin-Token`: +(`https:///docs`, sezione `admin` — disponibile solo se `ENABLE_API_DOCS=true` +è impostato in `.env`, disattivata di default perché espone l'intera API), +sempre passando `ADMIN_TOKEN` nell'header `X-Admin-Token`: ```bash # leggere la configurazione diff --git a/docs/running-the-server.md b/docs/running-the-server.md index ad396ed..739dcec 100644 --- a/docs/running-the-server.md +++ b/docs/running-the-server.md @@ -12,6 +12,9 @@ uvicorn app.main:app --reload --port 8123 - App su `http://127.0.0.1:8123/` - Pannello admin su `http://127.0.0.1:8123/admin` +- Docs API interattive su `http://127.0.0.1:8123/docs` solo se `ENABLE_API_DOCS=true` + in `.env` — disattivate di default perché espongono l'intera API, endpoint + admin inclusi (vedi [setup.md](setup.md)) - Log applicativi in `logs/app.log` (rotante, 10MB × 5 backup) - Nessun TLS, nessun reverse proxy — solo per test locali sulla tua macchina. diff --git a/docs/setup.md b/docs/setup.md index 97338d9..373e972 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -38,6 +38,11 @@ cp .env.example .env Le altre chiavi di `.env` (`DATABASE_URL`, `ELECTRUM_HOST`/`PORT`/`USE_SSL`, `MASTER_KEY_PATH`) hanno default sensati in `.env.example`. +`ENABLE_API_DOCS` (default `false`) controlla Swagger/ReDoc/l'OpenAPI JSON grezzo +su `/docs`, `/redoc` e `/openapi.json`: espongono l'intera superficie dell'API, +endpoint admin inclusi, quindi restano disattivati a meno di non impostarlo +esplicitamente a `true` — utile in locale, da evitare in produzione. + `ELECTRUM_FALLBACK_SERVERS` elenca i server di riserva, separati da virgola, nel formato `host:porta` (TLS, il caso normale) oppure `host:porta:notls`. Esempio: diff --git a/tests/unit/test_docs_disabled.py b/tests/unit/test_docs_disabled.py new file mode 100644 index 0000000..c9657a3 --- /dev/null +++ b/tests/unit/test_docs_disabled.py @@ -0,0 +1,28 @@ +"""B-42: Swagger/ReDoc/OpenAPI JSON must not be reachable unless explicitly enabled — +they enumerate the whole API surface, admin endpoints included.""" + +import importlib + +from app.config import settings + + +def _reload_main(): + import app.main + + return importlib.reload(app.main) + + +def test_docs_disabled_by_default(monkeypatch): + monkeypatch.setattr(settings, "enable_api_docs", False) + main = _reload_main() + assert main.app.docs_url is None + assert main.app.redoc_url is None + assert main.app.openapi_url is None + + +def test_docs_enabled_when_configured(monkeypatch): + monkeypatch.setattr(settings, "enable_api_docs", True) + main = _reload_main() + assert main.app.docs_url == "/docs" + assert main.app.redoc_url == "/redoc" + assert main.app.openapi_url == "/openapi.json" From d60da116030ebb45508108ef49b574b70fb86252 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 15:44:47 +0200 Subject: [PATCH 22/29] Add baseline HTTP security headers in Caddy (B-43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caddy adds none of these on its own. Add HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy and a CSP scoped to default-src 'self' plus the one external asset (Google Fonts). script-src/style-src need 'unsafe-inline' because both SPAs rely on inline onclick handlers and style="" attributes throughout — removing those is a separate, larger refactor. Validated with `caddy validate` and a live container curl check. Adds a static regression test asserting the header directives stay present in the Caddyfile, since nothing else in the Python suite exercises it. Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 20 ++++------- CLAUDE.md | 6 ++-- Caddyfile | 16 +++++++++ tests/unit/test_caddyfile_security_headers.py | 35 +++++++++++++++++++ 4 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 tests/unit/test_caddyfile_security_headers.py diff --git a/BUGS.md b/BUGS.md index ed2b963..d4cdf42 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-43 … B-49. B-25 through B-42 are fixed (see "Previously -fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 7 are +7 medium, 8 low), listed below as B-44 … B-49. B-25 through B-43 are fixed (see "Previously +fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 6 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 eighteen fixes so far brought -the suite from 139 to 224). +by existing coverage — every fix lands with a regression test (the nineteen fixes so far brought +the suite from 139 to 230). 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. @@ -18,15 +18,6 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume ## Low / hygiene -### B-43 — No HTTP security headers - -The [Caddyfile](Caddyfile) sets no CSP, no `X-Frame-Options`/`frame-ancestors`, and no HSTS -(Caddy does not add it on its own). The JWT lives in `localStorage`, so any XSS exfiltrates -it, and the page is iframeable. -**Fix:** a `header` block in the Caddyfile with `Strict-Transport-Security`, -`X-Content-Type-Options: nosniff`, `Referrer-Policy` and a CSP tight enough for two static -pages with no external assets (`default-src 'self'`). - ### B-44 — README and CLAUDE.md contradict each other The README says to run `uvicorn --reload` directly and @@ -83,6 +74,7 @@ already does. - **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-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 @@ -95,7 +87,7 @@ already does. - **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 fixes). Suite grew from 139 to 224 tests over the eighteen. +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 fixes). Suite grew from 139 to 230 tests over the nineteen. 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, diff --git a/CLAUDE.md b/CLAUDE.md index b916550..2a66989 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ All 10 stages of the original build order are code-complete and unit-tested — 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 **7 are still open** — no Critical, High or Medium remains, only Low/hygiene: an admin list endpoint with no pagination bound (B-45), unbounded `String` columns for large text (B-47), among others. 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 **6 are still open** — no Critical, High or Medium remains, only Low/hygiene: an admin list endpoint with no pagination bound (B-45), unbounded `String` columns for large text (B-47), among others. 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 .mmd` after editing either. @@ -55,7 +55,7 @@ docker compose down `SITE_ADDRESS` unset → `localhost`, Caddy issues a self-signed cert from its internal CA (browser warning on first visit is expected; `curl -k`). `SITE_ADDRESS=lottery.example.com docker compose up -d` → real Let's Encrypt cert, automatically renewed (needs DNS pointing here and ports 80+443 reachable). -The `Caddyfile` sets **no** security headers — no CSP, HSTS or `X-Frame-Options` (B-43). `restart: unless-stopped` on `app` means a mid-round crash auto-restarts: `closing` and `paying_out` resume on their own, `drawing` does not (see Known gaps). +The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and a CSP scoped to `default-src 'self'` plus the Google Fonts `@import` in `style.css`/`admin.css`. `script-src`/`style-src` need `'unsafe-inline'` because both SPAs use inline `onclick` handlers and `style=""` attributes throughout — removing those is a separate, larger refactor, not a header change. `restart: unless-stopped` on `app` means a mid-round crash auto-restarts: `closing` and `paying_out` resume on their own, `drawing` does not (see Known gaps). ## Tech stack @@ -233,7 +233,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) (7 open) — not duplicated here. +Accepted **by design**. For actual bugs see [BUGS.md](BUGS.md) (6 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. - **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. diff --git a/Caddyfile b/Caddyfile index b51229c..f6db413 100644 --- a/Caddyfile +++ b/Caddyfile @@ -13,5 +13,21 @@ not path /rounds/stream } encode @not_sse gzip + + # B-43: Caddy adds none of these on its own. The JWT lives in + # localStorage, so any XSS exfiltrates it — CSP is the main mitigation. + # script-src/style-src need 'unsafe-inline' because both SPAs + # (app/static/index.html, admin.html) use inline onclick handlers and + # style="" attributes throughout; removing those is a separate, + # larger refactor, not a header change. fonts.googleapis.com/gstatic.com + # are the one external asset (the Google Fonts @import in style.css/admin.css). + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'" + } + reverse_proxy app:8123 } diff --git a/tests/unit/test_caddyfile_security_headers.py b/tests/unit/test_caddyfile_security_headers.py new file mode 100644 index 0000000..ed12a9a --- /dev/null +++ b/tests/unit/test_caddyfile_security_headers.py @@ -0,0 +1,35 @@ +"""B-43: the Caddyfile must keep sending baseline security headers. Caddy adds +none of these on its own, and the JWT lives in localStorage, so a regression +here silently reopens an XSS/clickjacking exposure with no test ever failing +in the Python suite (the Caddyfile isn't imported/exercised by anything else).""" + +from pathlib import Path + +CADDYFILE = (Path(__file__).parent.parent.parent / "Caddyfile").read_text() + + +def test_header_block_present(): + assert "header {" in CADDYFILE + + +def test_hsts_is_set(): + assert "Strict-Transport-Security" in CADDYFILE + assert "max-age=" in CADDYFILE + + +def test_nosniff_is_set(): + assert 'X-Content-Type-Options "nosniff"' in CADDYFILE + + +def test_frame_ancestors_are_blocked(): + assert 'X-Frame-Options "DENY"' in CADDYFILE + assert "frame-ancestors 'none'" in CADDYFILE + + +def test_referrer_policy_is_set(): + assert "Referrer-Policy" in CADDYFILE + + +def test_csp_default_src_is_self(): + assert "Content-Security-Policy" in CADDYFILE + assert "default-src 'self'" in CADDYFILE From a574db0d935524aca9dc470b13f9b38aa18fbda3 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 16:04:24 +0200 Subject: [PATCH 23/29] Align README and running-the-server.md with the Docker-only policy (B-44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md declares the server always runs via Docker (dev and prod alike) with no supported bare-uvicorn workflow, but README's Quick start and docs/running-the-server.md's "Locale / venv" section still documented running uvicorn directly — a leftover from before that policy was adopted. Rewrite both to a single Docker-only path and update CLAUDE.md's own note about it. Verified docker compose run --rm app python scripts/generate_master_key.py against a real build/run to confirm the Quick start's Docker commands actually work as documented. Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 19 +++++--------- CLAUDE.md | 6 ++--- README.md | 35 +++++++++++-------------- docs/running-the-server.md | 27 +++++-------------- tests/unit/test_docs_no_bare_uvicorn.py | 18 +++++++++++++ 5 files changed, 50 insertions(+), 55 deletions(-) create mode 100644 tests/unit/test_docs_no_bare_uvicorn.py diff --git a/BUGS.md b/BUGS.md index d4cdf42..58ecb62 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-44 … B-49. B-25 through B-43 are fixed (see "Previously -fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 6 are +7 medium, 8 low), listed below as B-45 … B-49. B-25 through B-44 are fixed (see "Previously +fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 5 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 nineteen fixes so far brought -the suite from 139 to 230). +by existing coverage — every fix lands with a regression test (the twenty fixes so far brought +the suite from 139 to 232). 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. @@ -18,14 +18,6 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume ## Low / hygiene -### B-44 — README and CLAUDE.md contradict each other - -The README says to run `uvicorn --reload` directly and -`docker compose run --rm app python scripts/generate_master_key.py`; CLAUDE.md says explicitly -that neither is supported. Whoever opens the repo reads the README first. -**Fix:** align the README's Quick start with the Docker-only workflow documented in -CLAUDE.md and `docs/setup.md`. - ### B-45 — Unvalidated and unpaginated admin list endpoints `limit: int = 50` on `/admin/rounds` and `/admin/audit-log` has no bounds (`-1` means @@ -75,6 +67,7 @@ already does. - **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-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 @@ -87,7 +80,7 @@ already does. - **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 fixes). Suite grew from 139 to 230 tests over the nineteen. +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 fixes). Suite grew from 139 to 232 tests over the twenty. 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, diff --git a/CLAUDE.md b/CLAUDE.md index 2a66989..fba738c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,11 +12,11 @@ All 10 stages of the original build order are code-complete and unit-tested — 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 **6 are still open** — no Critical, High or Medium remains, only Low/hygiene: an admin list endpoint with no pagination bound (B-45), unbounded `String` columns for large text (B-47), among others. 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 **5 are still open** — no Critical, High or Medium remains, only Low/hygiene: an admin list endpoint with no pagination bound (B-45), unbounded `String` columns for large text (B-47), among others. 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 .mmd` after editing either. -Human-facing guides are in [docs/](docs/), in Italian by explicit request (an exception to the English-only rule): [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md). **[README.md](README.md)'s Quick start is stale** (bare `uvicorn --reload`, `docker compose run … generate_master_key.py` — neither is supported; B-44); this file and `docs/setup.md` are authoritative. +Human-facing guides are in [docs/](docs/), in Italian by explicit request (an exception to the English-only rule): [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md). README's Quick start and `docs/running-the-server.md` are Docker-only, matching this file — a bare `uvicorn --reload` workflow was removed from both (B-44). ## Commands @@ -233,7 +233,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) (6 open) — not duplicated here. +Accepted **by design**. For actual bugs see [BUGS.md](BUGS.md) (5 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. - **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. diff --git a/README.md b/README.md index bce79a4..cc21160 100644 --- a/README.md +++ b/README.md @@ -13,33 +13,30 @@ production-ready. ## Quick start +The server always runs via Docker (app + Caddy reverse proxy with automatic +TLS) — in dev and production alike, with only `SITE_ADDRESS` differing +between the two. There's no supported way to run `uvicorn` directly; the +venv is only for local tooling (tests, Alembic migrations, the one-time key +scripts) — see [CLAUDE.md](CLAUDE.md#commands). + ```bash cp .env.example .env # then fill in the generated secrets, see docs/setup.md -python3 -m venv .venv && source .venv/bin/activate -pip install -e ".[dev]" -PYTHONPATH=. python scripts/generate_master_key.py -alembic upgrade head -uvicorn app.main:app --reload --port 8123 -``` - -Open `http://127.0.0.1:8123/` for the test UI, `http://127.0.0.1:8123/admin` -for the admin dashboard. The interactive API docs at `/docs` are disabled by -default (they'd otherwise expose the whole API surface, admin endpoints -included) — set `ENABLE_API_DOCS=true` in `.env` for local development to -enable them. - -Or run the whole stack (app + Caddy reverse proxy with automatic TLS) via -Docker: - -```bash mkdir -p data/db data/keys data/logs docker compose run --rm app python scripts/generate_master_key.py docker compose up -d --build ``` +Open `https://localhost/` for the test UI, `https://localhost/admin` for the +admin dashboard (a self-signed-certificate warning on first visit is +expected in dev — accept it, or use `curl -k`). The interactive API docs at +`/docs` are disabled by default (they'd otherwise expose the whole API +surface, admin endpoints included) — set `ENABLE_API_DOCS=true` in `.env` to +enable them. + See [docs/setup.md](docs/setup.md) and [docs/running-the-server.md](docs/running-the-server.md) for the full -walkthrough (both workflows, dev vs. production TLS). +walkthrough (secrets, master key generation, production TLS with a real +domain). ## Documentation @@ -64,7 +61,7 @@ python -m pytest # all tests python -m pytest tests/unit/test_hd.py # one file ``` -76 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +232 unit tests cover HD derivation, PSBT building, the Electrum client, bets, deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config, the pending-inclusive balance calculation, and the SSE push channel. No automated integration tests against a live Electrum connection — mainnet diff --git a/docs/running-the-server.md b/docs/running-the-server.md index 739dcec..ffd8b37 100644 --- a/docs/running-the-server.md +++ b/docs/running-the-server.md @@ -3,27 +3,14 @@ Presuppone che [setup.md](setup.md) sia già stato completato (`.env` pronto, master key generata, migrazioni applicate). -## Locale / venv (sviluppo rapido) +Il server gira sempre via Docker, in sviluppo e in produzione allo stesso +modo — non esiste un modo supportato per lanciare `uvicorn` direttamente. +Il venv locale (`.venv/`) serve solo per i test, per scrivere le migrazioni +Alembic e per gli script una tantum di generazione chiavi (vedi +[setup.md](setup.md) e la sezione "Commands" di +[CLAUDE.md](../CLAUDE.md#commands)). -```bash -source .venv/bin/activate -uvicorn app.main:app --reload --port 8123 -``` - -- App su `http://127.0.0.1:8123/` -- Pannello admin su `http://127.0.0.1:8123/admin` -- Docs API interattive su `http://127.0.0.1:8123/docs` solo se `ENABLE_API_DOCS=true` - in `.env` — disattivate di default perché espongono l'intera API, endpoint - admin inclusi (vedi [setup.md](setup.md)) -- Log applicativi in `logs/app.log` (rotante, 10MB × 5 backup) -- Nessun TLS, nessun reverse proxy — solo per test locali sulla tua macchina. - -Per fermarlo: `Ctrl+C`, oppure se lanciato in background con `nohup`: -```bash -pkill -f "uvicorn app.main:app" -``` - -## Docker + Caddy (consigliato, anche per i test con dominio/TLS) +## Docker + Caddy (unico workflow supportato) ```bash mkdir -p data/db data/keys data/logs # una tantum, se non già presenti diff --git a/tests/unit/test_docs_no_bare_uvicorn.py b/tests/unit/test_docs_no_bare_uvicorn.py new file mode 100644 index 0000000..7999530 --- /dev/null +++ b/tests/unit/test_docs_no_bare_uvicorn.py @@ -0,0 +1,18 @@ +"""B-44: README and docs/running-the-server.md must not document a bare +`uvicorn --reload` workflow — the server always runs via Docker, in dev and +production alike (CLAUDE.md's "Commands" section), and the two files had +drifted back to contradicting that policy.""" + +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent.parent + + +def test_readme_has_no_bare_uvicorn_command(): + readme = (REPO_ROOT / "README.md").read_text() + assert "uvicorn app.main:app --reload" not in readme + + +def test_running_the_server_doc_has_no_bare_uvicorn_command(): + doc = (REPO_ROOT / "docs" / "running-the-server.md").read_text() + assert "uvicorn app.main:app --reload" not in doc From 6045c89ed0768ddadcc8cbaf7780764539dc5af8 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 16:10:53 +0200 Subject: [PATCH 24/29] Bound admin list endpoint limits, add status filter to pending-transactions (B-45) /admin/rounds and /admin/audit-log accepted any limit, including -1 (which SQLite treats as "no limit"), and /admin/pending-transactions had no limit at all -- it grows without end. Add Query(default=..., ge=1, le=500) to all three, plus an optional status filter on pending-transactions. Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 19 +++------ CLAUDE.md | 4 +- app/api/routes/admin.py | 17 +++++--- tests/unit/test_admin.py | 87 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/BUGS.md b/BUGS.md index 58ecb62..c58f377 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-45 … B-49. B-25 through B-44 are fixed (see "Previously -fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 5 are +7 medium, 8 low), listed below as B-46 … B-49. B-25 through B-45 are fixed (see "Previously +fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 4 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 fixes so far brought -the suite from 139 to 232). +by existing coverage — every fix lands with a regression test (the twenty-one fixes so far brought +the suite from 139 to 245). 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. @@ -18,14 +18,6 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume ## Low / hygiene -### B-45 — Unvalidated and unpaginated admin list endpoints - -`limit: int = 50` on `/admin/rounds` and `/admin/audit-log` has no bounds (`-1` means -"everything" on SQLite), and `/admin/pending-transactions` has no limit at all — it grows -without end. -**Fix:** `Query(default=50, ge=1, le=500)` on both, and the same treatment plus a status filter -on the pending-transaction list. - ### B-46 — `secrets.compare_digest` on a `str` raises on non-ASCII input `api/routes/admin.py:27` raises `TypeError` — a 500 instead of a 403 — when the header contains @@ -68,6 +60,7 @@ already does. - **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-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 @@ -80,7 +73,7 @@ already does. - **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 fixes). Suite grew from 139 to 232 tests over the twenty. +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 fixes). Suite grew from 139 to 245 tests over the twenty-one. 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, diff --git a/CLAUDE.md b/CLAUDE.md index fba738c..563fb95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ All 10 stages of the original build order are code-complete and unit-tested — 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 **5 are still open** — no Critical, High or Medium remains, only Low/hygiene: an admin list endpoint with no pagination bound (B-45), unbounded `String` columns for large text (B-47), among others. 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 **4 are still open** — no Critical, High or Medium remains, only Low/hygiene: unbounded `String` columns for large text (B-47), no cap on input count in `select_utxos` (B-48), among others. 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 .mmd` after editing either. @@ -233,7 +233,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) (5 open) — not duplicated here. +Accepted **by design**. For actual bugs see [BUGS.md](BUGS.md) (4 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. - **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. diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 9dde0b0..49790db 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -1,7 +1,7 @@ import json import secrets -from fastapi import APIRouter, Depends, Header, HTTPException, status +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -242,7 +242,9 @@ class AdminRoundResponse(BaseModel): @router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)]) -async def list_rounds(session: AsyncSession = Depends(get_session), limit: int = 50) -> list[AdminRoundResponse]: +async def list_rounds( + session: AsyncSession = Depends(get_session), limit: int = Query(default=50, ge=1, le=500) +) -> list[AdminRoundResponse]: rounds = (await session.scalars(select(Round).order_by(Round.id.desc()).limit(limit))).all() winner_ids = {r.winner_user_id for r in rounds if r.winner_user_id is not None} winners = {} @@ -282,7 +284,7 @@ class AdminAuditLogResponse(BaseModel): "/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)] ) async def list_audit_log( - session: AsyncSession = Depends(get_session), limit: int = 200 + session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500) ) -> list[AdminAuditLogResponse]: entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all() return [ @@ -319,10 +321,13 @@ class AdminPendingTransactionResponse(BaseModel): ) async def list_pending_transactions( session: AsyncSession = Depends(get_session), + limit: int = Query(default=50, ge=1, le=500), + status_filter: str | None = Query(default=None, alias="status"), ) -> list[AdminPendingTransactionResponse]: - entries = ( - await session.scalars(select(PendingTransaction).order_by(PendingTransaction.id.desc())) - ).all() + query = select(PendingTransaction).order_by(PendingTransaction.id.desc()) + if status_filter is not None: + query = query.where(PendingTransaction.status == status_filter) + entries = (await session.scalars(query.limit(limit))).all() return [ AdminPendingTransactionResponse( id=p.id, diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py index bdf8b95..24fc08f 100644 --- a/tests/unit/test_admin.py +++ b/tests/unit/test_admin.py @@ -277,3 +277,90 @@ async def test_pause_cannot_be_toggled_through_the_config_endpoint(client): resp = await client.put("/admin/config", headers=headers, json={"paused": True}) assert resp.status_code in (200, 422) # ignored or refused, but never applied assert (await client.get("/admin/config", headers=headers)).json()["paused"] is False + + +@pytest.mark.parametrize("endpoint", ["/admin/rounds", "/admin/audit-log", "/admin/pending-transactions"]) +@pytest.mark.parametrize("bad_limit", [0, -1, 501]) +async def test_admin_list_endpoints_reject_out_of_range_limit(client, endpoint, bad_limit): + """B-45: `limit` had no bounds — `-1` means "everything" on SQLite, so an + unvalidated limit could dump the entire table in one response.""" + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get(endpoint, headers=headers, params={"limit": bad_limit}) + assert resp.status_code == 422 + + +async def test_admin_list_rounds_respects_limit(client): + from app.db import base as db_base + from app.db.models import Round + + async with db_base.AsyncSessionLocal() as session: + session.add_all([Round(status="closed") for _ in range(3)]) + await session.commit() + + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get("/admin/rounds", headers=headers, params={"limit": 2}) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +async def test_admin_audit_log_respects_limit(client): + from app.db import base as db_base + from app.audit.log import write_audit_log + + async with db_base.AsyncSessionLocal() as session: + for _ in range(3): + await write_audit_log(session, "test_event", {}) + await session.commit() + + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get("/admin/audit-log", headers=headers, params={"limit": 2}) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +async def _make_pending_transaction(session, *, kind="bet", status="pending"): + from app.db.models import PendingTransaction + import secrets as _secrets + + tx = PendingTransaction( + kind=kind, + current_txid=_secrets.token_hex(32), + fee_rate_sat_vb=1, + raw_tx_hex="00", + status=status, + ) + session.add(tx) + return tx + + +async def test_admin_pending_transactions_respects_limit(client): + from app.db import base as db_base + + async with db_base.AsyncSessionLocal() as session: + for _ in range(3): + await _make_pending_transaction(session) + await session.commit() + + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get("/admin/pending-transactions", headers=headers, params={"limit": 2}) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +async def test_admin_pending_transactions_status_filter(client): + from app.db import base as db_base + + async with db_base.AsyncSessionLocal() as session: + await _make_pending_transaction(session, status="pending") + await _make_pending_transaction(session, status="confirmed") + await _make_pending_transaction(session, status="failed") + await session.commit() + + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get( + "/admin/pending-transactions", headers=headers, params={"status": "confirmed"} + ) + assert resp.status_code == 200 + entries = resp.json() + assert len(entries) == 1 + assert entries[0]["status"] == "confirmed" From 31bc9a327f6f1a65d746765c496aa907598e05b4 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 16:17:40 +0200 Subject: [PATCH 25/29] Compare admin token as UTF-8 bytes to avoid TypeError on non-ASCII input (B-46) secrets.compare_digest raises TypeError instead of returning False when a str argument contains non-ASCII characters, turning a bad admin token into an unhandled 500 instead of the expected 403. Encode both sides before comparing. Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 17 ++++++----------- CLAUDE.md | 4 ++-- app/api/routes/admin.py | 4 +++- tests/unit/test_admin.py | 9 +++++++++ 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/BUGS.md b/BUGS.md index c58f377..4581639 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-46 … B-49. B-25 through B-45 are fixed (see "Previously -fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 4 are +7 medium, 8 low), listed below as B-47 … B-49. B-25 through B-46 are fixed (see "Previously +fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 3 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-one fixes so far brought -the suite from 139 to 245). +by existing coverage — every fix lands with a regression test (the twenty-two fixes so far brought +the suite from 139 to 246). 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. @@ -18,12 +18,6 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume ## Low / hygiene -### B-46 — `secrets.compare_digest` on a `str` raises on non-ASCII input - -`api/routes/admin.py:27` raises `TypeError` — a 500 instead of a 403 — when the header contains -non-ASCII characters. -**Fix:** compare the UTF-8 encoded bytes of both sides. - ### B-47 — Unbounded `String` columns for large text `raw_tx_hex` (`db/models.py:146`) and `payload_json` (`:178`) should be `Text`. It works on @@ -61,6 +55,7 @@ already does. - **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-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 @@ -73,7 +68,7 @@ already does. - **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 fixes). Suite grew from 139 to 245 tests over the twenty-one. +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 fixes). Suite grew from 139 to 246 tests over the twenty-two. 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, diff --git a/CLAUDE.md b/CLAUDE.md index 563fb95..01fd4ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ All 10 stages of the original build order are code-complete and unit-tested — 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 **4 are still open** — no Critical, High or Medium remains, only Low/hygiene: unbounded `String` columns for large text (B-47), no cap on input count in `select_utxos` (B-48), among others. 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 **3 are still open** — no Critical, High or Medium remains, only Low/hygiene: unbounded `String` columns for large text (B-47), no cap on input count in `select_utxos` (B-48), among others. 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 .mmd` after editing either. @@ -233,7 +233,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) (4 open) — not duplicated here. +Accepted **by design**. For actual bugs see [BUGS.md](BUGS.md) (3 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. - **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. diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 49790db..6651485 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -26,7 +26,9 @@ async def require_admin(x_admin_token: str = Header(default="")) -> None: # anyone on an instance that never configured a token. if not settings.admin_token: raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token") - if not secrets.compare_digest(x_admin_token, settings.admin_token): + # compare_digest raises TypeError on a str containing non-ASCII characters + # (B-46) -- comparing the UTF-8 bytes instead accepts any input safely. + if not secrets.compare_digest(x_admin_token.encode(), settings.admin_token.encode()): raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token") diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py index 24fc08f..e17c4a7 100644 --- a/tests/unit/test_admin.py +++ b/tests/unit/test_admin.py @@ -67,6 +67,15 @@ async def test_admin_rejects_wrong_token(client): assert resp.status_code == 403 +async def test_admin_rejects_non_ascii_token_with_403_not_500(client): + """B-46: secrets.compare_digest raises TypeError on a non-ASCII str, which + used to bubble up as a 500 instead of the expected 403. httpx encodes str + header values as ASCII client-side, so the raw UTF-8 bytes are passed + directly to reproduce what a real non-ASCII header on the wire looks like.""" + resp = await client.get("/admin/config", headers={"X-Admin-Token": "café".encode("utf-8")}) + assert resp.status_code == 403 + + async def test_admin_reads_and_updates_config(client): headers = {"X-Admin-Token": "test-admin-token"} From 6a90136b5044196151f49e5fcb8d8693d7151427 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 16:24:05 +0200 Subject: [PATCH 26/29] Widen raw_tx_hex and payload_json from String to Text (B-47) Both held arbitrary-length data (a raw signed transaction hex, an audit payload) in a bare String, which SQLAlchemy compiles to VARCHAR with no length. SQLite and PostgreSQL accept that; other backends like MySQL require a length on VARCHAR and would reject it. Add a migration (verified upgrade/downgrade/upgrade round-trip, and confirmed with `alembic check` that it leaves no further diff against the models). Co-Authored-By: Claude Sonnet 5 --- BUGS.md | 17 +++------ CLAUDE.md | 4 +- app/db/models.py | 6 +-- ...c_widen_raw_tx_hex_and_payload_json_to_.py | 38 +++++++++++++++++++ tests/unit/test_models_text_columns.py | 16 ++++++++ 5 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 migrations/versions/87a0c640355c_widen_raw_tx_hex_and_payload_json_to_.py create mode 100644 tests/unit/test_models_text_columns.py diff --git a/BUGS.md b/BUGS.md index 4581639..d0a765b 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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-47 … B-49. B-25 through B-46 are fixed (see "Previously -fixed" below) — no Critical-, High- or Medium-severity finding remains open; the remaining 3 are +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-two fixes so far brought -the suite from 139 to 246). +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. @@ -18,12 +18,6 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume ## Low / hygiene -### B-47 — Unbounded `String` columns for large text - -`raw_tx_hex` (`db/models.py:146`) and `payload_json` (`:178`) should be `Text`. It works on -SQLite and PostgreSQL and breaks elsewhere. -**Fix:** switch both to `Text` in a migration. - ### 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 @@ -56,6 +50,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-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 @@ -68,7 +63,7 @@ already does. - **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 fixes). Suite grew from 139 to 246 tests over the twenty-two. +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, diff --git a/CLAUDE.md b/CLAUDE.md index 01fd4ce..b95d1e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ All 10 stages of the original build order are code-complete and unit-tested — 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 **3 are still open** — no Critical, High or Medium remains, only Low/hygiene: unbounded `String` columns for large text (B-47), no cap on input count in `select_utxos` (B-48), among others. 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 **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. 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 .mmd` after editing either. @@ -233,7 +233,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) (3 open) — not duplicated here. +Accepted **by design**. For actual bugs see [BUGS.md](BUGS.md) (2 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. - **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. diff --git a/app/db/models.py b/app/db/models.py index 7750d47..f4e672f 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sqlalchemy import BigInteger, ForeignKey, Index, String, UniqueConstraint, text +from sqlalchemy import BigInteger, ForeignKey, Index, String, Text, UniqueConstraint, text from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -154,7 +154,7 @@ class PendingTransaction(Base): user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None) current_txid: Mapped[str] = mapped_column(String(64)) fee_rate_sat_vb: Mapped[int] - raw_tx_hex: Mapped[str] = mapped_column(String) + raw_tx_hex: Mapped[str] = mapped_column(Text) # The *first* broadcast — never rewritten by a bump — since this is what the # reconciler's abandon-after-N-hours grace period (app/tx/reconcile.py) measures # from. Bumping used to overwrite this field, which reset that clock on every @@ -193,7 +193,7 @@ class AuditLog(Base): id: Mapped[int] = mapped_column(primary_key=True) event_type: Mapped[str] = mapped_column(String(32)) - payload_json: Mapped[str] = mapped_column(String) + payload_json: Mapped[str] = mapped_column(Text) user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None) round_id: Mapped[int | None] = mapped_column(ForeignKey("rounds.id"), default=None) created_at: Mapped[datetime] = mapped_column(default=utcnow) diff --git a/migrations/versions/87a0c640355c_widen_raw_tx_hex_and_payload_json_to_.py b/migrations/versions/87a0c640355c_widen_raw_tx_hex_and_payload_json_to_.py new file mode 100644 index 0000000..c7200d7 --- /dev/null +++ b/migrations/versions/87a0c640355c_widen_raw_tx_hex_and_payload_json_to_.py @@ -0,0 +1,38 @@ +"""widen raw_tx_hex and payload_json to Text + +Fixes B-47: both columns held arbitrary-length data (a raw signed transaction +hex, and a JSON audit payload) in an unbounded `String`, which SQLAlchemy +compiles to `VARCHAR` with no length. That's accepted by SQLite and +PostgreSQL but rejected by other backends (e.g. MySQL requires a length on +VARCHAR) — `Text` is the portable type for both. + +Revision ID: 87a0c640355c +Revises: 9ef6a51509f7 +Create Date: 2026-07-27 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '87a0c640355c' +down_revision: Union[str, Sequence[str], None] = '9ef6a51509f7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('audit_log') as batch_op: + batch_op.alter_column('payload_json', existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=False) + with op.batch_alter_table('pending_transactions') as batch_op: + batch_op.alter_column('raw_tx_hex', existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=False) + + +def downgrade() -> None: + with op.batch_alter_table('pending_transactions') as batch_op: + batch_op.alter_column('raw_tx_hex', existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=False) + with op.batch_alter_table('audit_log') as batch_op: + batch_op.alter_column('payload_json', existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=False) diff --git a/tests/unit/test_models_text_columns.py b/tests/unit/test_models_text_columns.py new file mode 100644 index 0000000..c2ac5c4 --- /dev/null +++ b/tests/unit/test_models_text_columns.py @@ -0,0 +1,16 @@ +"""B-47: raw_tx_hex (a full raw signed transaction hex) and payload_json (an +arbitrary audit payload) must stay `Text`, not a bare `String`/`VARCHAR` with +no length -- SQLite and PostgreSQL accept that, but other backends (e.g. +MySQL) require a length on VARCHAR and would reject it.""" + +from sqlalchemy import Text + +from app.db.models import AuditLog, PendingTransaction + + +def test_pending_transaction_raw_tx_hex_is_text(): + assert isinstance(PendingTransaction.__table__.c.raw_tx_hex.type, Text) + + +def test_audit_log_payload_json_is_text(): + assert isinstance(AuditLog.__table__.c.payload_json.type, Text) From 4c80c1c5bf601b5a78f5b56aaa05a2deb2a1dd9a Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 23:30:06 +0200 Subject: [PATCH 27/29] Cap the number of inputs a transaction may spend (B-48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- BUGS.md | 17 +++++------------ CLAUDE.md | 9 +++++---- app/bets/service.py | 2 +- app/rounds/scheduler.py | 9 ++++++--- app/static/i18n.js | 7 +++++++ app/wallet/psbt_builder.py | 32 +++++++++++++++++++++++++++++--- app/withdrawals/service.py | 2 +- tests/unit/test_bets.py | 30 ++++++++++++++++++++++++++++++ tests/unit/test_psbt_builder.py | 17 +++++++++++++++++ 9 files changed, 101 insertions(+), 24 deletions(-) diff --git a/BUGS.md b/BUGS.md index d0a765b..602cd15 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,11 +1,11 @@ # 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 +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 1 is 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). +by existing coverage — every fix lands with a regression test (the twenty-four fixes so far +brought the suite from 139 to 251). 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. @@ -18,14 +18,6 @@ admin auth, single-process assumptions, no user-facing history, etc.) are docume ## 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` @@ -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-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-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-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 diff --git a/CLAUDE.md b/CLAUDE.md index b95d1e9..3838ddb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 — 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. -**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 .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 251 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). @@ -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**. 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. - **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. diff --git a/app/bets/service.py b/app/bets/service.py index 572f7ef..28070d9 100644 --- a/app/bets/service.py +++ b/app/bets/service.py @@ -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 diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index f7f93d4..28f5647 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -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 diff --git a/app/static/i18n.js b/app/static/i18n.js index ae25689..38fa014 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -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': '指南当前不可用。', diff --git a/app/wallet/psbt_builder.py b/app/wallet/psbt_builder.py index b822617..26f89f7 100644 --- a/app/wallet/psbt_builder.py +++ b/app/wallet/psbt_builder.py @@ -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: diff --git a/app/withdrawals/service.py b/app/withdrawals/service.py index 84a7b6c..7cf8678 100644 --- a/app/withdrawals/service.py +++ b/app/withdrawals/service.py @@ -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). diff --git a/tests/unit/test_bets.py b/tests/unit/test_bets.py index aa2886a..8567b43 100644 --- a/tests/unit/test_bets.py +++ b/tests/unit/test_bets.py @@ -10,6 +10,7 @@ from app.db.base import Base from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent 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 +92,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() diff --git a/tests/unit/test_psbt_builder.py b/tests/unit/test_psbt_builder.py index fcd0686..4f8eb6e 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_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()) From e7f844b11f1234744b709d811dd1f7e25a380c0f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 23:35:10 +0200 Subject: [PATCH 28/29] Publish an SSE update from the bet/withdrawal rollback paths (B-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- BUGS.md | 36 ++++++++++++---------------------- CLAUDE.md | 10 +++++----- app/bets/service.py | 5 +++++ app/withdrawals/service.py | 1 + tests/unit/test_bets.py | 28 ++++++++++++++++++++++++++ tests/unit/test_withdrawals.py | 26 ++++++++++++++++++++++++ 6 files changed, 78 insertions(+), 28 deletions(-) diff --git a/BUGS.md b/BUGS.md index 602cd15..9ea0621 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,30 +1,19 @@ # 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-49. B-25 through B-48 are fixed (see "Previously -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 -by existing coverage — every fix lands with a regression test (the twenty-four fixes so far -brought the suite from 139 to 251). +7 medium, 8 low). B-25 through B-49 are now **all fixed** (see "Previously fixed" below) — +nothing from either audit remains open. The 139-test suite was green at the time of the audit, +so none of these were caught by existing coverage — every fix landed with a regression test, +which brought the suite from 139 to 253. -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-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. +The recurring pattern behind those findings is worth keeping in mind for the next one: 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 the by-design limitations (single-shared-token +admin auth, single-process assumptions, no user-facing history, etc.) are documented. This file +stays as the record of what was found and fixed; a new finding gets the next B-nn and its own +regression test. --- @@ -42,6 +31,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-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-49** — `_release_failed_bet`/`_release_failed_withdrawal` restored the balance without publishing an SSE update, so a rolled-back bet or withdrawal stayed on the dashboards until their next poll - **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-32** — an RBF bump could retry forever below BIP125's relay-mandated minimum fee delta, with no ceiling on the fee rate either diff --git a/CLAUDE.md b/CLAUDE.md index 3838ddb..a983235 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 — 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. +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 **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. +**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), all fixed as of 2026-07-27 — **nothing is open**. BUGS.md is the record of what each one was; "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 .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 251 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 ``` @@ -181,7 +181,7 @@ At 120s blocks that's ~4–6 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). @@ -234,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) (1 open) — not duplicated here. +Accepted **by design**. For actual bugs see [BUGS.md](BUGS.md) (none 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. - **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. diff --git a/app/bets/service.py b/app/bets/service.py index 28070d9..37854fa 100644 --- a/app/bets/service.py +++ b/app/bets/service.py @@ -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( diff --git a/app/withdrawals/service.py b/app/withdrawals/service.py index 7cf8678..dc32af0 100644 --- a/app/withdrawals/service.py +++ b/app/withdrawals/service.py @@ -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) diff --git a/tests/unit/test_bets.py b/tests/unit/test_bets.py index 8567b43..a76efc2 100644 --- a/tests/unit/test_bets.py +++ b/tests/unit/test_bets.py @@ -8,6 +8,7 @@ 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 @@ -196,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) diff --git a/tests/unit/test_withdrawals.py b/tests/unit/test_withdrawals.py index 975a1ba..8691a5c 100644 --- a/tests/unit/test_withdrawals.py +++ b/tests/unit/test_withdrawals.py @@ -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.""" From 0d2fef650256630d937186f12fd1daaeda845c01 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 27 Jul 2026 23:37:02 +0200 Subject: [PATCH 29/29] Delete BUGS.md now that both audits' findings are closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- BUGS.md | 58 ----------------------------------------------------- CLAUDE.md | 4 ++-- app/main.py | 2 +- 3 files changed, 3 insertions(+), 61 deletions(-) delete mode 100644 BUGS.md diff --git a/BUGS.md b/BUGS.md deleted file mode 100644 index 9ea0621..0000000 --- a/BUGS.md +++ /dev/null @@ -1,58 +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). B-25 through B-49 are now **all fixed** (see "Previously fixed" below) — -nothing from either audit remains open. The 139-test suite was green at the time of the audit, -so none of these were caught by existing coverage — every fix landed with a regression test, -which brought the suite from 139 to 253. - -The recurring pattern behind those findings is worth keeping in mind for the next one: 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 the by-design limitations (single-shared-token -admin auth, single-process assumptions, no user-facing history, etc.) are documented. This file -stays as the record of what was found and fixed; a new finding gets the next B-nn and its own -regression test. - ---- - -## 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-49** — `_release_failed_bet`/`_release_failed_withdrawal` restored the balance without publishing an SSE update, so a rolled-back bet or withdrawal stayed on the dashboards until their next poll -- **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-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). diff --git a/CLAUDE.md b/CLAUDE.md index a983235..a70bbb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ All 10 stages of the original build order are code-complete and unit-tested — 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), all fixed as of 2026-07-27 — **nothing is open**. BUGS.md is the record of what each one was; "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 .mmd` after editing either. @@ -234,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) (none 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. diff --git a/app/main.py b/app/main.py index ff547d0..82c87bd 100644 --- a/app/main.py +++ b/app/main.py @@ -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(