Replace flowchart.mmd with per-topic diagrams and a print pipeline

Split the single flowchart.mmd into flowchart/platform-overview.mmd
(all 5 phases) and flowchart/round-lifecycle.mmd (round/draw detail),
plus render-pdf.sh to generate print-ready A4/A3 PDFs with a consistent
theme, header/footer, and legible contrast against the page background.

Also flip the operational policy in CLAUDE.md: the app now always runs
via Docker (dev and prod alike), with the venv reserved for tests,
migration authoring, and one-time secret/key-generation scripts.
This commit is contained in:
2026-07-23 16:24:28 +02:00
parent bae08f2759
commit 8a3dfd4592
6 changed files with 276 additions and 66 deletions
+1
View File
@@ -8,3 +8,4 @@ master.xprv.enc
*.egg-info/
logs/
data/
flowchart/*.pdf
+11 -11
View File
@@ -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 <file>.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.
-55
View File
@@ -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
+51
View File
@@ -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
+163
View File
@@ -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 <name>-A4.pdf and <name>-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 `<!doctype html>
<html><head><meta charset="utf-8">
<style>
html, body { margin:0; padding:0; height:100%; font-family: "Segoe UI", Helvetica, Arial, sans-serif; }
body { display:flex; flex-direction:column; height:100%; box-sizing:border-box; padding:4mm 6mm; }
.header {
flex:0 0 auto;
display:flex;
align-items:baseline;
gap:3mm;
border-bottom:1.5pt solid #3949ab;
padding-bottom:1.5mm;
margin-bottom:2mm;
}
.header .brand { font-size:12pt; font-weight:700; color:#3949ab; }
.header .title { font-size:10pt; font-weight:400; color:#1a1a2e; }
.diagram { flex:1 1 auto; min-height:0; display:flex; align-items:flex-start; justify-content:center; }
.diagram svg { width:100%; height:auto; max-width:100%; max-height:100%; }
.footer {
flex:0 0 auto;
display:flex;
justify-content:space-between;
align-items:center;
border-top:0.5pt solid #c9c9d6;
padding-top:2mm;
margin-top:2mm;
font-size:8pt;
color:#6b6b7a;
}
</style>
</head><body>
<div class="header">
<span class="brand">PLM Lottery</span>
<span class="title">${title}</span>
</div>
<div class="diagram">${svg}</div>
<div class="footer">
<span>Diagramma di flusso</span>
<span>Generato il ${generatedAt} &middot; formato ${size} orizzontale</span>
</div>
</body></html>`;
}
(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."
+50
View File
@@ -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