A user could bet and then double-spend the 1-conference deposit that funded the bet with a private chain: the bet left the chain but its participant stayed `confirmed`, and the payout failed "insufficient pool UTXOs" for ever with everyone else's money stuck. Coinbase outputs were also credited at 1 confirmation (maturity is 120). Deposits - `round_config.deposit_confirmations` (default 6, 1-100, editable from /admin) is the threshold for a deposit; an output of a platform transaction (any txid of a pending_transactions row, RBF versions included) keeps 1; a coinbase output needs 120, recognised from the raw transaction, which must hash to the txid (`is_coinbase`, cached). `read_deposit_policy` / `classify_new_deposits` run between the read and the write, so no session is open across the lookup. - The quorum must also place the coin deep enough on our own tip (ELC-17). - Confirmed outputs short of their threshold are pending deposits (memory only, replaced at every refresh): shown in `pending_balance_sats` / `has_pending` (also for DEP-13's withdrawal message), never spendable. No scripthash notification announces a maturing deposit, so each new block refreshes only the users that have one, one task per user (DEP-09 now says so). Payout - `_trigger_payout` verifies the round's confirmed participants' bet outputs with the other servers (`verify_pool_outpoints`: strict majority of >= 2 answers per outpoint, a mempool-only coin or a tie gives no verdict) and builds from exactly those outpoints (RND-22), never from whatever else sits at the address. No quorum: `payout_failed`, `pool verification unavailable`, nothing changes. Bets that are gone become `reorged_out`, one `participant_reorged_out` row each, `pool_amount_sats` rewritten (only downward) in the same transaction, and the 70/30 split is computed on what is left. - Winner's own bet gone: nothing is built; `rounds.payout_blocked_at` (Alembic f2c64a8d1e93, with `deposit_confirmations`, plain add_column) is set, `payout_blocked` and the pause with `paused_reason = 'payout_blocked'` are written in one transaction; `POST /admin/resume` clears the mark and the next attempt verifies again. /admin/rounds returns it. - Rounds opened before B-80 (pool = the shared reserve address) keep the old active-server `listunspent` choice, since that address holds no single round's bets. Ambiguities resolved: the winner's row stays `confirmed` while held (AUD-45 says every participant whose bet is gone becomes `reorged_out`, but OPS-30/DAT-68 need the verification to repeat after the resume, which only a `confirmed` row allows); `/`'s deposit hint no longer says "1 confirmation" (7 languages, same keys); guides updated. Tests changed because the spec changed: listener tests that credited height-100 coins at tip 0 now set a tip past every threshold; the payout fixtures seed confirmed participants (the new source of the inputs) instead of an active-server `listunspent`, FakeNetwork answers the verification, and test_round_pool_address asserts the quorum is asked about the round's address. New: thresholds at exactly 6/5, configured value, coinbase at 119/120 and a mismatching or missing raw tx, platform outputs at 1, dust, quorum height, waiting-deposit display and disappearance, block-triggered refresh, resume, verification verdicts, gone bets excluded with the split recomputed, winner gone held and paused, held payouts untouched until the resume, re-verification after it, no quorum, a payout with 1 and with 400 participants against reserve+fee+dust, admin config bounds and migration. Mutation-checked: threshold 1, ignored coinbase, no height bound, no verification and no winner hold each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
219 lines
11 KiB
Markdown
219 lines
11 KiB
Markdown
# 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](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-87 — The RBF bump can leave a dust change output
|
|
|
|
`bump_fee` only requires change > fee delta. A 294-sat change (full-balance
|
|
withdrawal) becomes ~153 sat after the minimum bump and every replacement is refused
|
|
by relay, every 30s.
|
|
|
|
Fix: when a bump would leave change below `DUST_LIMIT_SATS`, the change output is
|
|
dropped and its value goes to the fee (the replacement is then final); `RbfError`
|
|
and every refused bump write a `rbf_bump_failed` audit row.
|
|
|
|
### 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-95 — Round parameters read live instead of snapshotted
|
|
|
|
Only duration and cooldown are snapshotted at open; bet amount, `fee_address` and the
|
|
new `draw_confirmations` are read live, so an edit mid-round changes the round in
|
|
progress (e.g. redirects its commission).
|
|
|
|
Fix: snapshot `bet_amount_sats`, `fee_address` and `draw_confirmations` on the round
|
|
at open. Optional per-round cap `max_round_pool_sats` (round_config, 0 = disabled):
|
|
once reached, further bets get `round_full`.
|
|
|
|
### 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.
|