- app runs as uid 1000; a one-shot data-permissions service chowns ./data to it and closes it to others (works on an older root-owned install with no host sudo); files are created 0600 (umask 077) - python:3.12-slim and caddy:2-alpine pinned by digest; dependencies installed from requirements.lock with --require-hashes - the Fernet key is XPRV_ENCRYPTION_KEY_FILE (./secrets/xprv_encryption.key, a compose secret), out of every environment variable and of a ./data backup - setup.md no longer presents a second way to run the server (B-72) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
59 KiB
CLAUDE.md
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.
Project status
All 10 stages of the original build order are code-complete and unit-tested — 728 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 (at the time, 1-conf; deposits now need deposit_confirmations), 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.
Two full-codebase audits — 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are all fixed as of 2026-07-27, each with its own regression test. A third audit (2026-08-03, B-52 … B-72) and a fourth, the 2026-09-28 specification review (B-73 … B-98: draw manipulation, ambiguous broadcasts, single-txid tracking, single-server abandonment, the 64 KiB Electrum reply limit, unbumpable payouts, 1-conf deposit reorgs, ops hazards), live in the tracked BUGS.md, which lists only what is still open (B-71 … B-98 at the time of writing). The spec in docs/ already describes the target behaviour for all of them — every affected requirement is marked *Stato:* da implementare (B-nn) with an *Oggi:* line saying what the code does now, so read both before assuming a requirement is implemented; the design decisions and the implementation order are in docs/piano-correzioni.md; the full history of every B-nn, open and closed, is in docs/storico-bug.md. Closed findings leave the file, so the ~276 B-nn markers left in comments across the code are pointers into git history (git log --all --grep 'B-nn' finds the commit that fixed one, and git show f1a1145:BUGS.md-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (tests/integration/ is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted by design instead. A new finding gets the next B-nn, in its own commit with its own regression test.
docs/ is the specification, and it is authoritative. Every verifiable rule there has a stable requirement ID (RND-04, BET-02, …) with *Codice:*/*Test:* pointers to what implements it; the index is docs/come-funziona.md and the ID convention and change workflow are in docs/processo-di-modifica.md. A behaviour change starts in the spec: edit or add the requirement, implement it, cite the ID in the test docstring and the commit message, and ship spec + code + test in one commit. If this file and the spec disagree, the spec wins and this file is the one to fix. Before writing code, read the relevant spec documents and the "Architecture" section below.
Human-facing guides are in docs/, in Italian by explicit request (an exception to the English-only rule): the specification starting at come-funziona.md, the operational guides setup.md, running-the-server.md, guida-utente.md, guida-admin.md. README's Quick start and docs/running-the-server.md are Docker-only, matching this file — a bare uvicorn --reload workflow was removed from both (B-44).
Documentation map (docs-first, SSOT, ADR)
Entry point: docs/README.md — the index of everything under docs/. Three rules, all in docs/processo-di-modifica.md:
- Docs-first. Behaviour changes start in
docs/, never in the code. Spec + code + test ship in one commit, with the requirement/ADR ID in the test docstring and the commit message. - SSOT. Every fact has one owner; everything else links to its ID instead of restating it. Why → an ADR (docs/adr/); what (verifiable rule) → a
PREFIX-NNrequirement in the domain document; how → the code; accepted limits → limiti-noti.md (LIM-nn); found/fixed defects → storico-bug.md (B-nn, open ones in BUGS.md). If two documents disagree, the spec wins and the other one gets fixed. This file included: it summarises, it does not own. - One ADR per feature/decision. A new feature, or a change of an existing choice, starts with an ADR from docs/adr/TEMPLATE.md (next free
ADR-NN, never reused or rewritten — a superseded one becomes "Sostituita da ADR-MM"), then its requirements, then the code. Add it to the area index and the feature → ADR table in docs/adr/README.md. Before touching a feature, read its ADRs there.
| Need | Go to |
|---|---|
| Why is it built this way / alternatives rejected | docs/adr/README.md (the old DEC-nn are now ADR-nn, same numbers) |
| What exactly must it do | domain docs listed in docs/README.md (RND, BET, WDR, DEP, REG, TX, ELC, AUD, ARC, DAT, API, SEC, OPS) |
| Narrative overview | docs/come-funziona.md |
| Accepted limits | docs/limiti-noti.md |
| Fix order for open B-nn | docs/piano-correzioni.md |
Commands
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.
source .venv/bin/activate # venv already created at .venv/
pip install -e ".[dev]"
alembic revision --autogenerate -m "message" # after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the master xprv (needs the key file `XPRV_ENCRYPTION_KEY_FILE` points at)
PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print it (asks for confirmation)
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # all 728 tests
python -m pytest tests/unit/test_hd.py # one file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
asyncio_mode = "auto" (pyproject.toml), so async tests need no @pytest.mark.asyncio.
.env (gitignored) holds the real secrets (except the Fernet key, which is secrets/xprv_encryption.key); .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.
Deployment (Docker + Caddy)
Same docker-compose.yml for dev and prod — only SITE_ADDRESS differs. Three services: a one-shot data-permissions (root, chowns ./data to uid 1000 and closes it to others, B-91), app (non-root uid 1000; 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.
mkdir -p data/db data/keys data/logs # one-time
docker compose up -d --build # dev and prod alike
docker compose logs -f app # also written to ./data/logs/app.log
docker compose down
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 sends baseline security headers — HSTS, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy, and a CSP scoped to default-src 'self' plus the Google Fonts @import in style.css/admin.css. It also overwrites X-Forwarded-For with the real peer (header_up X-Forwarded-For {remote_host}, B-54) — Caddy otherwise appends to whatever the client sent, which made every IP-keyed control (the B-33 throttles, B-38's SSE cap) bypassable; app/api/client_ip.py independently reads the last hop, so either half closes it. script-src/style-src need 'unsafe-inline' because both SPAs use inline onclick handlers and style="" attributes throughout — removing those is a separate, larger refactor, not a header change. restart: unless-stopped on app means a mid-round crash auto-restarts: closing, drawing and paying_out all resume on their own (B-81: _tick re-enters _complete_draw, which rebuilds everything from the database, so a resumed draw yields the same winner).
Tech stack
- Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite,
embitfor keys/PSBT/tx parsing. - PLM access via the Electrum protocol only (no full node/P2P). Dev bootstrap server:
santantonio.sytes.net:50002(SSL). - Auth: Argon2 hashing + JWT (HS256, 24h). Tokens are revocable (B-34): the token carries a
tvclaim,User.token_versionis bumped by a self-service password change and by an admin reset, andget_current_user/get_optional_userreject any token whosetvno longer matches — so changing the password invalidates every session issued before it, instead of leaving them valid for up tojwt_expire_minutes. A token predating the claim decodes astv = 0, which is what a migrated user starts at, so the deploy didn't log everyone out. Argon2 costs tens of ms of CPU per call by design, so every async caller goes throughhash_password_async/verify_password_async(run_in_threadpool, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts. - Secrets: master xprv Fernet-encrypted at rest, encryption key in its own file,
./secrets/xprv_encryption.key(XPRV_ENCRYPTION_KEY_FILE; a compose secret in the container, never in the DB, git or an environment variable — B-91).validate_runtime_secrets()(app/config.py, called from the lifespan — deliberately not aSettingsvalidator, so imports and tests need no real secrets) makes the server refuse to serve ifJWT_SECRET< 32 chars or the encryption key file is missing or empty. An emptyADMIN_TOKENis deliberately non-fatal:require_adminthen 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 onRoundConfig(app/db/models.py), notapp/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.
PLM network parameters (mainnet)
Source of truth: the PalladiumWallet repo — ChainProfiles.cs, 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.
| BIP44/84 coin type | 746 → m/84'/746'/0'/0/index |
| Bech32 HRP | plm |
| P2PKH / P2SH version byte | 55 (addresses start with P) / 5 |
| WIF prefix | 0x80 |
| Block time | 120s |
| BIP32 ext-key headers | see ExtKeyHeaders in ChainProfiles.cs |
Business parameters
| Parameter | Value | Where |
|---|---|---|
| Bet cost | 10 PLM (bet_amount_sats = 1_000_000_000) |
RoundConfig, admin-editable, snapshotted onto Round.bet_amount_sats when a round opens (B-95) — an edit applies from the next round |
| Prize split | 70% winner / 30% platform, rounding remainder to the platform; the platform's share pays the bump reserve (max(294, vsize(inputs, 3 outputs) × 10) sat, B-79) to the reserve address 1/0 and the rest goes to fee_address |
hardcoded in rounds/service.py:winner_share / wallet/psbt_builder.py:payout_bump_reserve — a code change, not an admin edit |
| Round duration / cooldown | 600s / 30s | RoundConfig, but snapshotted onto Round.duration_seconds/cooldown_seconds when a round opens (B-61) — an edit applies from the next round, never to the one in progress |
| Draw animation | 20s (cosmetic frontend minimum only) | RoundConfig |
| Draw confirmations | 3 blocks on the draw block (draw_confirmations, 1–20) |
RoundConfig, admin-editable, snapshotted onto Round.draw_confirmations at open (B-73) |
| Commission address | fee_address |
RoundConfig, admin-editable, snapshotted onto Round.fee_address at open (B-95): the payout pays the round's own, never the live one |
| Pool cap | max_round_pool_sats, 0 = none (otherwise ≥ the bet cost, ≤ 4·10^15) |
RoundConfig, snapshotted onto Round.max_round_pool_sats at open (B-95); place_bet refuses with round_full (params max_round_pool_sats) once the sum of the round's participant rows has reached it, in-flight bets included — the pool can end up above the cap by at most one bet |
| Fee rate / RBF timeout | 1 sat/vB / 900s | RoundConfig |
| Min withdrawal | = current bet_amount_sats (no separate field) |
withdrawals/service.py |
| Min deposit | none | — |
| Min password length | 8 | auth/security.py:MIN_PASSWORD_LENGTH |
| Confirmations | 1 for every platform-built tx (confirmation poller) and for the outputs it creates (bet/withdrawal change, payouts); deposit_confirmations (6) for a deposit, 120 for a coinbase output (B-82) |
tx confirmation hardcoded in tx/confirmation.py; deposit threshold in round_config (/admin), coinbase maturity hardcoded in deposits/service.py |
| Max inputs per user tx (bet, withdrawal) | 50 (MAX_TX_INPUTS, B-48) — over it the build fails with too_many_inputs, it never spends more |
hardcoded in wallet/psbt_builder.py |
| Max inputs per payout | 500 (MAX_PAYOUT_TX_INPUTS, B-52) — the pool holds one UTXO per bet, so reusing the user cap made any round past ~50 players unpayable |
hardcoded in wallet/psbt_builder.py |
| Max participants per round | 400 (MAX_PARTICIPANTS_PER_ROUND, B-52) — the 401st bet is refused with round_full before any money moves, so "a round can always be paid out" is an invariant rather than something discovered at payout time |
hardcoded in wallet/psbt_builder.py, enforced in bets/service.py |
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). It counts confirmed participants only (B-65), matching what the draw picks from and what the payout can spend, with pending_participant_count/pending_jackpot_sats/has_pending_bets reporting the in-flight bets alongside — inclusive figures, not deltas, exactly like pending_balance_sats (see "Balance display"). /'s round card shows the confirmed numbers big and the difference as an amber "+N in attesa" suffix, so a player who just bet sees their own bet immediately without the advertised jackpot ever exceeding what will be paid.
Round cooldown (round_cooldown_seconds, not in the original design): 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, with paused_reason (B-78, DAT-69): operator, or an automatic reason the system wrote itself (resurrected_tx, reorg, payout_blocked, rounds/pause.py) — an operator pause never overwrites an automatic one, and resume clears whichever it is.
No fee_address, no rounds (rounds/service.py:rounds_can_open, B-66): the payout pays the 30% commission to fee_address, which has no column default because an operator must set their own — so until they do, open_new_round_if_needed refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in paying_out with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as lottery_configured on GET /rounds/current — / shows a different banner from the maintenance one, since "come back later" would be false — and as a warning on /admin's Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in rounds_can_open next to it, not discovered at payout time. Since B-79 (RND-40) that includes the amounts: payout_config_problem(bet_amount_sats, fee_rate_sat_vb) checks a round of 1 and of MAX_PARTICIPANTS_PER_ROUND participants against the winner's network fee, the bump reserve and dust outputs, and PUT /admin/config refuses a bet amount or fee rate that fails it with 422 config_not_payable (judged on the resulting values and, for a fee-rate change, on the in-progress round's own snapshotted cost as well; B-95 adds 422 max_round_pool_below_bet_amount for a cap below the cost of one bet) (the payout reads fee_rate_sat_vb live, so raising it mid-round is exactly what could otherwise strand the round in progress).
Code map
| Package | Contents |
|---|---|
app/main.py |
entry point: lifespan starts the six background tasks, mounts the routers and app/static/ |
app/api/routes/ |
admin, bets, withdrawals, rounds (incl. SSE), users, qr, bug_reports; app/api/errors.py holds the error contract and app/api/client_ip.py the trusted-peer extraction every IP-keyed control uses |
app/auth/ |
routes (register/login), Argon2 + JWT (security.py), get_current_user/get_optional_user, login/registration throttling (rate_limit.py) |
app/db/ |
models.py (all tables + the active-round index), engine/session factories |
app/wallet/ |
HD derivation (users 0/i, per-round pool 2/<round_id>, reserve 1/0) + WIF export (hd.py), PLM network constants, address/scripthash, balance math, psbt_builder.py (build/sign bet, withdrawal, payout; select_utxos) |
app/electrum/ |
client.py (JSON-RPC, endpoint parsing, timeouts), listener.py (the one connection: rotation, keepalive, header validation, corroboration, deposit crediting) |
app/deposits/ |
crediting / external-spend detection / reinstatement (service.py), periodic sweep (reconcile.py) |
app/bets/, app/withdrawals/ |
build+broadcast services and their confirmation handlers |
app/rounds/ |
scheduler.py (close/draw/payout), service.py (open/active-round rules), draw.py (header math, powLimit, commit-reveal seed + winner pick), config.py, events.py (SSE pub/sub) |
app/tx/ |
broadcast.py (RBF bumper), outcome.py (broadcast verdict, B-76), versions.py (every txid of a row, B-77), confirmation.py (poller + handler registry), reconcile.py, locks.py (per-user locks) |
app/static/ |
the two SPAs (index.html/app.js/style.css, admin.html/admin.js/admin.css) + i18n.js |
Background tasks
app/main.py's lifespan starts six long-lived asyncio tasks and cancels them on shutdown. Their cadences determine how fast anything self-heals.
| Task | File | Cadence | Role |
|---|---|---|---|
ElectrumListener |
electrum/listener.py |
reconnect loop, 60s keepalive | the single connection; subscribes headers + every user's scripthash, credits deposits |
RoundScheduler |
rounds/scheduler.py |
5s | opens/closes rounds, draws, triggers and retries payouts |
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 |
RbfBumper |
tx/broadcast.py |
30s | fee-bumps anything past rbf_timeout_seconds |
PendingTransactionReconciler |
tx/reconcile.py |
at startup, then 120s | resolves building/pending rows against the chain |
DepositReconciler |
deposits/reconcile.py |
300s (sleeps first) | re-refresh_users 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. A non-null client means the tip is already known: _run_once publishes it only after the first header has been applied (B-63), so client is not None can be read as "the chain is reachable and we know where it is" — tip_height is never the initial 0 behind a live client, which is what the draw depends on (see DRAW below). 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/PORTis primary,ELECTRUM_FALLBACK_SERVERSa comma-separatedhost:port[:notls][:pin=<sha256>]list (client.py:parse_endpointsrejects malformed entries at startup, not during the outage when the fallback is needed;pin=fixes the server's DER certificate hash,ELECTRUM_CERT_PINdoes the same for the primary, B-84). The server refuses to start with fewer than 2 fallbacks (config.validate_quorum_servers, B-84;ALLOW_NO_QUORUM=trueoverrides it, development only). 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 hangPOST /betswhile 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_onceraces it against the notification consumers and a 60sserver.ping. A server that keeps answering pings but stopped following the chain is caught too (B-86): at every ping, if no header has been accepted for 360s (_STALL_SECONDS),_check_not_stalledasks the other servers for their tip and ends the session withServerStalledErrorif any is higher, sorun()rotates; none higher means a quiet chain and the session goes on. Without it the listener sat on queues nobody would ever fill whilelistener.clientstill looked alive. - The chain is checked, not just the headers (B-99, ELC-30):
ElectrumClient.connectfollowsserver.versionwithserver.featuresand refuses a server whosegenesis_hashis not PLM's. PLM shares Bitcoin's genesis and powLimit, so a Bitcoin Electrum server would pass every header check below; this is what tells them apart, for the quorum's connections too. - Headers are validated, not trusted (
_apply_header, async since B-74): the tip never regresses, a header must meet the difficulty target it claims and that target must be no easier thandraw.POW_LIMIT(PLM's powLimit;tests/unit/conftest.pyswaps in a regtest limit so tests can mine headers, and the tests of the limit itself putMAINNET_POW_LIMITback), and every advance must be linked to the tip: a single block by itsprev_hash, a jump by downloading the headers in between (blockchain.block.headers, ≤2016 per request) from the active server and checking each. Failure raisesHeaderValidationError, 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. The listener keeps the last 10 headers (_window, built from the first header plus its 9 predecessors); a header that does not join the tip is a reorg, walked back to its fork point inside the window and followed (_follow_reorg), a fork older than the window ends the session. A followed reorg writeschain_reorg_detectedbefore the window moves (_record_reorg, B-85), and a depth of 2 or more pauses the lottery withpaused_reason = 'reorg'in the same transaction; a header at the tip's own height with a different hash is a reorg too, not ignored. One more header is refused without ending the session (B-64): one carrying nohexat all (nothing to validate or draw from, and applying the height alone would break thetip_height/tip_header_hexpairing)._run_onceseparately refuses to publish the client while no tip is known, so the ignore-don't-kill choice can't reopen B-63. - A quorum corroborates every money-moving decision (
_corroborate_majority, 10s per server, asking only the other endpoints — never the active one, which is what a MITM controls):corroborate_headerbefore a block seeds the draw (B-28),corroborate_utxo_spentbefore a UTXO missing from onelistunspentis written off as externally spent (B-29), andcorroborate_utxo_creditbefore a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — and since B-83 that includescorroborate_utxo_reinstatementbefore a UTXO written off as externally spent is put back (same outpoint, amount, confirmed;deposits/service.py:find_reinstate_candidatesonly reads,reinstate_reappeared_utxoswrites what the quorum agreed on), andconfirmation_provenbefore a tx is marked confirmed:tx/confirmation.py:poll_oncetakes the height the active server reports only as a claim, and the row confirms when a strict majority of at least 2 of the other servers return aget_merkleproof that recomputes to the merkle root of the block's header (electrum/merkle.py; the header is the tip we hold, or for an older block the one the quorum agrees on and that meets its own target). With nonetworknothing confirms. Since B-82 the pool-coin check before a payout is on the quorum too (verify_pool_outpoints, below). A quorum needs at least 2 answers and a strict majority of those that answered (B-84): fewer → returns False, since one voice decides nothing independently and an unreachable network proves nothing. OnlyALLOW_NO_QUORUM=truekeeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only delays:find_new_credit_candidatesre-offers the outpoint on the next refresh orDepositReconcilersweep.
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.
Architecture — the 5 phases
Diagrams: come-funziona.md (the 5 phases), ciclo-di-vita-del-round.md (the round/draw lifecycle).
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 once the output reaches its threshold (B-82, DEP-03: 1 if a transaction of the platform created it — any txid of a pending_transactions row —, 120 for a coinbase, recognised from the raw tx that must hash to the txid, ElectrumListener.is_coinbase, and round_config.deposit_confirmations, default 6, for everything else) and only once the other servers corroborate the outpoint, its amount and a height that gives it that many confirmations on our tip (B-59, ELC-17). The deposit is the one input a user can double-spend, hence the 6. Outputs short of their threshold are pending deposits (ElectrumListener._pending_deposits, memory only): shown in pending_balance_sats (amber), never spendable, and since no scripthash notification announces a maturing deposit each new block refreshes only the users that have one (_refresh_waiting_deposits). A deposit that vanishes later takes the spent-outside path; there is no other 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, and at most MAX_PARTICIPANTS_PER_ROUND (400) players per round — past that the bet is refused with round_full and the player waits for the next round (B-52: the payout must spend one pool UTXO per bet, so a round is only ever allowed to grow to what a single payout tx can drain). PSBT user-address → pool-address, always with a change output back to the same user address of at least DUST_LIMIT_SATS — a user's balance must never exactly equal the bet, and since B-62 that's enforced (balance_leaves_no_change) rather than assumed. 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 (
round_deadline=opened_at + Round.duration_seconds, the value snapshotted at open time — B-61), not the DB status:place_betcallsrounds/service.round_accepts_bets, which rejects once the deadline passes even whilestatusis still"open"(the 5s scheduler tick can lag behind it). Once a round leavesopen, no new bets either, and no new round opens until this one is fullyclosed. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a compare-and-set on the round row (UPDATE rounds ... WHERE status = 'open', B-53): the scheduler flipsopen→closingin a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (onlyconfirmedparticipants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is_close_and_drawre-counting in-flight bets in the same session it snapshots the participants from. - "Yellow light": closing waits for every already-broadcast bet to confirm before drawing, so a bet in flight at the boundary isn't lost (
buildingcounts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms). - Algorithm (deliberately simple, meant to be replaced; commit-reveal since B-73, spec RND-14 … RND-20, RND-34 … RND-37): at open the round gets a 32-byte
server_seed— stored only Fernet-encrypted with the master-xprv key (server_seed_enc; the plaintext columnserver_seedstays NULL until the reveal) — and publishesserver_seed_hash = sha256(seed). Enteringdrawingfixes, in one transaction with the status change, the confirmed participants ordered by bet txid ascending (public data, no ties — neverbroadcast_at), each one'sdraw_position,participants_hash(sha256 oftxid:vout:address\nlines;bet_voutis the output paying the round's pool address), anddraw_height = tip_at_close + 1, and writesdraw_committed. The draw waits until the chain hasdraw_confirmationsblocks fromdraw_height(snapshotted at open fromround_config, default 3), then asks every server, the active one included, for the header at that exact height (ElectrumListener.agreed_block_hash: valid work, at least 2 other answers, strict majority of all answers) — no agreement means the same height is asked again, never another block, so whoever mines or relays blocks cannot pick the one that seeds the draw (draw_header_corroboration_failed, once per distinct hash the active server reported).seed = sha256(server_seed ‖ block_hash ‖ participants_hash)as a big-endian integer,index = seed mod n, winner = participant with thatdraw_position; equal probability for everyone, regardless of amount. The reveal (server_seedcolumn +winner_drawnpayload) happens in the same transaction that stores the winner (or when a round closes with no participants), andGET /rounds/{id}/proof(public) returns everything needed to recompute it. A tip of 0 at closing means unknown: the round staysclosing(draw_baseline_tip_unknown, once per round, B-63) becausedraw_heightmust come from a height we actually knew.draw_stalledfires when the chain has not advanced for 360s while waiting for confirmations, or the quorum keeps failing to agree._complete_drawreads everything it needs from the database (_close_and_drawentersdrawingand then calls it, and since B-81_tickcalls it again for any round found indrawing— after an exception or a restart — so it is idempotent: a round no longer indrawing, or already holding a winner, is left alone); a round opened before B-73 (noserver_seed_enc) is given a secret as it entersdrawing. - Payout is signed with the round's own pool key — every round has its own pool address
m/84'/746'/0'/2/<round_id>, stored inrounds.pool_addresswhen the round opens (B-80;derive_pool_address(round_id),derive_round_pool_key), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address1/0, or tofee_address) is refused withwithdrawal_to_platform_address. Rounds opened before B-80 keep the old shared1/0address as their pool; it is now the reserve address. The payout's network fee comes out of the winner's 70%; the platform's 30% pays the bump reserve (B-79, RND-38: always a third output, to the reserve address1/0, recorded inrounds.bump_reserve_sats) and the remainder goes tofee_address. The reserve is what makes the payout bumpable — it normally spends exactly the round's bet outputs and so has no change — andbump_feeshrinks it (_find_reducible_output: the reserve output for a payout, the user's change for a bet/withdrawal), never the winner's or the fee address's output. Same timeout → RBF → rebroadcast pattern. Verified before it is built (B-82, RND-22, RND-39,_trigger_payout→_verify_round_pool→ElectrumListener.verify_pool_outpoints): a round with its own pool address spends exactly its confirmed participants' bet outputs (bet_txid:bet_vout), never whatever else sits at the address, and first asks the other servers whether each is still there — a strict majority of at least 2 answers; a mempool-only coin or a tie concludes nothing (payout_failed,pool verification unavailable, retried). A participant whose bet is gone becomesreorged_out,participant_reorged_outis audit-logged,pool_amount_satsis rewritten (only ever downward) in the same transaction and the 70/30 split is computed on what is left. If the winner's own bet is gone nothing is built:payout_blocked_atis set,payout_blockedis logged and the lottery is paused withpaused_reason = 'payout_blocked'; the winner's row staysconfirmedso that afterPOST /admin/resume(which clears the mark) the verification really runs again. A round opened before B-80 (pool = the shared reserve address) keeps the old active-serverlistunspentchoice. - 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 whereuser_playedis true (computed viaget_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'scloses_atso a reload can't reset the countdown, and decoupled from the real (~block-time) wait forwinner_user_id. Once revealed it's persisted inlocalStorage.plm_persisted_result, surviving the move toclosed— at which pointget_active_roundstops returning the round andwinner_user_iddisappears fromGET /rounds/current.GET /users/me/last-round-resultis the durable DB-backed backstop for a device that missed the live window entirely. Full logic:refreshRound/checkLastRoundResultinapp/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. A full-balance withdrawal moves balance - DUST_LIMIT_SATS so the change output (and with it the ability to fee-bump) always exists — Withdrawal.amount_requested_sats vs amount_sent_sats is what records the difference (B-62).
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. That is the only mutual exclusion between them (B-70): a withdrawal is accepted while a bet is still unconfirmed, as long as confirmed, unspent UTXOs cover it — the lock plus place_bet/request_withdrawal only ever offering select_utxos UTXOs with spent_txid IS NULL is what prevents the two from picking the same input, so freezing the rest of the balance for a block on top of that would restrict the user without protecting anything.
Three separate on-chain confirmations sit between the timer hitting zero and the payout landing — a common point of confusion:
- Last bet's confirmation — the round doesn't even flip to
"closing"until every broadcast bet has 1 conf (_tick'spending_countcheck). May already have happened before the deadline. - The draw block — fixed at
draw_height = tip_at_close + 1(recorded only once step 1 is done, so necessarily a later block) and read once the chain hasdraw_confirmationsblocks from it (3 by default, so it is really ~3 blocks, not one). - Payout confirmation — built only after step 2's winner is known, so it needs yet another block; the generic
ConfirmationPollertracks it.
At 120s blocks that's ~8–10 min worst case (last bet confirms right at the deadline), ~6–8 min best case with the default 3 draw confirmations — independent of draw_animation_seconds.
Balance display
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). A withdrawal whose amount is covered by the pending-inclusive balance but not the confirmed one gets balance_pending_confirmation instead of a flat insufficient_balance (B-37), so the error doesn't contradict what the user is looking at.
Real-time updates (SSE)
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). The rollback paths (_release_failed_bet, _release_failed_withdrawal, the reconciler's abandon) publish too — a rollback moves as much state as the success path, so it must ping the dashboards the same way (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.
Transaction lifecycle and reconciliation
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.
buildingis written first, UTXOs already markedspent_txid, and committed before the broadcast (bets/service.py,withdrawals/service.py, andscheduler.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.- Nothing is released on doubt (B-76).
tx/outcome.py:broadcast_with_verdictsorts a broadcast into accepted / refused / uncertain. Only an explicit JSON-RPC error (ElectrumRpcError) thatElectrumListener.transaction_knownthen confirms — the active server and the quorum minimum of the others all say they do not know the tx — releases the UTXOs, restores the balance, removes the participant (or marks the withdrawalfailed), audit-logs, and raisesbroadcast_failed→ 502. If any server returns the tx, or the message is an "already known" one, it counts as accepted. A timeout, a dropped connection or a refusal that could not be verified leaves the rowsbuildingfor the reconciler and the API answers 202. An accepted tx is also fanned out to every other server (fan_out_transaction, best effort). tx/reconcile.pyasks the quorum about anything stillbuilding/pending(every txid of the row, on the active server and every other one): present on any server → promote (and adopt the version found); otherwise nothing is given up without proof (B-78, ADR-51): a row is abandoned —failedwith the proof infailure_reason, inputs released, domain row rolled back,pending_tx_abandonedlogged — only if one of its inputs is spent in a block by another tx and the other servers agree (confirmed_spender), or no server knows any txid and a rebroadcast of its latest raw tx (rebroadcast_everywhere) is refused by every server for missing/spent inputs. A fee refusal is not proof (building→pendingfor the bumper); too few answers, a transport failure or the age of the row prove nothing. Grace only decides from when a row is examined (120sbuilding, 6hpending). The write re-checks status andcurrent_txidunderbegin_immediate(TX-26).failedrows stay watched for 14 days (failed_at): one that confirms late is markedconfirmed,pending_tx_resurrectedis logged and the lottery pauses (paused_reason = 'resurrected_tx',rounds/pause.py); the linked rows are never corrected by the system. A tx still in some server's mempool is never abandoned, so in practice that is what bounds theclosingwait.
SQLite transactions are real ones (B-88). _register_sqlite_pragmas sets synchronous=FULL and switches pysqlite's implicit BEGIN off (isolation_level = None); an engine begin event emits BEGIN itself, so every read is inside a transaction. Every transaction that moves money or fixes its state opens with await begin_immediate(session) (app/db/base.py) — BEGIN IMMEDIATE, i.e. the write lock before the first read (it commits the caller's read-only transaction first and refuses a session with unflushed changes). A lock not granted within busy_timeout raises OperationalError: before a broadcast that means "refuse" (service_busy, 503, nothing built or sent); after one nothing is undone — the row stays building and the reconciler resolves it. New money-moving write code must call begin_immediate first, and tests of that must use a file DB whose engine went through _register_sqlite_pragmas (an in-memory engine without it never emits BEGIN IMMEDIATE).
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. Every version a row ever had is tracked (B-77): pending_transaction_txids (app/tx/versions.py) holds each txid with its raw tx, written with the row and, for an RBF replacement, before it is broadcast; the poller and the reconciler look for all of them, and when the network holds a version other than the current one broadcast.py:adopt_version makes it current and retargets everything. bump_fee ends with a conditional UPDATE … WHERE status='pending' AND current_txid=<replaced>; zero rows is a logged conflict, never an overwrite. 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).
Frontends
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 is set on the four HTML routes only; the JS/CSS under the StaticFiles mount get ETag/Last-Modified and no Cache-Control.
/— end-user test UI: register/login, then a navbar dashboard with four panels (Deposito with a QR fromGET /qr/{address}, Bet, Prelievo, Profilo — account info + self-service password change viaPOST /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: justX-Admin-TokenvsADMIN_TOKEN), then six 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, Bug report (triageopen→read→resolved, audit-loggedbug_report_status_changed); plus a live Electrum/tip-height pill. Deliberately not linked from/in either direction./report-bug— standalone page (no navbar, own language switcher), reachable logged-in or logged-out:POST /bug-reportsstores the report with the submitter attached when there is one,GET /bug-reports/mineis the reporter-side status view for the logged-in case, and/admin's Bug report section is the triage end.
Both talk to the same JSON API; there's no admin/user API split beyond require_admin.
Internationalization (/ and /report-bug)
app/static/i18n.js holds every user-facing string of / and /report-bug in 7 languages (en, it, es, fr, de, ru, zh) as one flat TRANSLATIONS table — no build step, no fetch. / loads it before app.js; /report-bug loads it before its own inline script — either way t() is always available by the time it's called. Language: localStorage.plm_lang → navigator.language → en, shared across both pages since they read/write the same localStorage key. On / 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. /report-bug has no navbar at all, so its switcher is just a top-right bar of its own.
- Static markup is translated by attribute (
data-i18n, plus-html,-placeholder,-title,-aria-label,-alt) viaapplyStaticTranslations(root?); anything rendered from server data usest()directly (app.js'sonLanguageChange(),report-bug.html's own inline equivalent) and is re-rendered on a language switch. An element belongs to one camp or the other, never both, or the two mechanisms overwrite each other — that's why#bet-btnhas nodata-i18n: its label carries the configurable bet amount, sorenderBetButton()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. /report-bug'sbugReport.englishNoticestring is itself translated into all 7 languages — it just always says, in whichever language the visitor reads, to write the actual bug description in English (so the admin panel, which is Italian-operator-facing and untranslated, doesn't end up with reports in 7 different languages)./adminis 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 rarely special-case unexpected errors (the exceptions — require_admin's 403/404s, the SSE 503, FastAPI's own 401/404/405/422 — still carry a bare-string or list detail; see docs/api.md), 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.
Non-obvious domain decisions
Explicit design choices, not derivable from any single file — respect them:
- 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 transaction the platform builds (bets, withdrawals, payouts, and the outputs they create). Deposits (
deposit_confirmations, 6) and coinbase outputs (120) are the explicit exceptions of B-82 — don't add other thresholds 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}/privkeyexporting 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 writesadmin_privkey_accessed— don't remove that logging.- Usernames are case-insensitive (B-57): one namespace, enforced by a unique index on
lower(username)(app/db/models.py) and matched withfunc.lower(...)on both register and login. The name is still stored as typed — that's what/adminand the audit log show. The migration refuses to run if two existing accounts differ only by case, rather than guessing which one to rename: both may hold funds. - Argon2 hashing means no password recovery, only reset:
POST /admin/users/{id}/reset-passwordsets a new random password, returns it once for the operator to relay, and logsadmin_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 output the tx reserves for it — the user's change for bets/withdrawals, the bump reserve (paid to the reserve address) for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only that one output shrinks, or is dropped into the fee when it would fall under dust (
bump_fee, B-87).
Known gaps / TODO
Accepted by design — distinct from the audit findings above (all fixed), which are not duplicated here.
- The
drawingwait is unbounded by design (the draw's entropy genuinely depends on a future block). It resumes on its own after an exception or a restart (B-81), and is observable rather than silent — past_DRAW_STALL_THRESHOLD_SECONDSit logs progress and writes adraw_stalledaudit entry, andGET /rounds/current'sdraw_waiting_sincesurfaces it live (B-36). - RBF handles one shape only: a single reducible output — the sender's change for a bet/withdrawal, the payout's bump reserve (B-79). No extra-input fallback and no CPFP, and none would help the case that used to hurt (a transaction that already spends everything the sender has) — which is why
build_signed_transactionguarantees a change output of at leastDUST_LIMIT_SATS(B-62) and every payout carries the reserve. Since B-87 a bump that would leave that output under dust drops it and gives its whole value to the fee (the replacement is then the last possible one); a bump with no output, or one whose output cannot even pay BIP125's minimum increase, raisesRbfError. Every such failure — andmax_fee_rateand a replacement the network refuses — is written byRbfBumper._record_failureas onerbf_bump_failedaudit row per (current txid, reason). What stays accepted: a transaction that has exhausted its change or reserve and still does not confirm waits, visibly, and is never abandoned while a server knows it. - 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 of rounds or transactions.
GET /users/me/last-round-resultcovers exactly one case (the reveal backstop above) andGET /bug-reports/mineone more (the reporter's own reports). Admin has/admin/rounds,/admin/pending-transactions,/admin/audit-log; a user has no equivalent — a failed withdrawal leaves afailedrow they can never see, which argues for closing this. - Admin auth is one shared bearer token (
ADMIN_TOKEN) with no per-admin identity:audit_logrecords what changed (config edits asconfig_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 on bet, withdrawal, admin or SSE. Login has a per-username + per-IP failure throttle and registration a per-IP quota of 5 accounts/hour (
app/auth/rate_limit.py:RateLimiterfor failed guesses at a secret,RollingQuotafor "how many of these may one source create" — B-33, B-58); everything else is unlimited. /guidais a placeholder (app/static/guida.html) — the link works, the content is "coming soon"./report-bugis not: it is fully implemented and translated, with admin triage (see Frontends).- 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.