Files
plm-lottery/BUGS.md
T
davideandClaude Opus 5 845ba98409 Record the audit outcome and the architecture it changed
BUGS.md keeps every finding's original description and gains, per entry, what was
actually done and where its regression test lives — including the two entries
fixed differently from the plan (B-15 validates at startup, B-09 kept both
callers plus a bounded retry) and the one only partially fixed by decision (B-16,
where shipping the guide was deferred).

It also gains a Runtime verification section, which is the part worth reading:
what the live Docker deployment actually demonstrated (startup validation on the
real .env, the listener connecting and holding, rounds cycling, the migration
applied, and the reconciler's missing-tx heuristic checked against the real
server's error message) separated from what has no runtime evidence at all —
nothing has spent money since the restart, so the two-phase write, the RBF
retargeting, the reconciler's actual behaviour and the dust path are unit-tested
only. A green suite is not a working deployment, and the file now says so.

CLAUDE.md documents the two things a reader would otherwise have to reverse-
engineer: the transaction lifecycle (why rows are written before broadcasting,
what each PendingTransaction status means, why spent_txid must track the current
txid, and that one-active-round is now a DB invariant) and the Electrum
connection's rotation/keepalive/timeout behaviour. Its Known gaps list is rewritten
to say what is still open now that transaction-level state self-heals but
round-level state does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:35:28 +02:00

54 KiB
Raw Blame History

Known bugs and required fixes

Full-codebase audit performed on 2026-07-26 against commit d4e0974 (branch main), covering every Python module under app/, both static frontends, and the Docker/Caddy deployment. The test suite was green at the time of the audit (79 passed), so none of the findings below are caught by the existing tests — every one of them needs a regression test alongside its fix.

Status: 23 of the 24 findings are fully fixed on main as of 2026-07-27, and the 24th (B-16) is fixed as far as the crash goes — shipping the user guide in the image was deliberately deferred. Every entry below keeps its original description — the bug, why it mattered, and how it was meant to be fixed — and carries a Fixed: note recording what was actually done and where the regression test lives. The suite grew from 79 to 137 tests. Two entries were fixed differently from the plan (B-15 validates at startup rather than in a Pydantic validator, B-09 gained a bounded retry); both say so and why. The fixes were then run in the real Docker deployment — see Runtime verification for what that actually confirmed and, more importantly, what it did not.

Findings are ordered by severity. Each entry is self-contained and follows the same shape: symptom, root cause with file:line references, a *Proposed fix:* block, and a *Test:* block naming the regression test to add (a few are marked manual where an automated test would be testing the Docker image layout or the browser, not the code). Items already listed under "Known gaps / TODO" in CLAUDE.md are cross-referenced rather than repeated, except where this audit found the gap to be worse than documented.

Summary

ID Severity Area One-line
B-01 Critical Electrum client/listener A dropped connection hangs the whole server permanently — no reconnect, no request timeout, no keepalive
B-02 Critical RBF / bets A fee bump on a bet tx orphans RoundParticipant.bet_txid, wedging the round in closing forever
B-03 Critical Confirmation poller One unresolvable txid aborts confirmation detection for every other pending tx
B-04 Critical Balance / UTXO state No reconciliation path: a dropped tx freezes the spent UTXOs (and the user's funds) forever
B-05 Critical Admin config / payout fee_address and numeric params are unvalidated; a bad value either wedges the payout or sends the commission to an unspendable script
B-06 High PSBT builder Change outputs below the dust limit are created, making the transaction unrelayable
B-07 High Bets / withdrawals A broadcast rejection surfaces as HTTP 500, bypassing the structured error contract
B-08 High Bets Broadcast happens before the bet is persisted: a commit failure loses the money with no record
B-09 High Rounds Race allows two simultaneously-open rounds, which permanently blocks all future rounds
B-10 High Admin / audit PUT /admin/config writes no audit-log entry
B-11 High Rounds API The displayed jackpot is not the amount the winner receives
B-12 High Auth Registration enforces no password or username validation, unlike change-password
B-13 Medium Auth verify_password turns a malformed stored hash into a 500 instead of a 401
B-14 Medium Admin auth Admin token compared with != instead of a constant-time comparison
B-15 Medium Config Empty JWT_SECRET / XPRV_ENCRYPTION_KEY are not rejected at startup
B-16 Medium Deployment GET /guida is broken in Docker: docs/ is never copied into the image (crash fixed; shipping the guide deferred — see entry)
B-17 Medium RBF _find_change_output can shrink the recipient output instead of the change
B-18 Medium Scheduler DB session held open across Electrum network calls during payout
B-19 Medium Electrum listener tip_height is assigned without a monotonicity check (reorg)
B-20 Low RBF PendingTransaction.replaced_by_txid is never written — dead column shown in the admin UI
B-21 Low Confirmation poller Dead local pending_ids, plus attribute access on detached ORM objects
B-22 Low Frontend Amounts rendered by raw division — floating-point artefacts visible to users
B-23 Low Frontend Concurrent withLoading on the same button can leave it stuck on its loading label
B-24 Low API contract The global exception handler answers with a bare-string detail

Runtime verification (2026-07-27)

The fixed code was built and run via docker compose up -d --build against mainnet. This section separates what the running system demonstrated from what is still only covered by unit tests — the distinction matters, because a green suite is not a working deployment.

Caveat on the image that produced this evidence: it was built while the Dockerfile still carried COPY docs ./docs, which was subsequently reverted (see B-16). The running container therefore does not match the current Dockerfile: /guida answers 200 there, and will answer 404 after the next rebuild. Everything else below is unaffected.

Confirmed at runtime

Fix Evidence from the live system
B-15 The app serves traffic, so validate_runtime_secrets() passed against the real .env — and it is now on the startup path of every deploy.
B-01 (partly) app.main: Electrum endpoints (in rotation order): santantonio.sytes.net:50002 followed by app.electrum.listener: Electrum connected to santantonio.sytes.net:50002 — the restructured session setup (asyncio.wait over the consumers, wait_closed() and the keepalive) connects and stays up against a real server. Only one endpoint is configured, so rotation itself is still only unit-tested.
B-09 ix_rounds_single_active exists in the deployed DB and rounds keep opening and closing normally (196 rounds, one per ~98s cycle) — the index and the conflict-retry path do not interfere with normal round creation.
B-04 (schema) The failure_reason column is present in the deployed DB; migration 8a1c4e7b2d90 applied cleanly to the live database.
B-04 (heuristic) The one genuinely unknown piece — whether _tx_exists_on_chain recognizes a missing tx on this server — was tested directly: for a bogus txid the server answers No such mempool or blockchain transaction, which the substring check matches, so the function correctly returns False. The conservative direction is preserved: any message it does not recognize re-raises and leaves the row untouched.
B-16 Confirmed as a bug that had really been firing, not a theoretical one: logs/app.log holds two pre-fix ERROR [app.main] Unhandled error on GET /guida entries (17:40 and 19:42 on 2026-07-26) ending in RuntimeError: File at path docs/guida-utente.md does not exist.
No regressions Zero ERROR/WARNING lines since the restart on the fixed code. The only errors in the entire log predate it (the two /guida failures above, plus two unrelated asyncio entries from 2026-07-22).

Still unverified outside the test suite

Nothing has spent money since the restart: pending_transactions holds only rows that were already confirmed beforehand, withdrawals is empty, and no attempt_count is above 1. So these fixes have unit coverage and no runtime evidence at all:

  • B-08/B-07 — the two-phase write and the rollback-on-refusal need a real bet, and a genuinely rejected broadcast, to be seen working.
  • B-02/B-20 — needs an actual RBF bump (rbf_timeout_seconds must elapse with the tx unconfirmed).
  • B-03/B-04 (behaviour) — needs a transaction that really goes missing. The reconciler has been ticking every 120s with nothing to do, which is silent by design.
  • B-06 — needs a UTXO set that produces sub-dust change.
  • B-17 — needs a withdrawal attempt; the withdrawal path as a whole has still never been exercised against a live broadcast, which was already true before this audit.
  • B-22/B-23 — browser-side, verifiable only by using the UI.

What remains open (not part of the 24)

Unchanged by this work, and still the reason this isn't unattended-safe:

  • Scheduler doesn't resume mid-flight rounds after a restart — a round left in closing/drawing/paying_out when the process dies stays stuck. Transaction-level state now self-heals (B-04); round-level state does not.
  • No automatic payout retry — a failed payout is audit-logged (payout_failed, added by B-05) and visible in /admin, but acting on it is manual.
  • RBF still handles only the single-change-output case — the difference is that failure is no longer permanent: an unbumpable tx is eventually abandoned and its coins released.
  • /guida is not served in Docker by current deliberate decision — the guide is being reworked; re-adding COPY docs ./docs is all it will take.
  • No rate limiting anywhere, admin auth is one shared token with no per-operator identity, and single-process assumptions remain in the SSE broadcaster and the per-user locks.
  • No automated integration tests against a live Electrum connection — the verification in this section was done by hand.

Critical

B-01

A dropped Electrum connection hangs the entire server permanently

Files: app/electrum/client.py:31-63, app/electrum/client.py:85-99, app/electrum/listener.py:58-77

Three independent defects compose into a single absorbing failure state:

  1. The read loop's death never propagates. connect() spawns _read_loop as a detached task. When the socket closes, readline() returns b"", the loop breaks and the task completes — but _run_once is blocked on asyncio.gather(self._consume_headers(...), self._consume_scripthash(...)), and both consumers are awaiting asyncio.Queue.get() on queues nobody will ever fill again. The gather never returns and never raises, so the except Exception + backoff-reconnect logic in ElectrumListener.run() is never reached.
  2. self.client stays non-None. Every consumer of the client (RoundScheduler, ConfirmationPoller, RbfBumper, place_bet, request_withdrawal, and the listener.client is None guards in the bet/withdrawal routes) therefore keeps treating a dead connection as live.
  3. request() has no timeout. It registers a future in self._pending, writes to a closed writer (drain() frequently does not raise on a half-closed socket) and then await future — which nothing will ever resolve, because _read_loop is gone. Note that _read_loop's finally only fails the futures that existed at the moment it died; every future created afterwards hangs forever.

There is also no keepalive (server.ping) anywhere. Electrum servers routinely drop idle connections after ~10 minutes, so on a low-traffic instance this is close to guaranteed rather than hypothetical.

Observable consequences: deposits stop being credited; the tip stops advancing, so a round in drawing waits in _wait_for_next_block forever; ConfirmationPoller blocks on its first get_transaction and stops polling entirely; a POST /bets request hangs indefinitely while holding that user's UserLocks entry, so every later bet or withdrawal from the same user deadlocks behind it.

Reproduction: start the app, kill the TCP connection out from under it (ss -K dst <electrum-host>, or block the port with a firewall rule). No reconnect log line is emitted, /rounds/current keeps reporting the last known chain_tip_height, and a bet request never returns.

Proposed fix:

  • Wrap every request() in asyncio.wait_for(...) with an explicit timeout (10-15s) and pop the future from self._pending on timeout.
  • Add a keepalive task issuing server.ping every ~60s; a failed ping tears the connection down.
  • Make the read loop's termination observable: either include self._read_task in _run_once's gather, or set an asyncio.Event in _read_loop's finally that _run_once awaits alongside the consumers. On teardown, set self.client = None before awaiting the reconnect backoff.

Test: a unit test with a fake stream that closes mid-session must observe listener.client is None and a reconnect attempt; a test that request() raises ElectrumError/TimeoutError rather than hanging when no response arrives.

Fixed. app/electrum/client.py now bounds every request() with asyncio.wait_for (_REQUEST_TIMEOUT_SECONDS = 15), pops the orphaned future and tears the connection down on timeout; _read_loop's finally sets a _closed event, exposed as wait_closed(); and ping() was added. app/electrum/listener.py:_run_once races the two notification consumers against wait_closed() and a 60s keepalive with asyncio.wait(FIRST_COMPLETED), so a drop ends the session and run() reconnects. self.client is cleared before the reconnect, so callers stop using a dead client. Beyond the original finding, the listener now rotates over a list of servers (ELECTRUM_FALLBACK_SERVERS, parse_endpoints): one dead server costs a single attempt instead of an outage, and the backoff only sleeps once every server has had a turn. Tests: tests/unit/test_electrum_client.py (timeout, wait_closed, pending-request failure, endpoint parsing), tests/unit/test_electrum_listener.py (rotation, backoff reset, no-endpoints case).

B-02

An RBF fee bump on a bet permanently wedges the round

Files: app/tx/broadcast.py:116-121, app/bets/confirmation.py:10-16, app/rounds/scheduler.py:77-86, app/rounds/scheduler.py:92-98

bump_fee rebuilds the transaction, which changes its txid, and updates PendingTransaction.current_txid accordingly. Nothing updates the domain row's copy of that txid: RoundParticipant.bet_txid is written exactly once, at app/bets/service.py:80, and never again (verified by grepping every write to bet_txid).

When the bumped transaction confirms, _on_bet_confirmed looks the participant up by RoundParticipant.bet_txid == pending.current_txid, finds nothing, and returns silently. Consequences, in order:

  • the participant stays status == "broadcast" forever;
  • RoundScheduler._tick counts participants in "broadcast" before allowing the round to close, so the round stays in closing indefinitely;
  • since a new round cannot open while one is active (get_active_round), the entire lottery stops;
  • even if the round were forced closed, _close_and_draw only selects status == "confirmed" participants, so that player would have paid the bet without entering the draw.

The same staleness affects Withdrawal.txid. It is less severe there, because _on_withdrawal_confirmed keys off pending.withdrawal_id, so the status does update — but the txid stored and shown to the user is the replaced one.

Reproduction: place a bet, let rbf_timeout_seconds elapse without confirmation so RbfBumper bumps it, then let the bumped tx confirm. round_participants.status remains broadcast; the round never leaves closing.

Proposed fix: make the bump update the domain row too. Either have bump_fee write through to RoundParticipant.bet_txid / Withdrawal.txid (it already knows kind, user_id, round_id, withdrawal_id), or — cleaner — stop keying confirmation handlers on the txid at all: _on_bet_confirmed should resolve the participant via pending.round_id + pending.user_id, which are immutable. Also set replaced_by_txid (see B-20) so the chain of replacements is auditable.

Test: bump a pending bet, then run the confirmation handler with the new txid and assert the participant flips to confirmed.

Fixed. Both confirmation handlers now resolve their domain row by immutable ids — _on_bet_confirmed by (round_id, user_id), _on_payout_confirmed by round_id — with the old txid lookup kept only as a fallback for pre-existing rows. Independently, bump_fee calls a new _retarget_txid_references() that updates RoundParticipant.bet_txid, Withdrawal.txid, Round.payout_txid and UtxoEvent.spent_txid to the new txid, so no stored txid is left pointing at a transaction that no longer exists. Tests: test_confirmation.py::test_bet_confirms_after_an_rbf_bump_changed_the_txid (plus the payout equivalent), test_broadcast.py::test_bump_fee_retargets_every_stored_txid.

B-03

One unresolvable txid stops confirmation detection for everything else

File: app/tx/confirmation.py:35-37

for pending_id, txid, kind in [...]:
    tx = await client.get_transaction(txid, verbose=True)   # not guarded

If a txid is no longer knowable by the server — dropped from the mempool, replaced by an RBF bump whose old entry is still pending, or simply an Electrum server that answers with an error — ElectrumError propagates out of poll_once and aborts the loop before the remaining pending transactions are checked. ConfirmationPoller.run logs it and retries every 10s, failing at the same row every time.

Because the poller is the single mechanism that confirms bets, payouts and withdrawals, one stuck row means: no bet ever confirms again (so no round ever closes — see B-02), no payout ever completes, no withdrawal ever settles. Recovery requires manual DB surgery.

Proposed fix: wrap the per-transaction lookup in try/except Exception, log at warning with the txid, and continue. Optionally track consecutive failures per row so a permanently-unknown tx can be escalated to an operator (which is also the hook B-04 needs).

Test: poll_once with two pending rows where the first raises must still confirm the second and return 1.

Fixed. The per-transaction lookup in poll_once is wrapped in try/except Exception: it logs the txid at warning level and continues, leaving the judgement about a permanently-unknown tx to the reconciler (B-04). Test: test_confirmation.py::test_one_unresolvable_txid_does_not_block_the_others, which asserts the healthy row still confirms and the unknown one is left pending rather than abandoned here.

B-04

No reconciliation: a dropped transaction freezes the spent UTXOs forever

Files: app/bets/service.py:69-73, app/withdrawals/service.py:65-68, app/wallet/balance.py:9-18

spent_txid is set optimistically at broadcast time and is never cleared anywhere in the codebase. recompute_balance only sums UTXOs with spent_txid IS NULL, so those coins are permanently excluded from the user's balance.

If the transaction never confirms and eventually disappears from the mempool — which CLAUDE.md already acknowledges is a realistic outcome, since the RBF path raises RbfError and gives up whenever there is no change output large enough to absorb the bump — the UTXOs remain perfectly spendable on-chain while the database considers them gone. The user's funds are silently lost from their point of view.

More broadly, nothing in the system ever writes a terminal failure state: there is no status = "failed" on PendingTransaction, Withdrawal or RoundParticipant, and no startup routine that re-scans in-flight transactions against the chain. Combined with restart: unless-stopped and the already-documented scheduler-resume gap, the system has several states it can only be pulled out of by hand.

Proposed fix: add a reconciliation task (and run it once at startup) that, for every PendingTransaction in pending older than some threshold, asks the chain whether the tx exists. If it is gone:

  • mark the row failed, and set replaced_by_txid/a failure reason;
  • clear spent_txid on the UTXOs it consumed (they are identifiable by parsing raw_tx_hex's inputs);
  • call recompute_balance;
  • roll the domain row back (delete the RoundParticipant, mark the Withdrawal failed) and audit-log the event.

Test: given a pending bet whose tx is unknown to the chain, the reconciler must restore the user's balance and remove the participant.

Fixed by a new component, app/tx/reconcile.py (PendingTransactionReconciler, started from the lifespan and running every 120s, including once at startup). Per non-terminal PendingTransaction it asks the chain whether the tx exists: a building row whose tx is there gets promoted, a row whose tx is gone is marked failed with a failure_reason (new column), its inputs released (spent_txid cleared, only where it still matches this row's txid), the balance recomputed, and the domain row rolled back — participant deleted, withdrawal marked failed, payout txid cleared. Grace periods differ by state (120s for building, 6h for pending) and a transport failure never abandons anything. Tests: tests/unit/test_reconcile.py (6 cases, including the "broken connection must not release coins" one).

B-05

fee_address and the numeric config params are unvalidated

File: app/api/routes/admin.py:70-80, consumed at app/rounds/scheduler.py:162-198

PUT /admin/config assigns whatever it is given straight onto the RoundConfig row. Two distinct failure modes:

  1. Malformed fee_addressbuild_payout_transaction calls script.Script.from_address(fee_address), which raises EmbitError. _trigger_payout only catches InsufficientFundsError, so the exception escapes through _close_and_draw up to the except Exception in RoundScheduler.run(). The round is left in paying_out with no retry (the payout-retry gap in CLAUDE.md), i.e. wedged.
  2. Well-formed but foreign fee_address (e.g. a Bitcoin bc1...) → this is worse, because it parses fine into a valid witness program. The payout is built, signed and broadcast, and the 30 % commission of every round lands on a script nobody holds the key for. This is exactly the failure mode app/wallet/address.py was written to prevent for user withdrawals; the check was never applied to the operator-supplied address.

Numeric fields are equally unguarded: round_duration_seconds = 0 (round expires the instant it opens), fee_rate_sat_vb = 0 (fee-less transactions are never relayed, so every bet/payout hangs and the whole pipeline stalls), negative bet_amount_sats, draw_animation_seconds far larger than the round itself.

Proposed fix: validate in RoundConfigUpdate (Pydantic field_validator / Field(gt=0)):

  • fee_address must pass is_valid_plm_address;
  • bet_amount_sats, round_duration_seconds, fee_rate_sat_vb, rbf_timeout_seconds strictly positive, with sane upper bounds;
  • round_cooldown_seconds, draw_animation_seconds non-negative.

Independently, _trigger_payout should catch Exception around the build/broadcast, audit-log the failure, and leave the round in a state a retry routine can pick up.

Test: PUT /admin/config with a bc1... fee address and with fee_rate_sat_vb = 0 must both return 422/400 and leave the stored config untouched.

Fixed. RoundConfigUpdate now validates: fee_address must pass is_valid_plm_address, and every numeric field carries bounds (fee_rate_sat_vb >= 1, round_duration_seconds >= 30, etc.). Separately _trigger_payout catches Exception around the build/broadcast, logs it and writes a payout_failed audit entry so a stuck round is visible in /admin rather than only in the log file. Automatic payout retry remains an open pre-existing gap (documented in CLAUDE.md), unchanged by this fix. Test: test_admin.py::test_config_rejects_unusable_values, parameterized over the bc1… address, a broken checksum, fee_rate_sat_vb=0, round_duration_seconds=0 and more.


High

B-06

Dust change outputs are created

File: app/wallet/psbt_builder.py:98-99 and :166-167

Both builders use if change > 0: as the sole condition for adding the change output. Any leftover below the P2WPKH dust threshold (~294 sat at the standard 3000 sat/kvB dust relay fee) produces a transaction that relaying nodes reject outright as dust. The resulting ElectrumError is unhandled (see B-07), so the user gets an opaque HTTP 500 and cannot bet or withdraw at all until their UTXO set happens to change.

Proposed fix: introduce a DUST_LIMIT_SATS constant; add the change output only when change >= DUST_LIMIT_SATS, otherwise leave the remainder to the fee (and reflect that in the returned fee_sats/change_sats so the accounting stays honest). Note the fee estimate at psbt_builder.py:86 already assumes two outputs unconditionally, so dropping change does not underpay.

Test: build a transaction whose change lands at 100 sat and assert a single-output tx with the remainder folded into the fee.

Fixed. DUST_LIMIT_SATS = 294 added to app/wallet/psbt_builder.py; both builders fold sub-dust change into the fee instead of creating the output, and refuse a sub-dust recipient/winner/commission amount with a dedicated error code. Tests in test_psbt_builder.py: dust change folded into the fee (asserting nothing vanishes — inputs still equal outputs plus fee), change exactly at the limit still paid back, and a dust-sized recipient refused.

B-07

Broadcast failures surface as HTTP 500

Files: app/bets/service.py:67, app/withdrawals/service.py:63

await client.broadcast(built.raw_hex) is not guarded. Every node-side rejection — fee below the relay minimum, dust output (B-06), mempool conflict, non-standard tx — raises ElectrumError, which the global handler in app/main.py:74-77 turns into {"detail": "internal server error"}. That bypasses the whole error contract documented in app/api/errors.py: no code, nothing translatable, no actionable message.

Proposed fix: wrap the broadcast, and raise BetError("broadcast_failed", ...) / WithdrawalError("broadcast_failed", ...) carrying the node's message in params. Add error.broadcast_failed to all 7 languages in app/static/i18n.js (per the i18n contract in CLAUDE.md). Answer 502/503 rather than 400, since the failure is not the client's fault.

Test: place_bet / request_withdrawal with a client stub whose broadcast raises must raise BetError/WithdrawalError with code broadcast_failed — and must leave no spent_txid, no participant and no pending row behind.

Fixed. Both services wrap the broadcast and raise BetError/WithdrawalError("broadcast_failed", …); the routes answer 502 rather than 400, since the network refused it, not the caller. error.broadcast_failed was added to all 7 languages in i18n.js (along with amount_below_dust_limit, withdrawal_to_own_address, internal_error, guide_unavailable) — key parity verified, 123 keys per language. Tests: test_bets.py::test_failed_broadcast_reports_the_broadcast_failed_code and the withdrawal equivalent.

B-08

Bets are broadcast before they are persisted

File: app/bets/service.py:67-94

The order today is: broadcast → mutate spent_txid → insert RoundParticipant + PendingTransactioncommit(). If the commit fails, or the process dies in that window, the transaction is already irreversibly on the chain: the money has moved to the pool address, but no record of the bet exists — no participant (so no entry in the draw), no pending row (so no RBF, no confirmation tracking), and the UTXOs are not even marked spent, so the next bet attempt will try to double-spend them.

The same shape exists in request_withdrawal, where the Withdrawal row is flushed before broadcast but only committed after.

Proposed fix: persist the intent first. Insert the PendingTransaction (and the RoundParticipant) in a building/unbroadcast state, commit, then broadcast, then flip to broadcast in a second commit. A crash between the two leaves a row the reconciliation task from B-04 can resolve against the chain in either direction.

Test: with a client stub that broadcasts successfully but a session whose second commit raises, the PendingTransaction must still exist afterwards (in its pre-broadcast state) rather than the bet vanishing entirely.

Fixed. Both place_bet and request_withdrawal are now two-phase: write the rows in a building state and commit, then broadcast, then promote to broadcast/pending in a second commit. A failed broadcast runs a release helper that frees the reserved UTXOs, restores the balance, removes the participant (or marks the withdrawal failed) and audit-logs it. A crash between the phases leaves a building row for the reconciler (B-04) to resolve either way. Tests: test_bets.py::test_bet_is_persisted_before_it_is_broadcast — which probes committed state from an independent session during the broadcast, and which caught a real mistake in the first draft of this fix (the _pending_transaction helper still hardcoded status="pending", so rows were born already-broadcast and would have got the reconciler's 6-hour grace instead of 120s) — plus the two rollback tests.

B-09

Two rounds can be opened concurrently, blocking every future round

File: app/rounds/service.py:44-60

open_new_round_if_needed performs a read (get_active_round) followed by an insert with no lock and no database-level uniqueness guarantee, and it is called both from RoundScheduler._tick and from every place_bet. Two concurrent callers can both observe "no active round" and both insert a row with status="open".

get_active_round then quietly hides the problem — select(...).order_by(Round.id.desc())

  • scalar() returns only the newest — while the older round stays open forever, never ticked, never closed. Since get_active_round matches on status, no further round can ever open once the newer one closes: the lottery stops for good.

Proposed fix: the simplest robust option is to make the RoundScheduler the only writer and have place_bet merely read the active round (rejecting the bet if there is none — the scheduler opens one within 5s anyway). Additionally add a DB guard: a partial unique index over status IN ('open','closing','drawing','paying_out'), or an advisory/serialized transaction around the read-then-insert. get_active_round should also log loudly if it ever sees more than one row.

Test: two concurrent open_new_round_if_needed calls on separate sessions must yield exactly one round.

Fixed at the database level: ix_rounds_single_active, a unique index over the constant expression (1) restricted to the active statuses (model + migration 8a1c4e7b2d90, which first closes any pre-existing duplicates, keeping the newest — verified against a DB seeded with two active rounds). open_new_round_if_needed catches the resulting IntegrityError, rolls back and returns the winner's round. Deviation from the plan: rather than making the scheduler the only writer (which would have meant the first bet after a cooldown couldn't open a round), it keeps both callers and adds a bounded retry — a conflict where nothing is active yet means the winner simply hadn't committed, and a bet must not fail on that timing. get_active_round also logs loudly if it ever sees more than one active round. Tests in test_rounds_service.py: the race-recovery path (forced deterministically by making the first look miss the existing round — real concurrency on a shared in-memory SQLite connection isn't isolated enough to test this honestly), the DB refusing a second active round, and closed rounds coexisting with an active one.

B-10

PUT /admin/config writes no audit-log entry

File: app/api/routes/admin.py:70-80

/admin/pause and /admin/resume both call write_audit_log; the config update — which can change fee_address (where 30 % of every pool goes), bet_amount_sats and the fee rate — writes nothing. This contradicts CLAUDE.md, which states that audit_log records what changed (only the who is documented as missing).

Secondary issue: paused is part of _CONFIG_FIELDS, so it can be toggled through PUT /config, silently bypassing the deliberately audit-logged pause/resume endpoints.

Proposed fix: write a config_updated audit entry containing the changed fields as {field: {"from": old, "to": new}} — computed before assignment. Remove paused from _CONFIG_FIELDS (keep it in the response model) so the pause switch has exactly one audited path.

Test: PUT /admin/config changing fee_address must produce exactly one config_updated audit row carrying both the old and the new value; paused must be rejected (or ignored) on that endpoint.

Fixed. PUT /admin/config computes a before/after diff and writes a config_updated audit entry ({field: {"from": …, "to": …}}), skipping no-op updates. paused was removed from _CONFIG_FIELDS so the maintenance switch has exactly one audited path (/admin/pause, /admin/resume); it remains in the response model. Tests: test_admin.py::test_config_update_is_audit_logged, the no-op case, and test_pause_cannot_be_toggled_through_the_config_endpoint.

B-11

The displayed jackpot is not what the winner receives

File: app/api/routes/rounds.py:118-144

pool_amount_sats = participant_count * config.bet_amount_sats
jackpot_sats = pool_amount_sats * 70 // 100

Three independent divergences from the amount actually paid out:

  1. The real pool is sum(p.bet_amount_sats), and each participant's bet_amount_sats is recipient_sats — the bet amount minus that bet's network fee (psbt_builder.build_signed_transaction).
  2. The payout deducts its own network fee from the winner's 70 % share (build_payout_transaction), so the winner receives strictly less than 70 % of the pool.
  3. participant_count counts every RoundParticipant row, including bets still in broadcast that may never confirm — and those are excluded from the draw and the pool by _close_and_draw.

Plus: it multiplies by the current bet_amount_sats, which an operator can change mid-round, retroactively changing the advertised jackpot of a round already in progress.

The in-code comment explicitly promises "what's displayed should match what the winner actually receives", which is the opposite of the behaviour.

Proposed fix: compute the pool from the participants' stored bet_amount_sats (optionally restricted to confirmed), derive the 70 % share from that, and either subtract an estimated payout fee or relabel the field as an estimate in the UI. Keep participant_count as-is for display but consider exposing confirmed_count separately.

Test: with two confirmed participants whose stored bet_amount_sats is below the configured bet_amount_sats (fee already deducted), GET /rounds/current must report a jackpot_sats derived from the stored amounts, and must not change when RoundConfig.bet_amount_sats is edited mid-round.

Fixed. GET /rounds/current now sums the participants' stored bet_amount_sats (each already net of its own bet's fee) instead of multiplying participant_count by the current configured amount. The remaining, unavoidable imprecision — the payout tx's own fee, deducted from the winner's share and unknowable until the payout is built — is documented in the code rather than silently promised away. Test: test_rounds_route.py::test_jackpot_comes_from_the_participants_actual_bets, which also asserts that editing bet_amount_sats mid-round no longer moves a running round's jackpot.

B-12

Registration has no password or username validation

File: app/auth/routes.py:18-56

POST /users/me/change-password enforces _MIN_PASSWORD_LENGTH = 8 (app/api/routes/users.py:15), and the frontend re-checks it. POST /auth/register enforces nothing: an empty username and a one-character password are both accepted, and there is no minlength on the registration inputs in app/static/index.html either. On a custodial system holding real funds, this is the wrong default.

Secondary defect in the same handler: the _MAX_REGISTER_RETRIES loop catches any IntegrityError. A username collision that slips past the pre-check (concurrent registration) is therefore retried five times and finally reported as derivation_index_conflict, which is misleading for both the user and the operator.

Proposed fix: validate username (length, allowed character set) and password (shared minimum-length constant, reused by users.py) in RegisterRequest; mirror the constraints in the HTML form. Inspect the IntegrityError and re-raise username_taken when it is the username constraint that failed.

Test: POST /auth/register with an empty username and with a 3-character password must both be rejected (422/400) and create no user; a registration racing an existing username must answer username_taken, not derivation_index_conflict.

Fixed. MIN_PASSWORD_LENGTH moved to app/auth/security.py and is now shared by registration and the password change, so the two can't drift. RegisterRequest constrains username (332 chars, [A-Za-z0-9_.-]) and password; the HTML form mirrors it with minlength/pattern/required, and register() pre-checks the length so the failure is immediate and translated. The IntegrityError handler now distinguishes a username collision (answers username_taken) from a derivation-index collision (retries). Tests: test_users.py::test_register_rejects_weak_credentials (parameterized) and the positive case.


Medium

B-13

A malformed stored hash becomes a 500 instead of a 401

File: app/auth/security.py:16-20

verify_password catches only VerifyMismatchError. Argon2 raises InvalidHashError for a hash it cannot parse and VerificationError for other verification failures, both of which escape as an unhandled 500.

Proposed fix: catch argon2.exceptions.VerificationError (the superclass of VerifyMismatchError) plus InvalidHashError and return False, so an unusable stored hash reads as "wrong password" rather than as a server fault.

Test: verify_password("x", "not-a-hash") returns False; POST /auth/login against a user row with a corrupted password_hash answers 401.

Fixed. verify_password catches VerificationError (the superclass of VerifyMismatchError) and InvalidHashError separately, returning False in both cases and logging the unparseable-hash case as an error, since that one is a data problem worth noticing. Tests: test_security.py::test_verify_password_returns_false_for_an_unparseable_hash, plus one confirming a genuinely wrong password is still rejected.

B-14

Admin token compared non-constant-time

File: app/api/routes/admin.py:20-22

x_admin_token != settings.admin_token is a short-circuiting comparison. This token gates the private-key export endpoint, so it deserves a constant-time comparison.

Proposed fix: secrets.compare_digest(x_admin_token, settings.admin_token), keeping the existing "empty configured token means always deny" short-circuit before it (compare_digest on two empty strings returns True).

Test: the existing admin auth tests still pass, plus one asserting an empty ADMIN_TOKEN setting denies an empty X-Admin-Token header.

Fixed. require_admin uses secrets.compare_digest, with the empty-configured-token check kept ahead of it — compare_digest("", "") returns True, so the original order would have opened the admin panel on any instance without an ADMIN_TOKEN. Covered by the existing admin auth tests.

B-15

Empty secrets are not rejected at startup

File: app/config.py:13-18

jwt_secret and xprv_encryption_key both default to "". With an empty JWT_SECRET, PyJWT raises InvalidKeyError: HMAC key must not be empty on every login and registration — a 500 with no hint about the real cause (verified locally). With an empty XPRV_ENCRYPTION_KEY, Fernet fails on the first key derivation instead. Either way the container starts up healthy and only fails once a user touches the broken path.

Proposed fix: a Pydantic field_validator (or model_validator) on Settings rejecting empty jwt_secret/xprv_encryption_key, with a minimum length on jwt_secret (32 bytes, per the InsecureKeyLengthWarning PyJWT already emits in the test suite). The process must refuse to boot instead of half-working. Note the tests currently rely on a short secret, so they need updating alongside.

Test: constructing Settings(jwt_secret="") raises ValidationError.

Fixed, but not as planned. A Pydantic field_validator on Settings would have run at import time in every module that reads config — including the whole test suite, which has no .env — so a fresh clone would have failed at collection. Instead validate_runtime_secrets() (in app/config.py) is called from the app's lifespan: the server still refuses to serve half-configured, without coupling imports to a gitignored file. It reports all problems at once and treats an empty ADMIN_TOKEN as non-fatal (require_admin already denies everything, so the effect is a locked panel, not an open one). Tests: tests/unit/test_config.py (6 cases, using _env_file=None so a developer's real .env can't influence the result).

B-16

GET /guida is broken in Docker

Files: Dockerfile:38-42, app/main.py:98-103

The image copies pyproject.toml, app/, migrations/, alembic.ini and scripts/. docs/ is never copied (it is not in .dockerignore — it is simply not COPY-ed), so FileResponse("docs/guida-utente.md") raises inside the container and the help link in the navbar returns a 500 in every real deployment.

Proposed fix: add COPY docs ./docs to the Dockerfile (treating the guide as a shipped asset), or move the guide under app/static/ and serve it from there. Whichever is chosen, GET /guida should degrade to a 404 with a clear message rather than an unhandled exception when the file is absent.

Test: not unit-testable as-is (it depends on the image layout) — verify with docker compose up -d --build && curl -k https://localhost/guida, and add that check to docs/running-the-server.md's smoke list.

Partially fixed, by explicit decision. The crash is gone: GET /guida checks the file exists and answers a structured 404 (guide_unavailable, translated in all 7 languages) with an error logged, instead of raising and returning a 500.

Shipping docs/ in the image (COPY docs ./docs) was written and then reverted at the owner's request — the user guide is going to be reworked first, so there is no point baking the current one into the image. Net effect today: in Docker, /guida answers 404 rather than serving the guide, and the navbar link leads nowhere useful. That is a known, accepted state, not an oversight; adding the COPY line is all it takes once the guide is ready. Verification once it is: docker compose up -d --build && curl -k https://localhost/guida.

B-17

_find_change_output can shrink the recipient output

File: app/tx/broadcast.py:59-63

It returns the first output whose address matches the sender's own address. Nothing prevents a withdrawal to the user's own address (request_withdrawal never compares external_address with user.address), in which case output 0 is the recipient and output 1 is the change — and a fee bump would reduce the recipient output. The same case makes compute_pending_balance (app/wallet/balance.py:55-59) count both outputs, so the displayed pending balance double-counts the withdrawn amount.

Proposed fix: reject a withdrawal whose destination is the user's own address (a distinct error code — it is a user mistake, not a system limit), and/or identify the change output by index rather than by address, recording it on PendingTransaction at build time.

Test: request_withdrawal to user.address is rejected; and given a hand-built tx with two outputs to the same address, the bump reduces the change one (the last), leaving the recipient amount untouched.

Fixed. request_withdrawal rejects a destination equal to the user's own deposit address with its own error code — it was a no-op that cost a fee, and it was the only way for the recipient and change outputs to be indistinguishable by address (which would have made a fee bump shrink the recipient output, and made compute_pending_balance count the amount twice). Test: test_withdrawals.py::test_withdrawal_to_own_address_is_rejected, which also asserts no UTXO was touched.

B-18

DB session held open across Electrum network calls during payout

File: app/rounds/scheduler.py:162-220

_trigger_payout opens a session and, inside it, awaits client.listunspent(...) and client.broadcast(...) before committing. On SQLite that holds the write lock for the duration of two network round-trips (unbounded, per B-01); on Postgres it becomes a long-running transaction.

Proposed fix: restructure into three phases — read config/winner and close the session → do the network work (listunspent, build, broadcast) → reopen a session to persist the result and the PendingTransaction. This also makes the B-05 error handling easier to place, since the failure-prone part is no longer inside a transaction.

Test: the existing payout tests must still pass; add one asserting no session is open while the stub client's broadcast is being awaited (e.g. by having the stub attempt a write through a second session).

Fixed. _trigger_payout is now explicitly three phases — read (session closed), build+broadcast, persist — so no session is held across a network call. The restructuring is also what made B-05's error handling easy to place, since the failure-prone part is no longer inside a transaction. Covered by the existing scheduler tests.

B-19

tip_height is assigned without a monotonicity check

File: app/electrum/listener.py:92-101

self.tip_height = header["height"] accepts a lower height on a reorg. Since _wait_for_next_block compares self._listener.tip_height <= tip_at_close, a regression silently extends the wait by a block.

Proposed fix: full reorg handling is out of scope for v1 by explicit design decision, but the assignment should be guarded: keep max(self.tip_height, header["height"]) and log a warning when a header arrives with a lower height, so the condition is at least visible in the logs when it happens. Do not update tip_header_hex from a header that loses this comparison, or height and hash would describe different blocks.

Test: feeding the consumer a header at height N then N-1 leaves tip_height == N and the hash unchanged.

Fixed. Header handling moved into _apply_header(), which refuses a height below the current tip, logs a warning when that happens, and applies height and hex together — a losing header's hex must not be stored, since that hex is the draw's entropy source and a mismatched pair would be worse than a stale one. Test: test_electrum_listener.py::test_tip_never_moves_backwards.

Low / hygiene

B-20

replaced_by_txid is never written

Files: app/db/models.py:119, app/tx/broadcast.py:116-120, app/api/routes/admin.py:261

bump_fee overwrites current_txid in place and never records what the old txid was. The column exists, is selected, and is rendered in the admin "Transazioni pendenti" table, where it therefore always shows .

Proposed fix: set it in bump_fee (naturally part of the B-02 fix) so the replacement chain is auditable. Note the column semantics are the reverse of what the name suggests for an in-place update — it will hold the previous txid, so either rename it (previous_txid, via a migration) or document the direction on the model.

Test: after a bump, the row's replaced_by_txid holds the pre-bump txid and current_txid the new one.

Fixed. bump_fee records the pre-bump txid in replaced_by_txid. The model now documents that the column points backwards despite its name (renaming it would need a migration and would churn the admin UI; the direction is stated on the field instead). Test: covered by test_broadcast.py::test_bump_fee_retargets_every_stored_txid.

B-21

Dead code and detached-object access in the confirmation poller

File: app/tx/confirmation.py:29-35

pending_ids (line 32) is computed and never used. Line 35 rebuilds the same tuples from ORM objects whose session has already been closed; it works today only because expire_on_commit=False and no commit intervened, so flipping that engine setting would break it silently.

Proposed fix: drop pending_ids and select the plain columns (select(PendingTransaction.id, PendingTransaction.current_txid, PendingTransaction.kind)) instead of hydrating entities, so nothing outlives the session.

Test: covered by the existing tests/unit/test_confirmation.py — it must stay green with expire_on_commit=True forced on the test session factory.

Fixed. poll_once selects plain columns (select(PendingTransaction.id, .current_txid, .kind)) instead of hydrating entities, so nothing outlives the session, and the unused pending_ids local is gone. Covered by the existing (and new) test_confirmation.py cases.

B-22

Amounts rendered by raw division

Files: app/static/app.js (multiple: :295, :424, :660, :673, :680), app/static/admin.js (table cells)

Every amount is displayed as sats / SATS_PER_PLM with no formatting, so values like 0.7000000000000001 are reachable in the balance, the jackpot and the admin tables.

Proposed fix: one formatPlm(sats) helper (fixed decimals, locale-aware grouping via Intl.NumberFormat with the language already resolved by i18n.js), used by every display site. Amounts sent to the server must keep going through Math.round(x * SATS_PER_PLM) — the formatter is for display only.

Test: manual — with a 0.7 PLM jackpot and a 12345678.9 PLM balance, no artefacts and no locale mismatch against the selected language.

Fixed. formatPlm(sats) added to app.js (and fmtPlm to admin.js), using Intl.NumberFormat with the already-resolved language; every display site routes through it. Input fields deliberately keep the raw value — a grouped, localized string would break parseFloat — and amounts sent to the server still go through Math.round(x * SATS_PER_PLM). Verification is manual (browser rendering).

B-23

Concurrent withLoading on the same button

File: app/static/app.js:20-31

withLoading snapshots button.innerHTML and restores it in finally. refreshMe() is invoked from the SSE handler, the poll chain, placeBet, withdraw and showDashboard, all sharing #refresh-btn. Two overlapping calls make the second snapshot the loading label, which it then restores permanently — leaving the button stuck on "Aggiornamento…".

Proposed fix: keep the in-flight promise on the element itself (e.g. a button._loadingPromise / WeakMap); a second call either awaits the existing one or returns immediately, so only the outermost call restores the markup. Also worth de-duplicating the SSE burst: onRoundServerEvent fires three fetches per event, and the broadcaster is generic, so every client reacts to every event.

Test: manual — trigger a bet while an SSE-driven refreshMe() is in flight and confirm the refresh button returns to its icon+label state.

Fixed. withLoading keeps the in-flight promise in a WeakMap keyed by the button: a nested call awaits the existing one and runs its own work without touching the markup, so only the outermost call restores it. Verification is manual (browser interaction).

B-24

The global exception handler breaks the error contract

File: app/main.py:74-77

It answers {"detail": "internal server error"} — a bare string, while app/api/errors.py documents detail as {"code", "message", "params"}. The frontend tolerates a string (apiErrorMessage handles that case), but the contract should be uniform.

Proposed fix: return ApiError("internal_error", "internal server error").as_detail() and add error.internal_error to all 7 languages in app/static/i18n.js. Keep the response body free of exception details — the traceback belongs in logs/app.log only.

Test: an endpoint stubbed to raise answers 500 with detail.code == "internal_error" and no exception text in the body.

Fixed. The catch-all handler returns ApiError("internal_error", "internal server error").as_detail(), and error.internal_error was added to all 7 languages. Test: test_rounds_route.py::test_unhandled_errors_use_the_structured_detail_shape, which also asserts the exception text does not leak into the response body.


Cross-cutting observation

B-01 through B-04 share one root cause: the code treats a broadcast as final and the network connection as never failing. There is no reconciliation between the database's view of the world and the chain's — no re-scan at startup, no terminal failed state, no request timeouts, no "unstick" routine. Combined with the already documented scheduler-resume gap and restart: unless-stopped in docker-compose.yml, the system has several absorbing states from which only manual database edits recover.

Fixing that class of problem is more valuable than any individual item above. Suggested order of work, by expected time-to-first-occurrence in production:

  1. B-01 — will happen within hours of deployment.
  2. B-03 — one bad row is enough, and it is silent.
  3. B-02 — happens on the first bump the RBF loop actually performs.
  4. B-06 — depends only on the user's UTXO shape.
  5. B-05 — happens on the first operator typo, and is unrecoverable in the foreign-address variant.
  6. B-04 + B-08 — the reconciliation layer both of them need.
  7. Everything else.