The round timer relied on each client's own wall clock, so two browsers with skewed local clocks showed different countdowns for the same round; the server now also returns server_time so the frontend can correct for clock skew. Also drop out-of-order /rounds/current responses (multiple independent triggers could resolve late and revert the UI to a stale drawing/result state) and prune per-round bookkeeping maps on round transitions. Separately, place_bet only checked status == "open", leaving a window (up to the scheduler's 5s tick interval) after a round's timer hit zero where a new bet could still be accepted. place_bet now checks the round's own deadline directly (round_accepts_bets), acting as an immediate "yellow light" for new entries while still letting already-broadcast bets confirm before the round closes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
17 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 build-order stages from /home/davide/.claude/plans/scalable-mixing-sloth.md are code-complete and unit-tested (54 tests green): project skeleton, DB schema + Alembic migrations, auth, HD wallet derivation, Electrum client, deposit detection, bet flow, round/draw engine, payout, withdrawal, RBF fee-bump, admin config + audit log. Beyond the original 10 stages: a Docker + Caddy deployment (see below), a full admin dashboard (/admin), and a static test UI for the user-facing flow (/).
Real-money verification on mainnet, done so far: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast, confirmed, change credited back), and a full round cycle — close → draw (real block hash) → payout (70/30 split, exact sat math verified against the broadcast tx) → confirmation → round closed → next round auto-opened. Withdrawal and the RBF bump path are unit-tested but have never been exercised against a live broadcast. See "Known gaps" below before treating this as production-ready.
Before writing code, always read flowchart.mmd in full: every node in the diagram corresponds to a behavior that must be implemented exactly as described, including the labels on the edges (conditions, retries, loops).
Human-facing guides live in docs/ (Italian, per explicit request — an exception to this file's English-only rule below): setup.md, running-the-server.md, guida-utente.md, guida-admin.md.
Commands
source .venv/bin/activate # venv already created at .venv/
pip install -e ".[dev]" # install/update deps
alembic upgrade head # apply DB migrations
alembic revision --autogenerate -m "message" # generate a new migration after editing app/db/models.py
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the server's master xprv (requires XPRV_ENCRYPTION_KEY in .env)
uvicorn app.main:app --reload --port 8123 # run the dev server
python -m pytest # run all tests
python -m pytest tests/unit/test_hd.py # run one test file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # run a single test
.env (gitignored) holds real secrets for local dev; .env.example documents the required keys and how to generate them.
Deployment (Docker + Caddy)
docker-compose.yml runs two containers: app (this codebase, built by Dockerfile, runs alembic upgrade head then uvicorn) and caddy (reverse proxy + automatic TLS). .env still holds the app secrets; docker-compose.yml overrides DATABASE_URL/MASTER_KEY_PATH to point at the bind-mounted ./data/ (db, encrypted master key, logs — all gitignored, persist across container restarts).
mkdir -p data/db data/keys data/logs # one-time: host dirs bind-mounted into the app container
docker compose run --rm app python scripts/generate_master_key.py # one-time: create+encrypt the master xprv into ./data/keys/
docker compose up -d --build # build + start app and caddy
docker compose logs -f app # tail app logs (also written to ./data/logs/app.log)
docker compose down # stop
Caddy's site address comes from SITE_ADDRESS (env var on the host, read by docker-compose.yml):
- Dev, no domain: leave it unset (defaults to
localhost). Caddy detects it isn't a public hostname and issues a self-signed cert from its own internal CA — browsers will warn on first visit, expected for local testing (curl -kor click through). - Production, with a domain:
SITE_ADDRESS=lottery.example.com docker compose up -d(DNS must already point at the server, ports 80+443 reachable). Caddy automatically requests and renews a real Let's Encrypt certificate — no other config needed.
Known risk: docker-compose.yml sets restart: unless-stopped on app, so a crash mid-round auto-restarts the container — which hits the scheduler-resume gap below (a round stuck in closing/drawing/paying_out at restart stays stuck). Don't treat this as unattended-safe until that gap is closed.
Tech stack (MVP)
- Backend language: Python.
- PLM node access: Electrum protocol only (no full node/P2P). Bootstrap server for development:
santantonio.sytes.net:50002(SSL). - Auth: Argon2 password hashing + JWT sessions.
- Secrets: master xprv encrypted at rest with a symmetric scheme (AES-GCM/Fernet); the encryption key itself lives in an env var, never in the DB or in git.
- Operational config: every business/round parameter (fee address, bet amount, round duration, round cooldown, draw animation duration, minimum amount, network fee rate, RBF timeout) lives in the
round_configDB table (single row,app/rounds/config.py) and is only editable live via the admin dashboard (/admin) or its API — no env var involved at all, no redeploy or restart needed. Defaults for a brand-new instance are hardcoded column defaults on theRoundConfigmodel (app/db/models.py), notapp/config.py. Secrets and infra wiring (master key, JWT secret, Electrum host, admin token, database URL) stay env-var-driven in.envsince those genuinely need a restart. - Round cooldown:
round_cooldown_seconds— gap after a round closes before the next one opens, so players have time to see the outcome (default 30s). Not in the original flowchart; added afterwards as an explicit design decision. - Maintenance pause:
RoundConfig.paused(defaultfalse), toggled viaPOST /admin/pause/POST /admin/resume(a dedicated "Manutenzione" card in/admin's Parametri section, not a plain config field — it's a deliberate operator action, audit-logged aslottery_paused/lottery_resumed). When set,rounds/service.py:open_new_round_if_neededstops opening a next round once the current one closes — it never interrupts a round already in progress (that one still closes, draws, and pays out its winner normally).GET /rounds/currentexposes it aslottery_pausedso the user-facing page (/) shows a maintenance banner.
PLM network parameters
Source of truth: PalladiumWallet repo, ChainProfiles.cs and PalladiumNetworks.cs — always re-check that repo if a value is needed that isn't listed here, rather than guessing.
Mainnet:
- BIP44/84 coin type:
746(i.e. HD pathm/84'/746'/0'/0/index) - Bech32 HRP:
plm - P2PKH address version byte:
55(addresses start withP) - P2SH address version byte:
5 - WIF prefix:
0x80 - Block time: 120s
- BIP32 extended key headers (Legacy/native-segwit
zprv/zpubetc.): seeExtKeyHeadersinChainProfiles.cs
MVP business parameters
- Bet cost per round: 10 PLM by default, admin-configurable (
RoundConfig.bet_amount_sats) — not a fixed constant. - Prize split: 70% winner / 30% fees, hardcoded in
rounds/scheduler.py(winner_share = pool_amount_sats * 70 // 100) — unlike bet amount, this ratio is not inRoundConfigand would need a code change, not an admin-panel edit. - Minimum withdrawal amount: 1 PLM by default, admin-configurable (
RoundConfig.min_amount_sats) — a business-friendly floor, above the network's technical dust limit. Deposits have no server-side minimum check. - Confirmations required for all tx types (deposit, bet, payout, withdrawal): 1, hardcoded in
tx/confirmation.py— not configurable, per the design decision below.
What is PLM Lottery
A periodic-round lottery system built on a Bitcoin-like coin (PLM, mainnet). Each user gets a dedicated P2WPKH address (server-side HD wallet); they deposit PLM to that address, place a fixed-cost bet to enter the current round, and when the round closes a winner is drawn who receives 70% of the prize pool (the remaining 30% goes to fees).
Architecture (from the flowchart subgraphs)
The flow is organized into 5 phases, each a subgraph in flowchart.mmd:
- REG (Registration): on signup the server derives a new P2WPKH address via BIP84 (
m/84'/coin'/0'/0/index, one index per user) from a master xprv encrypted at rest. This address is permanent and serves as both the deposit address and the address that receives winnings and withdrawals. - DEP (Balance top-up): an ElectrumClient/SPV subscribes to the user's address scripthash. Internal balance (DB) is credited after 1 confirmation only — the reorg risk at 1-conf is knowingly accepted in v1, with no rollback logic.
- PLAY (Bet): fixed cost per round, at most one active bet per user at a time in v1. The server builds a PSBT user-address → pool-address for the fixed amount, with a change output back to the same user address (the user's balance must never exactly equal the bet amount). Fee minimized (~1 sat/vB), deducted from the bet amount. If the tx doesn't confirm within a timeout, fee-bump (RBF) and rebroadcast.
- DRAW (Periodic draw): configurable timer (default 10 minutes). The round's own deadline (
opened_at + round_duration_seconds) is the authoritative "yellow light" cutoff for new bets — not the DB status transition.place_bet(app/bets/service.py) callsrounds/service.round_accepts_bets(round_, round_duration_seconds), which rejects the bet once the deadline has passed even ifstatusis still"open"in the DB (theRoundSchedulertick that flips it to"closing"runs every_TICK_INTERVAL_SECONDS= 5s and can lag a few seconds behind the deadline). This closes the race where a bet placed in that lag window would otherwise still be accepted. Once a round leavesopen(closing/drawing/paying_out), no new bets are accepted for it either, and a new round can't open until the current one is fullyclosed(see round cooldown below). Round closing waits for all already-broadcast bets to confirm before proceeding (avoids losing bets at the round boundary) — this is the "yellow light" behavior: no new entries once the timer hits zero, but bets already in flight are still given time to confirm before the round actually closes and draws. The next round only opens once the previous round's payout tx is confirmed — rounds never overlap in v1. v1 draw algorithm (deliberately simple, meant to be replaced later): wait for the first block confirmed after round closing, use its hash as seed,index = seed mod participant_countover the participant list ordered by broadcast timestamp (this is also the tie-break when two bets confirm in the same block). Every participant has equal probability regardless of bet amount (consistent with the fixed bet amount). The payout (70% winner / 30% fees) is signed with the pool address key; the payout fee is deducted from the winner's 70%, the 30% fee share stays intact. Same timeout → RBF → rebroadcast pattern here too. The frontend shows a "drawing" animation on every user's dashboard for at leastdraw_animation_seconds(admin-configurable, default 20s) once the round starts closing — purely cosmetic, decoupled from the real (and much longer, ~block-time) wait forwinner_user_idto actually be set; seeGET /rounds/current'swinner_user_id/winner_amount_satsandapp/static/index.html's reveal logic. - WITHDRAW (Withdrawal): the only way to move funds out of the platform to an external address. PSBT user-address → external-address + change back to the user address, fee deducted from the withdrawn amount, same RBF retry pattern.
PLAY and WITHDRAW share a per-user DB lock: a user can never have a bet-build and a withdrawal-build in flight at the same time, since both would otherwise spend from the same UTXO set on the user's dedicated address.
Admin dashboard and test UI
Two static single-page apps, served directly by FastAPI (app/main.py mounts app/static/ and adds a dedicated GET /admin route) — no build step, no framework:
/(app/static/index.html): the end-user test UI. Register/login, then a menu-driven dashboard (Deposito with a QR code of the address viaGET /qr/{address}, Bet, Prelievo) with a persistent round-status card (GET /rounds/current: id/status/timer/participant count/jackpot) above the menu./admin(app/static/admin.html): gated by a token screen (not a real login — just checksX-Admin-TokenagainstADMIN_TOKENfrom.env), then a navbar-driven dashboard with five sections, each backed by its own/admin/*endpoint (app/api/routes/admin.py): Parametri (RoundConfigCRUD), Utenti (list + per-user WIF privkey export, audit-logged), Round (history), Transazioni pendenti (in-flight RBF candidates), Audit log./adminis deliberately not linked from/in either direction — reachable only by knowing the URL.
Both pages talk to the same JSON API everything else uses; there's no separate "admin API" vs "user API" boundary beyond the require_admin dependency.
Non-obvious domain decisions
These choices were made explicitly during design (not derivable from reading a single file) and must be respected in any implementation:
- Private keys (xprv) are generated and held server-side — this is not a non-custodial system: the user never controls their own keys until they make an explicit withdrawal.
- The user's personal deposit address always doubles as the winnings-receiving address: there is no separate "winner address".
- 1 confirmation is the chosen threshold for all tx types (deposits, bets, payouts, withdrawals): don't introduce different thresholds (e.g. 3 or 6 confirmations) without an explicit decision.
- The draw algorithm (node R) is deliberately simple and should be treated as a replaceable/pluggable component, not the final design — don't architect around its current implementation.
- The admin panel can export any user's raw WIF private key (
GET /admin/users/{id}/privkey,app/wallet/hd.py:derive_user_wif). This is intentional, not a vulnerability to fix: the server already holds the master key everything derives from (custodial by design, see above), so this only exposes through the API something an operator could already do via a script. Every access is written toaudit_log(admin_privkey_accessed) — don't remove that logging when touching this endpoint. - RBF fee bumps are paid by whoever's change output the tx pays back to — the user for bets/withdrawals, the pool for payouts — never by the fixed counterparty amount (recipient/winner/fee-address outputs are untouched; only the sender's own change shrinks). See
bump_feeinapp/tx/broadcast.py.
Known gaps / TODO
Not blockers for reading the code, but must be addressed before this is production-ready:
- Scheduler doesn't resume mid-flight rounds after a restart.
rounds/scheduler.py's_tick()only acts on rounds withstatus == "open". If the process restarts while a round isclosing/drawing/paying_out, it's permanently stuck — nothing re-enters_wait_for_next_blockor retries_trigger_payout. Needs a startup routine that inspects in-progress rounds and resumes (or a periodic "unstick" check) before this can run unattended. - RBF bump only handles one case: a single change output, paying back to the tx's own sender address, large enough to absorb the fee increase. No additional-input selection fallback — an exact-amount tx (no change) or a change output too small to absorb the bump raises
RbfErrorand needs manual operator intervention. Documented intx/broadcast.py. - Payout retry: if
_trigger_payoutfails (e.g. insufficient pool UTXOs, Electrum disconnected), it just logs and returns — the round stays stuck inpaying_outwith no automatic retry. - Withdrawal and RBF bump have never been exercised against a live broadcast — only deposit and bet flow are verified end-to-end with real PLM as of this commit.
- No user-facing history endpoints (list my own bets / withdrawals / past rounds) — a user still only has
/users/me(balance). The admin side now has this (/admin/rounds,/admin/pending-transactions,/admin/audit-log), but there's no equivalent scoped to "my own history" for a logged-in user. - Admin auth is a single shared bearer token (
ADMIN_TOKEN,X-Admin-Tokenheader) — no per-admin identity or audit trail of who changed config (theaudit_logtable records what changed, not which operator did it). This token now gates a lot more than config (user list, private key export, round/audit history), so its blast radius if leaked is correspondingly larger. - No rate limiting / abuse protection on any endpoint (register, bet, withdrawal, admin).
- No automated integration tests against a live Electrum connection — all live-network verification so far has been manual (ad hoc scripts + real mainnet transactions), not part of the
pytestsuite. docker-compose.yml'srestart: unless-stoppedon the app container means a crash mid-round auto-restarts straight into the scheduler-resume gap above — see the Deployment section.