_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 <noreply@anthropic.com>
432 lines
25 KiB
Markdown
432 lines
25 KiB
Markdown
# 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).
|
|
|
|
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.
|
|
|
|
**`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
|
|
[CLAUDE.md](CLAUDE.md).
|
|
|
|
---
|
|
|
|
## 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
|
|
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
|
|
|
|
`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
|
|
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
|
|
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
|
|
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 needs B-27 fixed to
|
|
actually act on 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
|
|
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
|
|
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
|
|
|
|
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
|
|
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
|
|
`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
|
|
**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
|
|
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
|
|
`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.
|
|
|
|
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
|
|
|
|
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
|
|
(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
|
|
|
|
### 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`).
|
|
|
|
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).
|