open_new_round_if_needed now withholds opening the next round until ROUND_COOLDOWN_SECONDS (default 30) have passed since the previous round's closed_at, returning None in that window instead of a Round. Without this, the next round opened within one scheduler tick (~5s) of the previous payout confirming — not enough time for a player to notice the round they were in actually resolved. Callers updated: the scheduler treats None as "nothing to do this tick", and place_bet raises a "try again shortly" BetError instead of crashing on a None round. Not in the original flowchart — a deliberate UX addition on top of it, documented as such in CLAUDE.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
12 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 (49 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.
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).
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 (fee/commission address, RBF fee-bump wallet, etc.): stored in a DB config table, not env vars — must be editable without a redeploy.
- Round duration: configurable via env var, default 10 minutes (not hardcoded).
- Round cooldown:
ROUND_COOLDOWN_SECONDS(default 30s) — gap after a round closes before the next one opens, so players have time to see the outcome. 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
- Fixed bet cost: 10 PLM per round.
- Prize split: 70% winner / 30% fees (fee address configurable in DB).
- Minimum deposit/withdrawal amount: 1 PLM (business-friendly floor, above the network's technical dust limit).
- Confirmations required for all tx types (deposit, bet, payout, withdrawal): 1.
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.
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.
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 bets / withdrawals / past rounds) — only
/users/me(balance) exists. - No deployment setup: no Dockerfile, process manager, or reconnect/supervision beyond the in-process asyncio tasks. Currently only run manually via
uvicornin a dev venv. - Admin auth is a single shared bearer token (
ADMIN_TOKEN,X-Admin-Tokenheader) — no per-admin identity or audit trail of who changed config. - No rate limiting / abuse protection on any endpoint (register, bet, withdrawal).
- 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.