Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto the round when it opened. The cost of a bet and the fee address were read live, so an edit from /admin in the middle of a round gave the players of that round different stakes for the same chance and redirected the commission of the round in progress. - `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column, existing rounds backfilled with the current configuration, i.e. what they were using; the expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the round's fee address (an empty one on the row still stops the payout, as before), `GET /rounds/current` advertises the round's cost while one is active and the configured one otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10). - `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included, has reached the round's cap, before anything moves; the pool can exceed it by one bet at most. A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`. - PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the bet cost the round in progress opened with, which can be lower than the configured one. - `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the seven translations are generic. Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and "applies immediately, also to the round in progress" in the round_config table of modello-dati; the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line). Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and the old "fee address missing" payout test now empties the round's address (clearing it in the configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open, edits reaching only the next round, every bet of a round paying the same stake whether the cost is raised or lowered meanwhile, the payout following the round's address (and surviving it being cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap, a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route, the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the migration backfill. Mutation-checked: charging the live cost, paying the live address, not copying the cap and `>` instead of `>=` each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
9.7 KiB
BUGS.md — open findings
Two audits currently have open entries: 2026-08-03 (B-71, B-72, below) and the 2026-09-28 specification review (B-73 … B-98, at the end of the file).
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.
Low — documentation and consistency drift
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.
Audit of 2026-09-28 — specification review (B-73 … B-98)
Fourth audit, run against the new specification in docs/ rather than the code
alone: four independent reviews (coin/chain mechanics, money state machine,
DevOps, spec cross-validation), with the critical claims re-verified in code.
The target behaviour of every entry below is now written into docs/ as
requirements marked *Stato:* da implementare (B-nn); the Italian overview, the
design decisions and the implementation order are in
docs/piano-correzioni.md. The code has not been
changed yet. Same convention as above: one commit per entry, with its own
regression test, spec + code + test together.
Design decisions taken with the owner on 2026-09-28 (they change existing
decisions, see docs/limiti-noti.md):
- Draw by commit-reveal. The winner must not be computable by anyone who controls block production, since PLM's hashrate (~22 TH/s) is rentable.
- Never release funds on doubt. A money-moving row is rolled back only on proof (an explicit rejection, or its inputs confirmed-spent elsewhere), never on a timeout or on one server's silence.
- One pool address per round, so a round's money, history and payout are isolated from every other round's.
- Deposits need 6 confirmations (configurable), everything else keeps 1: the deposit is the only input a user can double-spend.
- Contain reorgs instead of pretending they don't happen: detect them, pause on deep ones, and pay out only what is verifiably on chain.
High
B-89 — Key problems surface only at first use
Startup checks only that the key file exists and XPRV_ENCRYPTION_KEY is non-empty;
a wrong key or a different xprv is found at the first registration or payout, and a
different xprv silently moves the pool address. The scripts accept any xprv and write
files 0644.
Fix: at startup decrypt the key and check the pool reserve address and a sample of
stored user addresses; refuse to serve on mismatch. The scripts refuse an xprv whose
derived addresses differ from the DB's and write 0600.
B-90 — Restoring a backup is unsafe
A restored DB reopens rounds that were already drawn and paid, and reassigns the derivation indexes of users registered after the backup to new users — who then receive the old users' funds.
Fix: START_PAUSED=true starts with the lottery paused; at startup the next 20
indexes past the highest assigned are checked for on-chain history and, if any has
some, registration is refused until the operator reconciles (restore_gap_detected);
per-round addresses (B-80) and B-82's pre-payout check make a replayed round
unpayable instead of payable twice.
B-91 — Host and supply-chain hardening
The container runs as root, .env and key files are world-readable, dependencies are
unpinned >= and base images float, secrets sit in env vars visible to
docker inspect.
Fix: non-root USER, 0600/0700 permissions, a hash-pinned lock file,
digest-pinned base images, the Fernet key mounted as a file rather than an env var.
Medium
B-92 — No health, readiness or real audit browsing
/health always answers ok; nothing exposes Electrum state; the audit endpoint
returns only the latest 500 rows unfiltered; registration writes no audit row; the
access log records Caddy's IP; app logs go only to the file.
Fix: GET /health/ready (Electrum connected, tip age < 360s, scheduler heartbeat <
30s) used as the container healthcheck; Electrum status in /admin; audit filters by
event type, round, user and time, with pagination; user_registered audit row with
index and address; uvicorn proxy headers from Caddy only; logs also to stdout.
B-93 — Deploys and rollbacks
A deploy with an open SSE stream never shuts down gracefully (uvicorn waits, Docker
kills after 10s); an older image cannot start on a newer DB; images are untagged;
SITE_ADDRESS passed per command reverts to localhost on the next plain
docker compose up.
Fix: close SSE streams on shutdown, stop_grace_period: 30s, await task
cancellation; tag images per release; SITE_ADDRESS in .env with ${SITE_ADDRESS:?};
documented pre-deploy backup and downgrade procedure.
B-94 — Account and endpoint abuse limits
change-password has no rate limit, no audit and no maximum length; login fields
have no length bounds; POST /bug-reports is anonymous and unlimited;
GET /admin/users has no pagination; admin, SSE and framework errors are bare
strings; static JS/CSS have no Cache-Control.
Fix: throttle and audit (password_changed) the password change, cap every
credential field at 256, rate-limit bug reports per IP, paginate the user list,
structured errors everywhere, Cache-Control: no-cache on static files.
B-96 — Smaller state inconsistencies
A reduced full-balance withdrawal is not re-checked against the minimum; an abandoned
payout leaves winner_amount_sats/fee_amount_sats set; a promoted payout writes no
payout_sent and a promoted bet no bet_placed; the reconciler pings SSE for every
row it checks; concurrent refreshes of one user can collide on the unique outpoint
and end the Electrum session; listener.client is read outside the per-user lock;
a bet covered only by pending change gets insufficient_balance instead of
balance_pending_confirmation. A round waiting in closing on a bet that never
leaves the mempool gives no signal at all.
Fix: each as described, one regression test per item; for the closing wait, a
closing_stalled audit row every 1800s past the deadline and closing_waiting_since
on GET /rounds/current (spec: TX-29).
B-98 — Coin selection stops at the first covering UTXO, refusing a payable bet
Found while writing the spec on 2026-09-28 (medium). select_utxos returns as soon
as the selected total covers the target. When that happens exactly (a 10 PLM coin
for a 10 PLM bet), the change is 0: the bet is refused with
balance_leaves_no_change and a withdrawal is reduced by DUST_LIMIT_SATS, even
though a second coin would have left a proper change output.
Fix: select until the total covers target + DUST_LIMIT_SATS (still largest-first,
still capped at max_inputs); only when every allowed coin together cannot reach it
fall back to today's behaviour (refuse the bet, reduce the withdrawal). Spec:
docs/scommesse-e-prelievi.md BET-24.