Third full-codebase audit, after the two earlier lists (B-01 … B-24 and B-25 … B-49) were emptied and BUGS.md deleted. Analysis only — nothing is fixed yet; each entry gets its own commit with its own regression test. Branched off feature/bug-reports rather than main on purpose: the audit describes this tree, /report-bug included (see B-68). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
17 KiB
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.
Nothing in this list is fixed yet — it is the analysis pass only. 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'
finds it later.
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
B-52 — a round with more than ~50 participants deadlocks the platform permanently
app/wallet/psbt_builder.py:42 (MAX_TX_INPUTS = 50),
app/rounds/scheduler.py:349-373.
Every confirmed bet leaves exactly one UTXO on the pool address, and the payout's
selection target is winner_share + commission, i.e. the whole pool — so it needs
all n bet UTXOs as inputs. At n ≥ 51 select_utxos raises too_many_inputs,
_trigger_payout records payout_failed, and _retry_payout_if_due re-attempts
every 60 s forever. The round stays paying_out, so open_new_round_if_needed
never opens another round: the lottery halts, the pool is unspendable through the
normal path, and the only way out is a manual consolidation with the pool key.
CLAUDE.md presents B-48's input cap purely as a fragmented user address problem. The pool case is structural rather than an edge case: participant count alone causes it, with the default bet amount and no unusual deposit pattern.
Fix directions: consolidate the pool between rounds (a sweep tx from pool to pool), or let a payout span more than one transaction, or — as an interim guard — cap participants per round and reject bets past the cap with a translatable error. Whatever the choice, it needs a regression test at n = MAX_TX_INPUTS + 1.
B-53 — a bet can pay into the pool and still be left out of the draw
app/bets/service.py:80-92, app/rounds/scheduler.py:101-129.
place_bet commits its participant row as building before broadcasting
(the deliberate two-phase write of B-08). The scheduler counts in-flight
participants in one session and then reads the confirmed participants in a
second, separate session. A bet that passed round_accepts_bets just before the
deadline can commit its phase-1 row between those two queries: the count saw
zero, so the round draws and pays out, while the new row — not yet confirmed —
is excluded from participants. The bet then confirms normally and its sats land
in the pool address, credited to no round and to no participant. There is no
refund path, and the money silently improves the next round's payout change.
The window is one task switch wide, but both queries do real DB I/O, so it is reachable rather than theoretical.
Fix directions: re-check the in-flight count inside the same transaction that snapshots the participants (and abort the close if it is non-zero), or make the deadline authoritative at the row level so a bet cannot commit against a round whose timer has expired.
(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.
High — security
B-54 — X-Forwarded-For is trusted blindly, so every IP-keyed control is bypassable
app/api/client_ip.py:14-17, Caddyfile.
client_ip() returns xff.split(",")[0]. Caddy's reverse_proxy appends the
real peer address to an incoming X-Forwarded-For rather than replacing it, so
the first element is whatever the client sent. Rotating a fake value per request
defeats login_ip, register_ip (B-33) and the per-IP SSE subscriber cap
(B-38) outright; only the per-username login bucket still bites.
Fix: take the last element of the header, or force it at the proxy with
header_up X-Forwarded-For {remote_host} and keep reading element 0. The proxy-side
fix is the more robust of the two, since it makes the app's assumption true.
B-55 — Argon2 hashing runs on the event loop
app/auth/security.py:20-39, called from app/auth/routes.py and
app/api/routes/users.py.
hash_password/verify_password are synchronous and cost tens of milliseconds
each, so every login, registration and password change blocks the whole process —
including all six background tasks (scheduler, confirmation poller, RBF bumper,
listener, both reconcilers). A burst of unauthenticated login attempts (which,
per B-54, is not effectively throttled) is a cheap denial of service that also
delays draws and confirmations.
Fix: run both through starlette.concurrency.run_in_threadpool.
B-56 — RateLimiter._buckets is never pruned
app/auth/rate_limit.py:37.
The dict grows without bound, keyed by attacker-chosen strings (arbitrary
usernames, and — via B-54 — arbitrary IPs). decay_seconds ages a bucket's
counter but never removes the entry.
Fix: evict entries whose last failure is older than decay_seconds (opportunistically
on record_failure, or on a periodic sweep), and cap the dict size.
B-57 — username matching is case-sensitive while the login throttle key is not
app/auth/routes.py:126 (body.username.lower()) vs :132
(User.username == body.username).
Two consequences: Bob and bob are separate accounts sharing a single rate-limit
bucket (one locks the other out), and registration happily accepts near-duplicate
usernames, which is an impersonation vector on a custodial system.
Fix: make username uniqueness case-insensitive (store a normalized form, or a functional unique index) and key the throttle on the same normalized value.
B-58 — the registration throttle counts successes as failures and is IP-only
app/auth/routes.py:66-71.
record_failure is called on every registration attempt, successful ones
included. Five legitimate signups from one shared/NAT address lock the sixth real
user out with exponential backoff up to 600 s — while an attacker skips the limiter
entirely through B-54. The intent (bounding accounts per source) is reasonable;
the current shape punishes only honest users. The inline comment also cites B-31
(the resubscribe finding) where it means B-33.
Fix: keep an accounts-per-IP quota if that is the goal, but express it as a quota rather than as failure backoff, and fix the B-nn reference.
B-59 — deposit crediting is not corroborated, unlike external-spend detection
app/deposits/service.py:31-45, app/electrum/listener.py:401-433.
A candidate external spend is corroborated across the other configured servers
before it can reduce a balance (B-29), but value and height for a credit
are taken from the single active connection with no cross-check. A hostile or
broken server can inflate a user's displayed balance with outpoints that do not
exist. The blast radius is bounded — a bet or withdrawal built on a phantom UTXO
is refused at broadcast and the rollback releases it — but it wedges the user's
balance display and burns build attempts.
Fix: either corroborate credits the same way (symmetry with B-29), or record the asymmetry explicitly as an accepted risk in CLAUDE.md with its bound stated.
B-60 — POST /admin/bug-reports/{id}/status writes no audit entry
app/api/routes/admin.py:404-419.
Every other admin mutation (config edit, pause/resume, privkey export, password
reset) is audit-logged. This one is not, so a report can be silently marked
resolved with no trace — and with one shared ADMIN_TOKEN and no per-admin
identity, the audit log is the only accountability there is.
Medium — correctness and robustness
B-61 — config edits apply retroactively to the round already in progress
app/rounds/scheduler.py:71, app/rounds/service.py:51-61,
app/api/routes/admin.py:106-128.
round_duration_seconds is read live on every tick and on every bet check, and the
deadline is computed as opened_at + duration. Lowering it from 600 to 60 while a
round is 300 s in closes that round instantly; raising it moves the closes_at
clients are already counting down to. round_cooldown_seconds has the same
property for the gap after a close.
B-11 fixed exactly this class of problem for bet_amount_sats (an in-progress
round's advertised jackpot must not move when an operator edits the bet amount);
the timing fields were left live.
Fix: snapshot the duration (and cooldown) onto the Round row when it opens and
read them from there, leaving the config row as the value for the next round.
B-62 — "withdraw the full amount" reliably produces an unbumpable transaction
app/static/app.js:798-807, app/wallet/psbt_builder.py:138-141,
app/tx/broadcast.py:150-152.
The max-amount checkbox sends amount_sats == myBalanceSats == total_in, so
change == 0, the change output is dropped, and the tx has a single output. bump_fee
then finds no change output to absorb the increase and raises RbfError every 30 s
until the reconciler abandons the row six hours later. The RBF single-change-output
limitation is a documented gap, but the UI makes it the default withdrawal path
rather than a corner case (the same applies to an exact-amount bet).
Fix directions: leave a change output above DUST_LIMIT_SATS when the requested
amount would consume the whole input total (i.e. reserve a little), or warn in the
UI that a full-balance withdrawal cannot be fee-bumped, or implement the
extra-input RBF fallback.
B-63 — tip_height == 0 window right after connecting can seed a draw from a pre-close block
app/electrum/listener.py:160-167, app/rounds/scheduler.py:153.
_run_once assigns self.client before subscribe_headers() returns, so there
is a window in which the client looks alive while tip_height is still 0 and
tip_header_hex is None. A _close_and_draw entering that window records
tip_at_close = 0, and the first header applied — the current tip, a block mined
before the round closed — satisfies tip_height > tip_at_close and becomes the
draw's entropy. The draw must use a block that did not exist at close time; a block
whose hash was already public before betting closed is not the guarantee the
flowchart describes.
Fix: publish self.client only after the first header has been applied, or refuse
to draw while tip_header_hex is None / tip_height == 0.
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_versionimplements 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-bugis a placeholder; it is fully implemented and translated, with admin triage. Only/guidais 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.pyandapp/api/routes/bug_reports.py. - README links
flowchart.mmd, which does not exist (the diagrams live inflowchart/), describesdocs/running-the-server.mdas covering "local venv vs. Docker" (that workflow was removed in B-44), and links the anchorCLAUDE.md#tech-stack-mvp, which no longer exists.
B-69 — stale in-code comments
app/tx/reconcile.py:206calls the payout retry "a future payout-retry routine — still an open gap"; it exists (B-26).app/db/base.py:9says "five concurrent background tasks"; there are six.app/auth/routes.py:31cites 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.