diff --git a/.gitignore b/.gitignore index ea894d4..e08c19e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ master.xprv.enc *.egg-info/ logs/ data/ +flowchart/*.pdf diff --git a/CLAUDE.md b/CLAUDE.md index 7df335d..45960cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,25 +12,24 @@ All 10 build-order stages from `/home/davide/.claude/plans/scalable-mixing-sloth 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](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). +Before writing code, always read the "Architecture" section below in full, plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) for the whole 5-phase flow, and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) for the round/draw lifecycle in detail. Every node in these diagrams corresponds to a behavior that must be implemented exactly as described, including the labels on the edges (conditions, retries, loops). Regenerate their companion PDFs with `flowchart/render-pdf.sh .mmd` after editing either one. 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 +The server itself — in development and in production alike — always runs via Docker (see "Deployment" below); there is no supported way to run `uvicorn` directly against this codebase. The venv (`.venv/`) is only for local tooling: running tests, authoring Alembic migrations, and running the one-time scripts that generate the secrets/key material that end up referenced from `.env`. + ```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 +alembic revision --autogenerate -m "message" # generate a new migration after editing app/db/models.py (applied automatically by the container's startup command — see Deployment — never run `alembic upgrade head` manually) -PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the server's master xprv (requires XPRV_ENCRYPTION_KEY in .env) +PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the server's master xprv (requires XPRV_ENCRYPTION_KEY in .env; see Deployment for where MASTER_KEY_PATH should point) PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print the existing master xprv (asks for confirmation first) PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: bring your own externally-generated xprv instead of generating one (getpass prompt, --overwrite to replace) -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 @@ -40,14 +39,15 @@ python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # run ## 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). +The app is always run via Docker — dev and prod alike use the same `docker-compose.yml`, just with a different `SITE_ADDRESS` (see below); there's no separate dev-mode compose file or bare-`uvicorn` workflow. `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` holds the app secrets; `docker-compose.yml` overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted master key, logs — all gitignored, persist across container restarts). Set `MASTER_KEY_PATH` in `.env` itself to the host-side equivalent, `./data/keys/master.xprv.enc`, so the venv-run key-generation scripts above (see "Commands") write to the exact same file the container reads — one source of truth for the key, whichever way it was generated. ```bash -mkdir -p data/db data/keys data/logs # one-time: host dirs bind-mounted into the app container +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/ +# one-time: generate the master key via the venv script above (scripts/generate_master_key.py), +# not via `docker compose run` — MASTER_KEY_PATH in .env already points at ./data/keys/ -docker compose up -d --build # build + start app and caddy +docker compose up -d --build # build + start app and caddy — same command for dev and prod docker compose logs -f app # tail app logs (also written to ./data/logs/app.log) docker compose down # stop ``` @@ -113,7 +113,7 @@ A periodic-round lottery system built on a Bitcoin-like coin (PLM, mainnet). Eac ## Architecture (from the flowchart subgraphs) -The flow is organized into 5 phases, each a subgraph in [flowchart.mmd](flowchart.mmd): +The flow is organized into 5 phases (see [flowchart/platform-overview.mmd](flowchart/platform-overview.mmd) for the full-platform diagram, and [flowchart/round-lifecycle.mmd](flowchart/round-lifecycle.mmd) for the round/draw phase in detail): - **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. diff --git a/flowchart.mmd b/flowchart.mmd deleted file mode 100644 index 7a48ff0..0000000 --- a/flowchart.mmd +++ /dev/null @@ -1,55 +0,0 @@ -flowchart TD - - subgraph REG["Registration"] - A["User registers: username + password"] --> B["Server derives a new P2WPKH address\n(BIP84, path m/84'/746'/0'/0/index)\nmaster xprv encrypted at rest"] - B --> C["Address linked to the user profile in the DB"] - end - - subgraph DEP["Balance top-up"] - C --> D["User sends PLM to their dedicated address"] - D --> E["ElectrumClient/SPV monitors the address\n(subscribe scripthash)"] - E --> F{"Tx confirmed\n(1 confirmation)?"} - F -- No --> E - F -- Yes --> G["User balance credited in the DB\n(balance = confirmed UTXOs on the address)"] - end - - subgraph PLAY["Bet"] - G --> H{"User confirms bet purchase?\n(fixed cost: 10 PLM per round,\nmax 1 active bet at a time,\nacquires per-user DB lock shared with WITHDRAW)"} - H -- No --> G - H -- "Yes (balance >= bet cost)" --> I["Server builds PSBT:\nuser address -> pool address\n(bet cost) + change -> user address\nfee ~1 sat/vB deducted from the bet amount"] - I --> J["Server signs with the user's derived key"] - J --> K["Broadcast tx to the network"] - K --> L{"Tx confirmed\n(1 confirmation)?"} - L -- "No (timeout)" --> K2["Fee bump (RBF) and rebroadcast"] - K2 --> K - L -- Yes --> M["User registered as a participant\nin the current round (with bet amount)"] - end - - subgraph DRAW["Periodic draw"] - N["Round timer: every X minutes (configurable, default 10)"] --> O{"Are there bets\nalready broadcast but not yet confirmed?"} - O -- Yes --> O - O -- No --> O2["Close current round"] - O2 --> P["List of round participants\n(user address + bet amount),\nordered by broadcast timestamp\n(tie-break for same-block confirmations)"] - P --> Q{"Are there participants?"} - Q -- No --> N - Q -- Yes --> R["Draw winner (simple v1 algorithm):\n1. wait for the first block confirmed after round closing\n2. seed = block hash (hex -> integer)\n3. index = seed mod participant_count\n4. winner = participants[index]\n(anyone can recompute and verify it;\nalgorithm replaceable in the future)"] - R --> S["Compute total round prize pool\n(sum of confirmed deposits to the pool address)"] - S --> T["70% of the prize pool - payout tx fee\n-> winner's deposit address"] - S --> U["30% of the prize pool (unchanged)\n-> fee address (configurable)"] - T --> V["Payout tx signed with\nthe pool address key"] - U --> V - V --> V2{"Tx confirmed\n(1 confirmation)?"} - V2 -- "No (timeout)" --> V3["Fee bump (RBF) and rebroadcast"] - V3 --> V2 - V2 -- Yes --> W["Log round\n(winner, amount, txid) for audit"] - W --> N - end - - subgraph WITHDRAW["Withdrawal (simple v1)"] - G --> X["User requests withdrawal:\nexternal address + amount <= balance\n(min 1 PLM, acquires per-user DB lock\nshared with PLAY)"] - X --> Y["Server builds and signs PSBT:\nuser address -> external address\n+ optional change -> user address\nfee deducted from the withdrawn amount"] - Y --> Z["Broadcast + wait for 1 confirmation\n(same RBF-on-timeout pattern)"] - Z --> G - end - - M --> N diff --git a/flowchart/platform-overview.mmd b/flowchart/platform-overview.mmd new file mode 100644 index 0000000..51d5452 --- /dev/null +++ b/flowchart/platform-overview.mmd @@ -0,0 +1,51 @@ +flowchart LR + + subgraph REG["FASE 1 - Registrazione"] + direction TB + A1["L'utente si registra\n(username + password)"] --> A2["Il server genera un indirizzo\ndedicato e permanente per l'utente\n(chiave segreta cifrata,\ncustodita dal server)"] + A2 --> A3["L'indirizzo viene collegato\nal profilo utente\n(sara' sia l'indirizzo di deposito\nche quello che ricevera' vincite\ne prelievi)"] + end + + subgraph DEP["FASE 2 - Deposito"] + direction TB + B1["L'utente invia PLM\nal proprio indirizzo dedicato"] --> B2["Il sistema monitora\nl'indirizzo sulla blockchain"] + B2 --> B3{"Transazione\nconfermata?"} + B3 -- "No" --> B2 + B3 -- "Si'" --> B4["Saldo dell'utente\naccreditato nel sistema"] + end + + subgraph PLAY["FASE 3 - Scommessa"] + direction TB + C1{"L'utente vuole\nscommettere?\n(costo fisso, es. 10 PLM;\nal massimo una scommessa\nattiva alla volta)"} + C1 -- "Si', saldo sufficiente" --> C2["Si prepara la transazione:\ndal suo indirizzo verso\nil conto comune del montepremi\n(con resto che torna a lui)"] + C2 --> C3["Transazione firmata\ne inviata alla rete"] + C3 --> C4{"Confermata?"} + C4 -- "No, troppo tempo" --> C5["Si aumenta la commissione\ne si reinvia"] + C5 --> C3 + C4 -- "Si'" --> C6["L'utente e' ufficialmente\npartecipante al round in corso"] + end + + subgraph DRAW["FASE 4 - Round ed estrazione"] + direction TB + D0["(dettaglio completo in\nround-lifecycle.mmd)"] -.-> D1["Il round ha un tempo limite\nper accettare scommesse"] + D1 --> D2["Allo scadere, si aspettano\nle scommesse gia' in corso\ne poi il round si chiude"] + D2 --> D3["Si estrae un vincitore\nin modo casuale e verificabile\n(hash del primo blocco\ndopo la chiusura)"] + D3 --> D4["Il montepremi viene diviso:\n70% al vincitore\n30% alla piattaforma"] + D4 --> D5["Pagamento inviato e confermato\nsulla rete\n(stesso schema di riprova\ncon commissione aumentata\nin caso di ritardo)"] + end + + subgraph WITHDRAW["FASE 5 - Prelievo"] + direction TB + E1["L'utente richiede un prelievo:\nindirizzo esterno + importo\n(non puo' avvenire insieme\na una scommessa in corso)"] --> E2["Si prepara e firma la transazione:\ndal suo indirizzo verso\nl'indirizzo esterno indicato\n(con resto che torna a lui)"] + E2 --> E3["Transazione inviata\nalla rete"] + E3 --> E4{"Confermata?"} + E4 -- "No, troppo tempo" --> E5["Si aumenta la commissione\ne si reinvia"] + E5 --> E3 + E4 -- "Si'" --> E6["Saldo dell'utente aggiornato"] + end + + A3 --> B1 + B4 --> C1 + B4 --> E1 + C6 --> D1 + D5 -.->|"round successivo"| C1 diff --git a/flowchart/render-pdf.sh b/flowchart/render-pdf.sh new file mode 100755 index 0000000..d1a04b7 --- /dev/null +++ b/flowchart/render-pdf.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Regenerates professional-looking A4 and A3 landscape PDFs from a Mermaid +# .mmd flowchart: consistent color theme, legible fonts, a title (read from +# the .mmd's own YAML frontmatter) and a footer with the generation date. +# +# Usage: +# ./render-pdf.sh [path/to/file.mmd] +# +# Defaults to round-lifecycle.mmd in this same directory. +# Produces -A4.pdf and -A3.pdf next to the .mmd file. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MMD_FILE="${1:-$SCRIPT_DIR/round-lifecycle.mmd}" + +if [[ ! -f "$MMD_FILE" ]]; then + echo "Errore: file non trovato: $MMD_FILE" >&2 + exit 1 +fi + +OUT_DIR="$(cd "$(dirname "$MMD_FILE")" && pwd)" +BASE="$(basename "$MMD_FILE" .mmd)" +SVG_TMP="$OUT_DIR/.${BASE}.tmp.svg" +CONFIG_TMP="$OUT_DIR/.${BASE}.tmp-config.json" + +cleanup() { + rm -f "$SVG_TMP" "$CONFIG_TMP" "$OUT_DIR/.${BASE}.tmp-A4.html" "$OUT_DIR/.${BASE}.tmp-A3.html" +} +trap cleanup EXIT + +# Consistent, print-friendly color theme (indigo nodes/edges, warm amber phase +# clusters, generous font size) instead of mermaid's flat default palette. +cat > "$CONFIG_TMP" <<'EOF' +{ + "theme": "base", + "themeVariables": { + "fontFamily": "\"Segoe UI\", Helvetica, Arial, sans-serif", + "fontSize": "17px", + "primaryColor": "#c9d6f7", + "primaryBorderColor": "#3949ab", + "primaryTextColor": "#1a1a2e", + "lineColor": "#3949ab", + "secondaryColor": "#fff8e1", + "tertiaryColor": "#ffffff", + "clusterBkg": "#fff8e1", + "clusterBorder": "#c9a227", + "edgeLabelBackground": "#c9d6f7", + "titleColor": "#1a1a2e" + }, + "flowchart": { + "curve": "basis", + "padding": 16, + "htmlLabels": true, + "nodeSpacing": 100, + "rankSpacing": 25 + } +} +EOF + +echo "-> Rendering diagram to SVG..." +npx -y @mermaid-js/mermaid-cli -i "$MMD_FILE" -o "$SVG_TMP" -b white -c "$CONFIG_TMP" + +echo "-> Building print-ready A4/A3 PDFs..." + +# mermaid-cli pulls in puppeteer as a transitive dependency; reuse that install +# instead of adding a separate one just for this script. +PUPPETEER_DIR="$(dirname "$(find "$HOME/.npm/_npx" -maxdepth 3 -type d -name puppeteer 2>/dev/null | head -n1)")" +if [[ -z "$PUPPETEER_DIR" || ! -d "$PUPPETEER_DIR" ]]; then + echo "Errore: modulo puppeteer non trovato (serve mermaid-cli gia' eseguito almeno una volta)." >&2 + exit 1 +fi +export NODE_PATH="$PUPPETEER_DIR" + +GENERATED_AT="$(date '+%d/%m/%Y %H:%M')" + +# Human title for the header banner; falls back to a prettified filename for +# any .mmd this script hasn't been told about explicitly. +case "$BASE" in + round-lifecycle) TITLE="Ciclo di vita di un round" ;; + platform-overview) TITLE="Flusso completo della piattaforma" ;; + *) TITLE="$(echo "$BASE" | tr '-' ' ' | sed 's/\b\(.\)/\u\1/g')" ;; +esac + +node -e ' +const fs = require("fs"); +const path = require("path"); +const puppeteer = require("puppeteer"); + +const outDir = process.argv[1]; +const base = process.argv[2]; +const svgPath = process.argv[3]; +const generatedAt = process.argv[4]; +const title = process.argv[5]; + +const svg = fs.readFileSync(svgPath, "utf-8"); + +function htmlFor(size) { + return ` + + + +
+ PLM Lottery + ${title} +
+
${svg}
+ +`; +} + +(async () => { + const browser = await puppeteer.launch({ args: ["--no-sandbox"] }); + const page = await browser.newPage(); + for (const fmt of ["A4", "A3"]) { + const htmlPath = path.join(outDir, `.${base}.tmp-${fmt}.html`); + fs.writeFileSync(htmlPath, htmlFor(fmt)); + await page.goto("file://" + htmlPath, { waitUntil: "networkidle0" }); + const pdfPath = path.join(outDir, `${base}-${fmt}.pdf`); + await page.pdf({ + path: pdfPath, + format: fmt, + landscape: true, + printBackground: true, + margin: { top: "6mm", bottom: "6mm", left: "6mm", right: "6mm" }, + }); + console.log(" " + pdfPath); + } + await browser.close(); +})(); +' "$OUT_DIR" "$BASE" "$SVG_TMP" "$GENERATED_AT" "$TITLE" + +echo "Fatto." diff --git a/flowchart/round-lifecycle.mmd b/flowchart/round-lifecycle.mmd new file mode 100644 index 0000000..ebb06c1 --- /dev/null +++ b/flowchart/round-lifecycle.mmd @@ -0,0 +1,50 @@ +flowchart LR + + A["Il round precedente\nsi e' chiuso\n(pagamento vincitore confermato)"] --> B{"Lotteria in pausa\nmanutenzione?"} + B -- "Si'" --> B_WAIT["Si attende"] + B_WAIT --> B + B -- "No" --> C["Breve attesa\n'di raffreddamento'\n(cosi' i giocatori vedono\nil risultato precedente)"] + C --> D["Si apre un nuovo round\n(stato: APERTO)\ncon un limite di tempo\nper scommettere"] + + subgraph OPEN["FASE 1 - Round aperto (accetta scommesse)"] + direction TB + D --> F["Un giocatore\npiazza una scommessa"] + F --> G{"E' arrivata prima\ndella scadenza\ndel round?"} + G -- "Si'" --> H["Accettata:\ngiocatore aggiunto\nai partecipanti"] + G -- "No, troppo tardi" --> F2["Rifiutata:\nil round non e' piu'\nin tempo per accettarla"] + H --> F + F2 --> F + end + + D -.->|"scade il tempo"| I + + subgraph CLOSING["FASE 2 - Chiusura"] + direction TB + I["Il round raggiunge la sua scadenza\n(indipendentemente dalle scommesse\ngia' in corso, che restano valide):\nda questo momento nessuna\nnuova scommessa e' accettata"] --> J{"Ci sono scommesse\ngia' inviate ma non\nancora confermate?"} + J -- "Si'" --> J_WAIT["Si attende qualche secondo\ne si ricontrolla"] + J_WAIT --> J + J -- "No, tutte confermate" --> K["Il round si chiude:\nsi fotografa lo stato\nattuale della blockchain"] + end + + subgraph DRAWING["FASE 3 - Estrazione vincitore"] + direction TB + K --> L{"C'e' almeno\nun partecipante?"} + L -- "No" --> M["Round concluso\nsenza vincitore"] + L -- "Si'" --> N["Si attende il primo\nnuovo blocco dopo\nla chiusura del round"] + N --> O["L'hash del blocco\nsceglie il vincitore\nin modo casuale e verificabile\n(stessa probabilita' per tutti)"] + end + + subgraph PAYING["FASE 4 - Pagamento"] + direction TB + O --> P["Si calcola\nil montepremi totale"] + P --> Q["Si divide:\n70% al vincitore\n30% alla piattaforma"] + Q --> R["Si prepara e firma\nla transazione di pagamento"] + R --> S["La transazione\nviene inviata"] + S --> T{"Confermata\nsulla rete?"} + T -- "No, troppo tempo" --> T2["Si aumenta la\ncommissione e si reinvia"] + T2 --> S + T -- "Si'" --> U["Vincitore registrato\nnel registro di controllo"] + end + + U --> V["Round CHIUSO\n(il ciclo ricomincia\ndall'inizio per\nil round successivo)"] + M --> V