_apply_header's linkage check only fires on a single-block advance, so a header at the height we already held one for was applied on nothing but its own self-consistency — and that check, as header_meets_its_own_target's own docstring says, a server can satisfy with a self-declared easy target. So the one value the draw is seeded from could be replaced under us at the current height, by a reorg at the tip or by a single server disagreeing with the rest, with no check able to speak to it. Separately, a header carrying no hex set tip_header_hex back to None while advancing tip_height, leaving the two describing different blocks — the exact pairing that function exists to keep. Both are now refused without ending the session, unlike the fabrication cases above them: neither is evidence of a hostile server, and rotating away would cost us the one connection that also credits deposits and broadcasts transactions. - A same-height header is ignored (logged when it actually differs). The hash committed to for a height is not swapped under us; if ours turns out to be the orphan, corroborate_header already refuses to seed a draw from it and the draw waits for a further block. - A hex-less header is ignored outright: nothing to validate, nothing to draw from. A server that only ever pushed heights now freezes the draw — visibly, via B-36's draw_stalled — instead of costing us the connection. Because ignoring is not fatal, _run_once additionally refuses to publish the client when the initial header leaves the tip still unknown, so this cannot reopen B-63's window from the other side. The two _run_once tests are bounded with asyncio.wait_for: without their guard that call waits on session tasks nothing ends, and a regression must fail rather than hang the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
39 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 — 313 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.
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. They were tracked in a BUGS.md that was deleted once the list emptied, 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.
Before writing code, read the "Architecture" section below in full plus the diagrams in flowchart/: platform-overview.mmd (the 5-phase flow) and 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/, in Italian by explicit request (an exception to the English-only rule): 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).
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 XPRV_ENCRYPTION_KEY in .env)
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 313 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; .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. 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.
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 and paying_out resume on their own, drawing does not (see Known gaps).
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, no revocation — B-34). Argon2 costs tens of ms of CPU per call by design, so every async caller goes through
hash_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 an env var (never in the DB or git).
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 orXPRV_ENCRYPTION_KEYis 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 |
| Prize split | 70% winner / 30% fees, rounding remainder to fees | hardcoded in rounds/scheduler.py — 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 |
| 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, every tx kind | 1 | hardcoded in tx/confirmation.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).
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/ |
app/api/routes/ |
admin, bets, withdrawals, rounds (incl. SSE), users, qr; app/api/errors.py holds the error contract |
app/auth/ |
routes (register/login), Argon2 + JWT (security.py), get_current_user/get_optional_user |
app/db/ |
models.py (all tables + the active-round index), engine/session factories |
app/wallet/ |
HD derivation + 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 + winner pick), config.py, events.py (SSE pub/sub) |
app/tx/ |
broadcast.py (RBF bumper), 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]list (client.py:parse_endpointsrejects 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 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. Without it the listener sat on queues nobody would ever fill whilelistener.clientstill 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 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. Two more headers are refused without ending the session, since neither implies a hostile server (B-64): one at a height we already hold a header for (a reorg at the tip, or one server disagreeing — the hash committed to for a height is never swapped under us, andcorroborate_headeris what catches us holding an orphan), and 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. No fallbacks configured → returns True (the accepted risk of an emptyELECTRUM_FALLBACK_SERVERS); nobody answers → returns False, since an unreachable network proves nothing. 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: platform-overview.mmd, round-lifecycle.mmd.
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 and only once the other servers corroborate the outpoint and its amount (B-59), 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, 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): 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_failedaudit entry rather than stalling silently — hash as seed,index = seed mod participant_countover participants ordered by broadcast timestamp (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount. The baseline the draw compares against (tip_at_close) must be a height we actually knew at closing time: a0there means unknown, not "the chain is at zero", so_wait_for_next_blockadopts the first height it then learns as the baseline and waits for a block strictly after it (draw_baseline_tip_unknown, B-63) — seeding from a block that already existed while bets were open would make the winner predictable to whoever was watching the chain. - 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 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.
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 —
_wait_for_next_blockwaits fortip_height > tip_at_close, recorded only once step 1 is done, so this is necessarily a later block. - 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 ~4–6 min worst case (last bet confirms right at the deadline), ~2–4 min best case — 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.- A refused broadcast releases the UTXOs, restores the balance, removes the participant (or marks the withdrawal
failed), audit-logs, and raisesbroadcast_failed→ 502, since the network refused it, not the caller. tx/reconcile.pyasks the chain about anything stillbuilding/pending: present → promote; positively unknown →failedwith afailure_reason, inputs released, domain row rolled back,pending_tx_abandonedlogged. Grace differs by state (120sbuilding, 6hpending, 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).
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.
/— 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 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.
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 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.
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 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}/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 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).
Known gaps / TODO
Accepted by design — distinct from the audit findings above (all fixed), which are not duplicated here.
drawingdoesn't resume after a restart._tick()handlesopen,closingandpaying_out(the last via_retry_payout_if_due); nothing re-enters_wait_for_next_blockafter a crash. That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer silent — past_DRAW_STALL_THRESHOLD_SECONDSit logs progress and writes adraw_stalledaudit entry, andGET /rounds/current'sdraw_waiting_sincesurfaces it live (B-36). Restart-resumption itself remains 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, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why
build_signed_transactionnow guarantees a change output of at leastDUST_LIMIT_SATSinstead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused withbalance_leaves_no_change. What's left is a bump whose delta exceeds an otherwise-fine change output, which still raisesRbfError; that tx 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-resultcovers 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 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. /guidaand/report-bugare 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.