|
|
|
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
|
|
|
|
|
|
|
|
|
## Project status
|
|
|
|
|
|
|
|
|
|
All 10 stages of the original build order are code-complete and unit-tested — 313 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
|
|
|
|
All 10 stages of the original build order are code-complete and unit-tested — 345 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
|
|
|
|
|
|
|
|
|
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
|
|
|
|
|
|
|
|
|
@@ -33,7 +33,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
|
|
|
|
|
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
|
|
|
|
|
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
|
|
|
|
|
|
|
|
|
|
python -m pytest # all 313 tests
|
|
|
|
|
python -m pytest # all 345 tests
|
|
|
|
|
python -m pytest tests/unit/test_hd.py # one file
|
|
|
|
|
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
|
|
|
|
|
```
|
|
|
|
@@ -61,7 +61,7 @@ The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Option
|
|
|
|
|
|
|
|
|
|
- Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite, `embit` for keys/PSBT/tx parsing.
|
|
|
|
|
- **PLM access via the Electrum protocol only** (no full node/P2P). Dev bootstrap server: `santantonio.sytes.net:50002` (SSL).
|
|
|
|
|
- Auth: Argon2 hashing + JWT (HS256, 24h, **no revocation** — B-34). Argon2 costs tens of ms of CPU per call by design, so every async caller goes through `hash_password_async`/`verify_password_async` (`run_in_threadpool`, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts.
|
|
|
|
|
- Auth: Argon2 hashing + JWT (HS256, 24h). Tokens **are** revocable (B-34): the token carries a `tv` claim, `User.token_version` is bumped by a self-service password change and by an admin reset, and `get_current_user`/`get_optional_user` reject any token whose `tv` no longer matches — so changing the password invalidates every session issued before it, instead of leaving them valid for up to `jwt_expire_minutes`. A token predating the claim decodes as `tv = 0`, which is what a migrated user starts at, so the deploy didn't log everyone out. Argon2 costs tens of ms of CPU per call by design, so every async caller goes through `hash_password_async`/`verify_password_async` (`run_in_threadpool`, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts.
|
|
|
|
|
- Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or `XPRV_ENCRYPTION_KEY` is empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
|
|
|
|
|
- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway.
|
|
|
|
|
|
|
|
|
@@ -108,8 +108,8 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
|
|
|
|
|
| Package | Contents |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `app/main.py` | entry point: lifespan starts the six background tasks, mounts the routers and `app/static/` |
|
|
|
|
|
| `app/api/routes/` | `admin`, `bets`, `withdrawals`, `rounds` (incl. SSE), `users`, `qr`; `app/api/errors.py` holds the error contract |
|
|
|
|
|
| `app/auth/` | routes (register/login), Argon2 + JWT (`security.py`), `get_current_user`/`get_optional_user` |
|
|
|
|
|
| `app/api/routes/` | `admin`, `bets`, `withdrawals`, `rounds` (incl. SSE), `users`, `qr`, `bug_reports`; `app/api/errors.py` holds the error contract and `app/api/client_ip.py` the trusted-peer extraction every IP-keyed control uses |
|
|
|
|
|
| `app/auth/` | routes (register/login), Argon2 + JWT (`security.py`), `get_current_user`/`get_optional_user`, login/registration throttling (`rate_limit.py`) |
|
|
|
|
|
| `app/db/` | `models.py` (all tables + the active-round index), engine/session factories |
|
|
|
|
|
| `app/wallet/` | HD derivation + WIF export (`hd.py`), PLM network constants, address/scripthash, balance math, `psbt_builder.py` (build/sign bet, withdrawal, payout; `select_utxos`) |
|
|
|
|
|
| `app/electrum/` | `client.py` (JSON-RPC, endpoint parsing, timeouts), `listener.py` (the one connection: rotation, keepalive, header validation, corroboration, deposit crediting) |
|
|
|
|
@@ -210,7 +210,8 @@ Everything that spends money is written **before** it is broadcast and resolved
|
|
|
|
|
Two static SPAs served directly by FastAPI (`main.py` mounts `app/static/` and adds routes for `/admin`, `/guida`, `/report-bug`) — no build step, no framework, no bundler, `Cache-Control: no-store`.
|
|
|
|
|
|
|
|
|
|
- **`/`** — end-user test UI: register/login, then a navbar dashboard with four panels (Deposito with a QR from `GET /qr/{address}`, Bet, Prelievo, Profilo — account info + self-service password change via `POST /users/me/change-password`), above a persistent round-status card and the chain-status bar with the language switcher.
|
|
|
|
|
- **`/admin`** — gated by a token screen (not a login: just `X-Admin-Token` vs `ADMIN_TOKEN`), then five sections each backed by its own `/admin/*` endpoint: Parametri (`RoundConfig` + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log; plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction.
|
|
|
|
|
- **`/admin`** — gated by a token screen (not a login: just `X-Admin-Token` vs `ADMIN_TOKEN`), then six sections each backed by its own `/admin/*` endpoint: Parametri (`RoundConfig` + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log, Bug report (triage `open` → `read` → `resolved`, audit-logged `bug_report_status_changed`); plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction.
|
|
|
|
|
- **`/report-bug`** — standalone page (no navbar, own language switcher), reachable logged-in or logged-out: `POST /bug-reports` stores the report with the submitter attached when there is one, `GET /bug-reports/mine` is the reporter-side status view for the logged-in case, and `/admin`'s Bug report section is the triage end.
|
|
|
|
|
|
|
|
|
|
Both talk to the same JSON API; there's no admin/user API split beyond `require_admin`.
|
|
|
|
|
|
|
|
|
@@ -245,9 +246,9 @@ Accepted **by design** — distinct from the audit findings above (all fixed), w
|
|
|
|
|
- **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_wait_for_next_block` after a crash. That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36). Restart-resumption itself remains the last prerequisite for running unattended.
|
|
|
|
|
- **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why `build_signed_transaction` now guarantees a change output of at least `DUST_LIMIT_SATS` instead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused with `balance_leaves_no_change`. What's left is a bump whose *delta* exceeds an otherwise-fine change output, which still raises `RbfError`; that tx is eventually abandoned and its UTXOs released.
|
|
|
|
|
- **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network).
|
|
|
|
|
- **No user-facing history.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
|
|
|
|
|
- **No user-facing history of rounds or transactions.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above) and `GET /bug-reports/mine` one more (the reporter's own reports). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
|
|
|
|
|
- **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
|
|
|
|
|
- **No rate limiting on bet, withdrawal, admin or SSE.** Login has a per-username + per-IP failure throttle and registration a per-IP quota of 5 accounts/hour (`app/auth/rate_limit.py`: `RateLimiter` for failed guesses at a secret, `RollingQuota` for "how many of these may one source create" — B-33, B-58); everything else is unlimited.
|
|
|
|
|
- **`/guida` and `/report-bug` are placeholders** (`app/static/guida.html`, `report-bug.html`) — links work, content is "coming soon".
|
|
|
|
|
- **`/guida` is a placeholder** (`app/static/guida.html`) — the link works, the content is "coming soon". `/report-bug` is *not*: it is fully implemented and translated, with admin triage (see Frontends).
|
|
|
|
|
- **No integration tests against a live Electrum connection.** `tests/integration/` is empty; live verification has all been manual (`scripts/electrum_smoke_test.py`, ad hoc scripts, real mainnet txs).
|
|
|
|
|
- **Single-process assumptions**: the SSE broadcaster and the per-user locks are in-process only. A multi-worker deployment needs a shared channel and a DB/Redis lock. The round-uniqueness invariant is *not* in this category — it's a DB index.
|
|
|
|
|