Ran through the init skill's checklist against the current codebase: test count (49 -> 54), a new "Admin dashboard and test UI" architecture section (the two static SPAs, their endpoints, and the deliberate non-linking between them), two new non-obvious domain decisions (admin privkey export is intentional not a bug; who pays an RBF fee bump), a pointer to docs/ for the human-facing guides, and updated Known gaps (deployment gap resolved and removed; admin-token blast radius and the Docker auto-restart risk called out; user-facing history gap reworded now that the admin side has one). MVP business parameters reworded to distinguish what's admin-configurable (bet amount, min amount) from what's genuinely hardcoded (70/30 split, 1-conf threshold). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
15 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, 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.
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). Round closing waits for all already-broadcast bets to confirm before proceeding (avoids losing bets at the round boundary). 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. - 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.