This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Language
The user communicates in Italian in chat — reply to them in Italian. Everything written to the repository (code, comments, commit messages, docs, this file) must be in English. Reasoning/thinking should also be done in English.
All 10 stages of the original build order are code-complete and unit-tested — 185 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
**Read [BUGS.md](BUGS.md) before trusting any behaviour here.** Two audits: 2026-07-26 found 24 bugs (5 critical), all fixed; 2026-07-27 found 25 more (B-25 … B-49), of which **14 are still open** — no Critical or High remains, only Medium/Low: `_wait_for_next_block` has no timeout or visibility if a round gets stuck in `drawing` (B-36), no WAL/`busy_timeout` under five concurrent SQLite writer tasks (B-39), among others. BUGS.md is the live open list with a proposed fix per finding; "Known gaps" at the end of this file is for limitations accepted **by design** instead. Don't fix a BUGS.md item silently as a side effect of other work — each fix lands with its own regression test.
Before writing code, read the "Architecture" section below in full plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) (the 5-phase flow) and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) (the round/draw lifecycle). Every node **and edge label** (conditions, retries, loops) is a behaviour that must be implemented as described. Regenerate the companion PDFs with `flowchart/render-pdf.sh <file>.mmd` after editing either.
Human-facing guides are in [docs/](docs/), in Italian by explicit request (an exception to the English-only rule): [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md). **[README.md](README.md)'s Quick start is stale** (bare `uvicorn --reload`, `docker compose run … generate_master_key.py` — neither is supported; B-44); this file and `docs/setup.md` are authoritative.
The server always runs via Docker, in dev and prod alike — there is no supported way to run `uvicorn` directly. The venv (`.venv/`) is only for local tooling: tests, Alembic migrations, and the one-time key/secret scripts.
alembic revision --autogenerate -m "message"# after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand
`asyncio_mode = "auto"` (`pyproject.toml`), so async tests need no `@pytest.mark.asyncio`.
`.env` (gitignored) holds the real secrets; `.env.example` documents the required keys and how to generate each. Note it does **not** list `DATABASE_URL` or `MASTER_KEY_PATH`, which the real `.env` does set.
Same `docker-compose.yml` for dev and prod — only `SITE_ADDRESS` differs. Two containers: `app` (this codebase; its startup command refuses to start if the master key file is missing, then runs `alembic upgrade head` and `uvicorn`) and `caddy` (reverse proxy + automatic TLS). The compose file overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted key, logs — gitignored, survive restarts). Set `MASTER_KEY_PATH` in `.env` to the host-side `./data/keys/master.xprv.enc` so the venv scripts write the exact file the container reads — one source of truth for the key.
`SITE_ADDRESS` unset → `localhost`, Caddy issues a self-signed cert from its internal CA (browser warning on first visit is expected; `curl -k`). `SITE_ADDRESS=lottery.example.com docker compose up -d` → real Let's Encrypt cert, automatically renewed (needs DNS pointing here and ports 80+443 reachable).
The `Caddyfile` sets **no** security headers — no CSP, HSTS or `X-Frame-Options` (B-43). `restart: unless-stopped` on `app` means a mid-round crash auto-restarts: `closing` and `paying_out` resume on their own, `drawing` does not (see Known gaps).
- Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or `XPRV_ENCRYPTION_KEY` is empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not*`app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway.
Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumWallet/src/Core/Chain/ChainProfiles.cs), [PalladiumNetworks.cs](../PalladiumWallet/src/Core/Chain/PalladiumNetworks.cs). Re-check there for anything not listed here rather than guessing; its `ChainProfiles.Mainnet.BootstrapServers` is also where `.env.example`'s suggested `ELECTRUM_FALLBACK_SERVERS` come from.
`GET /rounds/current`'s `jackpot_sats` is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual `bet_amount_sats` (each already net of its own bet fee) rather than `count × current bet amount` — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11).
**Round cooldown** (`round_cooldown_seconds`, not in the original flowchart): gap after a round closes before the next opens, so players can see the outcome.
**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner.
## Code map
| Package | Contents |
|---|---|
| `app/main.py` | entry point: lifespan starts the six background tasks, mounts the routers and `app/static/` |
| `ConfirmationPoller` | `tx/confirmation.py` | 10s | `pending` → `confirmed` via per-kind handlers registered by `app/{bets,rounds,withdrawals}/confirmation.py` — imported for that side effect in `main.py`, **don't "clean up" those imports** |
| `PendingTransactionReconciler` | `tx/reconcile.py` | at startup, then 120s | resolves `building`/`pending` rows against the chain |
| `DepositReconciler` | `deposits/reconcile.py` | 300s (sleeps first) | re-`refresh_user`s every address, catching a silently-lost subscription (B-30) |
Chain access goes through `listener.client`, passed as `lambda: listener.client` so a reconnect swaps the client under its consumers; a task finding it `None` skips that cycle instead of failing. `DepositReconciler` takes the whole listener instead, reusing `refresh_user` so the periodic and notification-driven paths can't diverge.
## Electrum connection
One connection serves everything — deposit credits, broadcasts, confirmations, the tip the draw waits on — so it's both the biggest single point of failure and, with a hostile server on the other end, the biggest integrity risk. Five defences:
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
- **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets`*while holding the per-user lock*, and could stall the confirmation poller permanently.
- **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
- **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner.
- **A quorum corroborates the two money-moving decisions** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29). No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing.
On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep.
**REG** — on signup the server derives a P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from the encrypted master xprv. Permanent, and doubles as deposit address, winnings address and withdrawal change address.
**DEP** — the listener subscribes to the user's scripthash; balance is credited after **1 confirmation**, with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
**PLAY** — fixed cost, **at most one active bet per user**. PSBT user-address → pool-address, always with a **change output back to the same user address** (a user's balance must never exactly equal the bet). Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
**DRAW** — configurable timer (default 600s):
- *Bet cutoff is the round's own deadline* (`opened_at + round_duration_seconds`), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`.
- *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
- *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount.
- *Payout* is signed with the pool key; its **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
- *UI, two independent layers.* A generic phase box ("Pagamento al vincitore in corso…") shows to **every** viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by `status`. **Additively**, a personalized "Hai vinto!/Non hai vinto" box appears only where `user_played` is true (computed via `get_optional_user`, since the endpoint is reachable logged-out) — nobody else has anything to reveal.
- *Reveal timing.* Delayed by at least `draw_animation_seconds`, anchored to the server's `closes_at` so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for `winner_user_id`. Once revealed it's persisted in `localStorage.plm_persisted_result`, surviving the move to `closed` — at which point `get_active_round` stops returning the round and `winner_user_id` disappears from `GET /rounds/current`. `GET /users/me/last-round-result` is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: `refreshRound`/`checkLastRoundResult` in `app/static/app.js`.
**WITHDRAW** — the only way out to an external address: PSBT user-address → external + change back to the user, fee deducted from the withdrawn amount, same RBF pattern.
PLAY and WITHDRAW share a **per-user lock** (`tx/locks.py`): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set.
**Three separate on-chain confirmations sit between the timer hitting zero and the payout landing** — a common point of confusion:
1.**Last bet's confirmation** — the round doesn't even flip to `"closing"` until every broadcast bet has 1 conf (`_tick`'s `pending_count` check). May already have happened before the deadline.
2.**The draw block** — `_wait_for_next_block` waits for `tip_height > tip_at_close`, recorded only once step 1 is done, so this is necessarily a later block.
3.**Payout confirmation** — built only after step 2's winner is known, so it needs yet another block; the generic `ConfirmationPoller` tracks it.
At 120s blocks that's ~4–6 min worst case (last bet confirms right at the deadline), ~2–4 min best case — independent of `draw_animation_seconds`.
`place_bet`/`request_withdrawal` select whole UTXOs (`select_utxos`, largest-first) and mark each `spent_txid` at broadcast time, long before any confirmation. `cached_balance_sats` (`recompute_balance`) sums only confirmed, unspent UTXOs, so right after a bet it understates the real balance by the whole unconfirmed change — often far more than the amount actually moving.
`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without changing what's spendable: it decodes the raw tx of every in-flight (`pending`) bet/withdrawal for the user and adds back the outputs paying to the user's own address. `GET /users/me` returns both — `balance_sats` (confirmed only; still what withdrawal-max and spend logic use, since only confirmed UTXOs are spendable) and `pending_balance_sats` + `has_pending` (what the UI shows: green when settled, amber while pending). The gap is user-visible and currently under-explained in errors (B-37).
`GET /rounds/stream` is **additive to** the polling loops in the two SPAs, not a replacement — a blocked or dropped stream just degrades to the old behaviour. No payload, no auth: it's a "something changed, go refetch" ping, with all personalization (e.g. `user_played`) staying in the authenticated REST endpoints. The generator re-checks `request.is_disconnected()` every 5s and sends a keep-alive comment every 20s, so neither a client that vanished without a clean close nor a proxy idle timeout breaks it silently.
`rounds/events.py`'s `RoundEventBroadcaster` (singleton `broadcaster`) is in-process pub/sub, one `asyncio.Queue(maxsize=1)` per client so redundant notifications coalesce. `publish()` is called on: a round opening (`rounds/service.py`), every status transition (`scheduler.py`), a bet or withdrawal broadcast, any pending tx confirming (`tx/confirmation.py`), a deposit credited (`deposits/service.py`), and a new tip arriving (`electrum/listener.py` — exactly what the drawing phase waits on). Rollback paths are the known exception (B-49).
Deliberate scope limits, not oversights: **single-process only** (fine for one uvicorn process; a multi-worker deployment needs e.g. Redis pub/sub — don't add it speculatively); **generic broadcast, not per-user** (everyone refetches on every event; acceptable at ~100 concurrent users, and a targeted channel would need auth on the stream plus server-side knowledge of who each event affects); `MAX_SUBSCRIBERS` (500) is defensive only — past it the endpoint returns 503 and `EventSource` falls back to polling, which being global and unauthenticated makes the cap itself a cheap DoS of the realtime feature (B-38).
Both SPAs refresh on an `update` message *or* on `open` — the latter fires on every automatic reconnect, closing most of the "missed while disconnected" gap.
Everything that spends money is written **before** it is broadcast and resolved against the chain afterwards; this is what makes the system recover without manual DB edits. `PendingTransaction.status`: `building` → `pending` → `confirmed`, or `failed`.
-`building` is written first, UTXOs already marked `spent_txid`, and committed *before* the broadcast (`bets/service.py`, `withdrawals/service.py`, and `scheduler.py:_trigger_payout` — the same shape in four phases, so no DB session is ever held across a network call). A crash in that window leaves evidence, not coins spent on-chain with no record.
- A refused broadcast releases the UTXOs, restores the balance, removes the participant (or marks the withdrawal `failed`), audit-logs, and raises `broadcast_failed` → **502**, since the network refused it, not the caller.
-`tx/reconcile.py` asks the chain about anything still `building`/`pending`: present → promote; positively unknown → `failed` with a `failure_reason`, inputs released, domain row rolled back, `pending_tx_abandoned` logged. Grace differs by state (120s `building`, 6h `pending`, so the bumper gets its attempts first). A *transport* failure never abandons anything — only a server that positively doesn't know the tx, currently inferred by substring-matching the error text (fragile — B-41).
`UtxoEvent.spent_txid` must always equal the tx's *current* txid, so `bump_fee` retargets it along with `RoundParticipant.bet_txid`, `Withdrawal.txid` and `Round.payout_txid` on every bump. `broadcast_at` is the *first* broadcast and is never rewritten (the reconciler's abandon clock measures from it); `last_broadcast_at` is what a bump updates and `should_bump` reads. Confirmation handlers key off immutable ids (`round_id`/`user_id`, `withdrawal_id`), never the txid, which changes under them.
**Payouts retry, and are guarded against paying twice.** Every tick re-examines a `paying_out` round: `_retry_payout_if_due` throttles to one attempt per 60s using the latest `payout_failed` audit entry as its clock (a build failure leaves no DB row to throttle on), and every early return in `_trigger_payout` writes one, so `/admin` shows *why* a round is stuck. Before building, `_trigger_payout` refuses if a `building`/`pending` payout already exists for the round, and `_reserved_payout_outpoints` excludes pool UTXOs claimed by any unresolved payout — without both, a retry would pay the winner twice.
**"At most one active round" is a DB invariant**, not a convention: `ix_rounds_single_active` (unique index over the constant `(1)`, restricted to the active statuses) makes a concurrent second insert fail cleanly, and `open_new_round_if_needed` recovers by adopting the winner's round (max 3 attempts).
Two static SPAs served directly by FastAPI (`main.py` mounts `app/static/` and adds routes for `/admin`, `/guida`, `/report-bug`) — no build step, no framework, no bundler, `Cache-Control: no-store`.
- **`/`** — end-user test UI: register/login, then a navbar dashboard with four panels (Deposito with a QR from `GET /qr/{address}`, Bet, Prelievo, Profilo — account info + self-service password change via `POST /users/me/change-password`), above a persistent round-status card and the chain-status bar with the language switcher.
- **`/admin`** — gated by a token screen (not a login: just `X-Admin-Token` vs `ADMIN_TOKEN`), then five sections each backed by its own `/admin/*` endpoint: Parametri (`RoundConfig` + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log; plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction.
`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is always available. Language: `localStorage.plm_lang` → `navigator.language` → `en`. The switcher sits in the **chain-bar, not the navbar**, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it.
- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`) via `applyStaticTranslations(root?)`; anything rendered from server data uses `t()` in `app.js` and is re-rendered by `onLanguageChange()`. An element belongs to one camp or the other, **never both**, or the two mechanisms overwrite each other — that's why `#bet-btn` has no `data-i18n`: its label carries the configurable bet amount, so `renderBetButton()` owns it.
- **Every language must have exactly the same key set.** There is no fallback beyond `en`; a missing key renders as the raw key string.
-`/admin` is intentionally untranslated (operator-facing, Italian), as is `/guida`.
**API error contract** (`app/api/errors.py`) — the API is single-language by design. Failures answer with a structured `detail`: `{"code", "message", "params"}`, where `message` is English for non-dashboard consumers and `code` is what the frontend maps to `error.<code>` in `i18n.js` (falling back to `message` for an unknown code). `BetError`/`WithdrawalError` subclass `ApiError` and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (`internal_error`), so clients never special-case unexpected errors, and the exception text stays in `logs/app.log`. Adding a user-facing error: give it a code, add `error.<code>` to all 7 languages, and pass values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling) instead of baking them into English text.
- Keys are generated and held **server-side**: this is **custodial**. The user controls nothing until they withdraw.
- The deposit address *is* the winnings address — there is no separate "winner address".
- **1 confirmation** for every tx kind. Don't introduce differing thresholds (3, 6, …) without an explicit decision.
- The draw algorithm is a **replaceable component**, not the final design — don't architect around its current form.
-`GET /admin/users/{id}/privkey` exporting a raw WIF is **intentional**, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes `admin_privkey_accessed` — don't remove that logging.
- Argon2 hashing means **no password recovery, only reset**: `POST /admin/users/{id}/reset-password` sets a new random password, returns it once for the operator to relay, and logs `admin_password_reset`. No self-service reset exists (no email is ever collected); a logged-in user can only *change* their password by supplying the current one.
- RBF bumps are paid by whoever's change the tx pays back to — the user for bets/withdrawals, the pool for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only the sender's own change shrinks (`bump_fee`).
- **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_wait_for_next_block`. That wait is also unbounded and invisible in `/admin` (B-36) — the last prerequisite for running unattended.
- **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback — an exact-amount tx or too-small change raises `RbfError`. Not permanent, though: an unbumpable tx that never confirms is eventually abandoned and its UTXOs released.
- **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network).
- **No user-facing history.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
- **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
- **No rate limiting anywhere** (register, bet, withdrawal, admin, SSE). For login this is a blocker, not a gap — tracked as B-33.
- **`/guida` and `/report-bug` are placeholders** (`app/static/guida.html`, `report-bug.html`) — links work, content is "coming soon".
- **No integration tests against a live Electrum connection.** `tests/integration/` is empty; live verification has all been manual (`scripts/electrum_smoke_test.py`, ad hoc scripts, real mainnet txs).
- **Single-process assumptions**: the SSE broadcaster and the per-user locks are in-process only. A multi-worker deployment needs a shared channel and a DB/Redis lock. The round-uniqueness invariant is *not* in this category — it's a DB index.