Files
davide 0d2fef6502 Delete BUGS.md now that both audits' findings are closed
B-01 … B-49 are all fixed, so the file held no open work — only a history that
git already keeps. CLAUDE.md now explains how to resolve the B-nn markers left
throughout the code against that history, and repeats the caveat the empty list
does not carry on its own: no open findings is not the same as no bugs.

The one remaining reference, in an already-applied migration's docstring, is
left as the historical record it is.
2026-07-27 23:37:02 +02:00

33 KiB
Raw Permalink Blame History

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 — 253 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 253 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. 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, embit for 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).
  • Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). validate_runtime_secrets() (app/config.py, called from the lifespan — deliberately not a Settings validator, so imports and tests need no real secrets) makes the server refuse to serve if JWT_SECRET < 32 chars or XPRV_ENCRYPTION_KEY is empty. An empty ADMIN_TOKEN is deliberately non-fatal: require_admin then denies everything, i.e. a locked panel, not an open one.
  • Operational config lives in the DB, not in env vars: every business/round parameter is one row of round_config (app/rounds/config.py), editable live from /admin — no redeploy, no restart. Defaults for a fresh instance are column defaults on RoundConfig (app/db/models.py), not app/config.py. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in .env, since those need a restart anyway.

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 746m/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
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 tx 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

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 pendingconfirmed 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. DepositReconciler takes the whole listener instead, reusing refresh_user so the periodic and notification-driven paths can't diverge.

Electrum connection

One connection serves everything — deposit credits, broadcasts, confirmations, the tip the draw waits on — so it's both the biggest single point of failure and, with a hostile server on the other end, the biggest integrity risk. Five defences:

  • Rotation. ELECTRUM_HOST/PORT is primary, ELECTRUM_FALLBACK_SERVERS a comma-separated host:port[:notls] list (client.py:parse_endpoints rejects malformed entries at startup, not during the outage when the fallback is needed). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
  • Bounded requests (_REQUEST_TIMEOUT_SECONDS = 15s); a timeout tears the connection down. Unbounded waits used to hang POST /bets while holding the per-user lock, and could stall the confirmation poller permanently.
  • The drop is observable: client.wait_closed() resolves when the read loop dies, and _run_once races it against the notification consumers and a 60s server.ping. Without it the listener sat on queues nobody would ever fill while listener.client still looked alive.
  • Headers are validated, not trusted (_apply_header): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises HeaderValidationError, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner.
  • A quorum corroborates the two money-moving decisions (_corroborate_majority, 10s per server, asking only the other endpoints — never the active one, which is what a MITM controls): corroborate_header before a block seeds the draw (B-28), corroborate_utxo_spent before a UTXO missing from one listunspent is written off as externally spent (B-29). No fallbacks configured → returns True (the accepted risk of an empty ELECTRUM_FALLBACK_SERVERS); nobody answers → returns False, since an unreachable network proves nothing.

On reconnect _subscribe_all_users runs as its own task with bounded concurrency (_RESUBSCRIBE_CONCURRENCY = 20) rather than inline and serially — otherwise a large user base froze tip_height, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. address_for_new_user (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or DepositReconciler sweep.

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, with the 1-conf reorg risk knowingly accepted and no rollback logic. deposits/service.py also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and reinstates ones that reappear.

PLAY — fixed cost, at most one active bet per user. PSBT user-address → pool-address, always with a change output back to the same user address (a user's balance must never exactly equal the bet). Fee ~1 sat/vB, deducted from the bet amount. No confirmation within the timeout → RBF bump and rebroadcast.

DRAW — configurable timer (default 600s):

  • Bet cutoff is the round's own deadline (opened_at + round_duration_seconds), not the DB status: place_bet calls rounds/service.round_accepts_bets, which rejects once the deadline passes even while status is still "open" (the 5s scheduler tick can lag behind it). Once a round leaves open, no new bets either, and no new round opens until this one is fully closed.
  • "Yellow light": closing waits for every already-broadcast bet to confirm before drawing, so a bet in flight at the boundary isn't lost (building counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
  • Algorithm (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a further block and writes a draw_header_corroboration_failed audit entry rather than stalling silently — hash as seed, index = seed mod participant_count over participants ordered by broadcast timestamp (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount.
  • Payout is signed with the pool key; its fee comes out of the winner's 70%, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
  • UI, two independent layers. A generic phase box ("Pagamento al vincitore in corso…") shows to every viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by status. Additively, a personalized "Hai vinto!/Non hai vinto" box appears only where user_played is true (computed via get_optional_user, since the endpoint is reachable logged-out) — nobody else has anything to reveal.
  • Reveal timing. Delayed by at least draw_animation_seconds, anchored to the server's closes_at so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for winner_user_id. Once revealed it's persisted in localStorage.plm_persisted_result, surviving the move to closed — at which point get_active_round stops returning the round and winner_user_id disappears from GET /rounds/current. GET /users/me/last-round-result is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: refreshRound/checkLastRoundResult in app/static/app.js.

WITHDRAW — the only way out to an external address: PSBT user-address → external + change back to the user, fee deducted from the withdrawn amount, same RBF pattern.

PLAY and WITHDRAW share a per-user lock (tx/locks.py): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set.

Three separate on-chain confirmations sit between the timer hitting zero and the payout landing — a common point of confusion:

  1. Last bet's confirmation — the round doesn't even flip to "closing" until every broadcast bet has 1 conf (_tick's pending_count check). May already have happened before the deadline.
  2. The draw block_wait_for_next_block waits for tip_height > tip_at_close, recorded only once step 1 is done, so this is necessarily a later block.
  3. Payout confirmation — built only after step 2's winner is known, so it needs yet another block; the generic ConfirmationPoller tracks it.

At 120s blocks that's ~46 min worst case (last bet confirms right at the deadline), ~24 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: buildingpendingconfirmed, or failed.

  • building is written first, UTXOs already marked spent_txid, and committed before the broadcast (bets/service.py, withdrawals/service.py, and scheduler.py:_trigger_payout — the same shape in four phases, so no DB session is ever held across a network call). A crash in that window leaves evidence, not coins spent on-chain with no record.
  • A refused broadcast releases the UTXOs, restores the balance, removes the participant (or marks the withdrawal failed), audit-logs, and raises broadcast_failed502, since the network refused it, not the caller.
  • tx/reconcile.py asks the chain about anything still building/pending: present → promote; positively unknown → failed with a failure_reason, inputs released, domain row rolled back, pending_tx_abandoned logged. Grace differs by state (120s building, 6h pending, so the bumper gets its attempts first). A transport failure never abandons anything — only a server that positively doesn't know the tx, currently inferred by substring-matching the error text (fragile — B-41).

UtxoEvent.spent_txid must always equal the tx's current txid, so bump_fee retargets it along with RoundParticipant.bet_txid, Withdrawal.txid and Round.payout_txid on every bump. broadcast_at is the first broadcast and is never rewritten (the reconciler's abandon clock measures from it); last_broadcast_at is what a bump updates and should_bump reads. Confirmation handlers key off immutable ids (round_id/user_id, withdrawal_id), never the txid, which changes under them.

Payouts retry, and are guarded against paying twice. Every tick re-examines a paying_out round: _retry_payout_if_due throttles to one attempt per 60s using the latest payout_failed audit entry as its clock (a build failure leaves no DB row to throttle on), and every early return in _trigger_payout writes one, so /admin shows why a round is stuck. Before building, _trigger_payout refuses if a building/pending payout already exists for the round, and _reserved_payout_outpoints excludes pool UTXOs claimed by any unresolved payout — without both, a retry would pay the winner twice.

"At most one active round" is a DB invariant, not a convention: ix_rounds_single_active (unique index over the constant (1), restricted to the active statuses) makes a concurrent second insert fail cleanly, and open_new_round_if_needed recovers by adopting the winner's round (max 3 attempts).

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 from GET /qr/{address}, Bet, Prelievo, Profilo — account info + self-service password change via POST /users/me/change-password), above a persistent round-status card and the chain-status bar with the language switcher.
  • /admin — gated by a token screen (not a login: just X-Admin-Token vs ADMIN_TOKEN), then five sections each backed by its own /admin/* endpoint: Parametri (RoundConfig + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log; plus a live Electrum/tip-height pill. Deliberately not linked from / in either direction.

Both talk to the same JSON API; there's no admin/user API split beyond require_admin.

Internationalization (/ only)

app/static/i18n.js holds every user-facing string of / in 7 languages (en, it, es, fr, de, ru, zh) as one flat TRANSLATIONS table — no build step, no fetch, loaded before app.js so t() is always available. Language: localStorage.plm_langnavigator.languageen. The switcher sits in the chain-bar, not the navbar, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it.

  • Static markup is translated by attribute (data-i18n, plus -html, -placeholder, -title, -aria-label, -alt) via applyStaticTranslations(root?); anything rendered from server data uses t() in app.js and is re-rendered by onLanguageChange(). An element belongs to one camp or the other, never both, or the two mechanisms overwrite each other — that's why #bet-btn has no data-i18n: its label carries the configurable bet amount, so renderBetButton() owns it.
  • Every language must have exactly the same key set. There is no fallback beyond en; a missing key renders as the raw key string.
  • /admin is intentionally untranslated (operator-facing, Italian), as is /guida.

API error contract (app/api/errors.py) — the API is single-language by design. Failures answer with a structured detail: {"code", "message", "params"}, where message is English for non-dashboard consumers and code is what the frontend maps to error.<code> in i18n.js (falling back to message for an unknown code). BetError/WithdrawalError subclass ApiError and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (internal_error), so clients never special-case unexpected errors, and the exception text stays in logs/app.log. Adding a user-facing error: give it a code, add error.<code> to all 7 languages, and pass values through params (amounts as *_sats — the frontend derives a *_plm sibling) instead of baking them into English text.

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}/privkey exporting a raw WIF is intentional, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes admin_privkey_accessed — don't remove that logging.
  • Argon2 hashing means no password recovery, only reset: POST /admin/users/{id}/reset-password sets a new random password, returns it once for the operator to relay, and logs admin_password_reset. No self-service reset exists (no email is ever collected); a logged-in user can only change their password by supplying the current one.
  • RBF bumps are paid by whoever's change the tx pays back to — the user for bets/withdrawals, the pool for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only the sender's own change shrinks (bump_fee).

Known gaps / TODO

Accepted by design — distinct from the audit findings above (all fixed), which are not duplicated here.

  • drawing doesn't resume after a restart. _tick() handles open, closing and paying_out (the last via _retry_payout_if_due); nothing re-enters _wait_for_next_block after 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_SECONDS it logs progress and writes a draw_stalled audit entry, and GET /rounds/current's draw_waiting_since surfaces 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 — an exact-amount tx or too-small change raises RbfError. Not permanent, though: an unbumpable tx that never confirms is eventually abandoned and its UTXOs released.
  • Withdrawal and RBF bump are unit-tested but never live-broadcast (failure and rollback branches included — still not a real network).
  • No user-facing history. GET /users/me/last-round-result covers exactly one case (the reveal backstop above). Admin has /admin/rounds, /admin/pending-transactions, /admin/audit-log; a user has no equivalent — a failed withdrawal leaves a failed row they can never see, which argues for closing this.
  • Admin auth is one shared bearer token (ADMIN_TOKEN) with no per-admin identity: audit_log records what changed (config edits as config_updated, with before/after) but never who did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
  • No rate limiting anywhere (register, bet, withdrawal, admin, SSE). For login this is a blocker, not a gap — tracked as B-33.
  • /guida and /report-bug are placeholders (app/static/guida.html, report-bug.html) — links work, content is "coming soon".
  • No integration tests against a live Electrum connection. tests/integration/ is empty; live verification has all been manual (scripts/electrum_smoke_test.py, ad hoc scripts, real mainnet txs).
  • Single-process assumptions: the SSE broadcaster and the per-user locks are in-process only. A multi-worker deployment needs a shared channel and a DB/Redis lock. The round-uniqueness invariant is not in this category — it's a DB index.