Files
plm-lottery/CLAUDE.md
T

138 lines
17 KiB
Markdown
Raw Normal View History

2026-07-20 21:22:35 +02:00
# 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.
2026-07-20 21:22:35 +02:00
Before writing code, always read [flowchart.mmd](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/](docs/) (Italian, per explicit request — an exception to this file's English-only rule below): [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md).
## Commands
```bash
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).
```bash
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 -k` or 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.
2026-07-20 21:22:35 +02:00
## 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_config` DB 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 the `RoundConfig` model (`app/db/models.py`), not `app/config.py`. Secrets and infra wiring (master key, JWT secret, Electrum host, admin token, database URL) stay env-var-driven in `.env` since 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` (default `false`), toggled via `POST /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 as `lottery_paused`/`lottery_resumed`). When set, `rounds/service.py:open_new_round_if_needed` stops 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/current` exposes it as `lottery_paused` so the user-facing page (`/`) shows a maintenance banner.
2026-07-20 21:22:35 +02:00
## PLM network parameters
Source of truth: `PalladiumWallet` repo, [ChainProfiles.cs](../PalladiumWallet/src/Core/Chain/ChainProfiles.cs) and [PalladiumNetworks.cs](../PalladiumWallet/src/Core/Chain/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 path `m/84'/746'/0'/0/index`)
- Bech32 HRP: `plm`
- P2PKH address version byte: `55` (addresses start with `P`)
- P2SH address version byte: `5`
- WIF prefix: `0x80`
- Block time: 120s
- BIP32 extended key headers (Legacy/native-segwit `zprv`/`zpub` etc.): see `ExtKeyHeaders` in `ChainProfiles.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 in `RoundConfig` and 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.
2026-07-20 21:22:35 +02:00
## 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](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). Once a round leaves `open` (closing/drawing/paying_out), **no new bets are accepted** for it — `place_bet` checks `status == "open"` and a new round can't open until the current one is fully `closed` (see round cooldown below). 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_count` over 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 least `draw_animation_seconds` (admin-configurable, default 20s) once the round starts closing — purely cosmetic, decoupled from the real (and much longer, ~block-time) wait for `winner_user_id` to actually be set; see `GET /rounds/current`'s `winner_user_id`/`winner_amount_sats` and `app/static/index.html`'s reveal logic.
2026-07-20 21:22:35 +02:00
- **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 via `GET /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 checks `X-Admin-Token` against `ADMIN_TOKEN` from `.env`), then a navbar-driven dashboard with five sections, each backed by its own `/admin/*` endpoint (`app/api/routes/admin.py`): Parametri (`RoundConfig` CRUD), Utenti (list + per-user WIF privkey export, audit-logged), Round (history), Transazioni pendenti (in-flight RBF candidates), Audit log. **`/admin` is 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.
2026-07-20 21:22:35 +02:00
## 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 to `audit_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_fee` in `app/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 with `status == "open"`. If the process restarts while a round is `closing`/`drawing`/`paying_out`, it's permanently stuck — nothing re-enters `_wait_for_next_block` or 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 `RbfError` and needs manual operator intervention. Documented in `tx/broadcast.py`.
- **Payout retry**: if `_trigger_payout` fails (e.g. insufficient pool UTXOs, Electrum disconnected), it just logs and returns — the round stays stuck in `paying_out` with 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-Token` header) — no per-admin identity or audit trail of *who* changed config (the `audit_log` table 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 `pytest` suite.
- **`docker-compose.yml`'s `restart: unless-stopped`** on the app container means a crash mid-round auto-restarts straight into the scheduler-resume gap above — see the Deployment section.