Files
plm-lottery/BUGS.md
T
davideandClaude Opus 5 8a0ebecfcc Never let a draw be seeded by a block that predates the close (B-63)
ElectrumListener._run_once assigned self.client before subscribe_headers()
returned, so there was a window — one round-trip wide, at process start — where
the connection looked alive while tip_height was still its initial 0.
"client is not None" is what every consumer reads as "the chain is reachable",
RoundScheduler._tick included, and a round closing inside that window recorded
tip_at_close = 0. The very first header we then learned about — the current tip,
a block mined *before* the round closed, whose hash was already public while
bets were still open — satisfied tip_height > tip_at_close and became the draw's
entropy. The draw's whole guarantee is that its seed did not exist yet when
betting stopped.

Two changes, defending different things:

- The client is published only once the first header has been applied, so
  "client is not None" now means "reachable *and* we know where the chain is".
  During the window consumers see no connection, which is honest: a bet gets the
  same 503 it already gets while disconnected, and the background tasks skip a
  cycle as they already do.

- _wait_for_next_block treats a baseline of 0 as *unknown*, not as height zero:
  it adopts the first height it learns as the baseline, waits for a block
  strictly after it, and records draw_baseline_tip_unknown so the extra block of
  waiting is explainable from /admin. Unreachable via the listener now, but it is
  the local statement of what the draw requires, and nothing else in that
  function would notice if the invariant stopped holding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:01:21 +02:00

162 lines
7.8 KiB
Markdown

# BUGS.md — audit of 2026-08-03
Third full-codebase audit, opened after the 2026-07-26 (B-01 … B-24) and
2026-07-27 (B-25 … B-49) lists were emptied. Numbering continues from the last
fixed finding, B-51.
The list opened at B-52 … B-72 and holds only what is still **open**: a finding is
removed from this file once it is fixed, and is not listed here afterwards. Per
CLAUDE.md's convention each entry gets its own commit with its own regression test,
and the `B-nn` marker goes in a comment next to the fix, so
`git log --all --grep 'B-nn'` is the record of how any closed finding was closed.
State of the tree at audit time: 264 unit tests, all passing; `tests/integration/`
still empty; withdrawal and the RBF bump path still never live-broadcast.
Verified as *not* broken while looking for these: i18n key parity (146 identical
keys across all 7 languages), HTML escaping of every user-controlled value in
`admin.js`, the Alembic chain (linear, single head, matching `models.py`),
strictly-integer satoshi arithmetic everywhere, and `.gitignore` coverage of
secrets/DB/logs (nothing sensitive is tracked in git).
Severity is about consequence, not likelihood:
**critical** = money stuck or lost, or the lottery stops;
**high** = a security control that does not hold;
**medium** = wrong behaviour with a bounded blast radius;
**low** = drift between documentation and code.
---
## Critical
### (not new) `drawing` does not resume after a restart
Already tracked as an accepted gap in CLAUDE.md's "Known gaps", not re-numbered
here. Worth restating in context: with `restart: unless-stopped` on the app
container, this is the one state that gets stuck with money in play, and it
remains the last prerequisite for running unattended.
---
## Medium — correctness and robustness
### B-64 — `_apply_header` accepts a same-height header without the chaining check
`app/electrum/listener.py:271-296`.
The chain check only runs for `height == self.tip_height + 1`. A header at exactly
the current tip height replaces `tip_header_hex` after passing only the
self-target check — which, as the docstring of `header_meets_its_own_target`
already notes, a server can satisfy with a self-declared easy target. Separately,
a header with no `hex` field sets `tip_header_hex = None`, discarding a tip we
otherwise accepted.
Fix: treat a same-height header as either ignorable or as a reorg signal rather
than silently replacing the entropy source, and don't clear `tip_header_hex` on a
hex-less notification.
### B-65 — `/rounds/current` counts unconfirmed participants; the draw and payout do not
`app/api/routes/rounds.py:131-143` vs `app/rounds/scheduler.py:126`.
`participant_count` and `jackpot_sats` are computed over *all* `round_participants`
rows, while the draw and the payout only use `status == "confirmed"`. So the
advertised jackpot can exceed what is actually paid out, and a participant whose
bet is later abandoned appears in the count and then vanishes from it.
Fix: either count only `confirmed` (and accept that a fresh bet takes a block to
show up), or expose the two figures separately (confirmed vs in-flight) so the
number on screen and the number that gets paid agree by construction.
### B-66 — nothing stops rounds from opening with no `fee_address` configured
`app/rounds/config.py:12-17`, `app/rounds/scheduler.py:330-335`.
A fresh instance starts with `fee_address = ""`. Rounds open, bets are accepted and
confirm, and only then does the payout refuse to build — leaving the round in
`paying_out`, retrying every 60 s, with the audit log as the only signal.
Fix: refuse to open a round while `fee_address` is unset (and surface it on
`/admin` and as a maintenance-style banner), so the failure happens before anyone's
money is committed.
### B-67 — `/qr/{address}` is unauthenticated, synchronous and only shape-validated
`app/api/routes/qr.py:11-21`.
`qrcode.make` runs on the event loop, so the endpoint is a cheap CPU amplifier for
an unauthenticated caller, and the regex accepts any `plm1[a-z0-9]{10,90}` string
without validating the bech32 checksum — so it happily renders a QR for a
non-address.
Fix: validate with `is_valid_plm_address` (already used by withdrawals and by the
admin `fee_address` validator), and either offload the render or cache it per
address.
---
## Low — documentation and consistency drift
### B-68 — CLAUDE.md and README describe a state the code has moved past
- CLAUDE.md's tech-stack line still says JWT has **no revocation (B-34)**; `token_version`
implements exactly that revocation (`app/db/models.py:29`, `app/auth/dependencies.py:26`).
- "Known gaps" still says **no rate limiting anywhere (B-33)**; login and registration
are throttled (`app/auth/rate_limit.py`). What is genuinely still unthrottled is
bets, withdrawals and the admin endpoints — that is the claim worth keeping.
- "Known gaps" still says **`/report-bug` is a placeholder**; it is fully implemented
and translated, with admin triage. Only `/guida` is still a stub.
- Test counts are stale in three places: 264 actual, CLAUDE.md says 253 twice,
README says 232.
- The code map omits `app/auth/rate_limit.py`, `app/api/client_ip.py` and
`app/api/routes/bug_reports.py`.
- README links `flowchart.mmd`, which does not exist (the diagrams live in
`flowchart/`), describes `docs/running-the-server.md` as covering "local venv vs.
Docker" (that workflow was removed in B-44), and links the anchor
`CLAUDE.md#tech-stack-mvp`, which no longer exists.
### B-69 — stale in-code comments
- `app/tx/reconcile.py:206` calls the payout retry "a future payout-retry routine —
still an open gap"; it exists (B-26).
- `app/db/base.py:9` says "five concurrent background tasks"; there are six.
- `app/auth/routes.py:31` cites B-31 where it means B-33 (see B-58).
### B-70 — the flowchart's WITHDRAW precondition is not implemented as written
`flowchart/platform-overview.mmd:39` (node E1) states a withdrawal cannot happen
together with a bet in progress. The code only serializes the *builds* through the
per-user lock (`app/tx/locks.py`): a withdrawal is accepted while a bet is still
unconfirmed, as long as confirmed UTXOs cover it.
CLAUDE.md declares every node and edge label of the diagrams a behaviour that must
be implemented as described, so one of the two has to move — most likely the
diagram, since the lock already prevents the actual double-spend hazard, but that
is a decision, not a cleanup.
### B-71 — `.env` points `MASTER_KEY_PATH` at a second copy of the master key
CLAUDE.md's deployment section prescribes pointing `MASTER_KEY_PATH` at the
host-side `./data/keys/master.xprv.enc` so the venv scripts and the container read
one file. `.env` instead sets `./master.xprv.enc`, and both files now exist in the
working tree (both gitignored). They were verified during this audit to decrypt to
the *same* xprv, so nothing has diverged yet — but `scripts/decrypt_master_key.py`
reads a different file from the one the container uses, and a future
`generate_master_key.py --overwrite` would split them silently, with an ops
recovery path that then reports the wrong key.
The same duplication exists for the database (`./plm_lottery.db` next to
`data/db/plm_lottery.db`), which is less dangerous but equally confusing.
Fix: set `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, delete the stray
root copy once confirmed redundant, and state the same for `DATABASE_URL`.
### B-72 — `docs/setup.md` still frames setup as "locally or via Docker"
`docs/setup.md:1-10` lists Python as a prerequisite "for the local/venv workflow"
and describes the master-key step as local *or* Docker, while B-44 made Docker the
only supported way to run the server (`docs/running-the-server.md` and README were
updated, this file was not). The venv genuinely is needed for tests, migrations and
the key scripts — the wording just needs to say that instead of implying a second
way to run the server.