Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5af15087c | ||
|
|
a384b08044 | ||
|
|
ee4e845c89 | ||
|
|
977bb762c7 | ||
|
|
fe909bedcf | ||
|
|
9207bbcb8f | ||
|
|
5cfe2d6f95 | ||
|
|
0d2fef6502 | ||
|
|
e7f844b11f | ||
|
|
4c80c1c5bf | ||
|
|
6a90136b50 | ||
|
|
31bc9a327f | ||
|
|
6045c89ed0 | ||
|
|
a574db0d93 | ||
|
|
d60da11603 | ||
|
|
22e3cfb2be | ||
|
|
4124dc08e6 | ||
|
|
08c566d547 | ||
|
|
97545ad91f | ||
|
|
702b37b319 | ||
|
|
0b44fe632e | ||
|
|
7fa26df104 | ||
|
|
bb8b71278a | ||
|
|
739fc9fed2 | ||
|
|
16802cafb6 | ||
|
|
17c557b8a3 | ||
|
|
fe5639a037 | ||
|
|
12df04178e | ||
|
|
63df38d30b | ||
|
|
e8fdea0389 | ||
|
|
0ce0562fd7 | ||
|
|
7224ca0e66 | ||
|
|
933760e948 | ||
|
|
50a43ae3ca | ||
|
|
f13f6850b7 | ||
|
|
43d2321e0f | ||
|
|
f1a1145cda | ||
|
|
447bbba83e | ||
|
|
f9f822f437 | ||
|
|
845ba98409 | ||
|
|
dc4d5761df | ||
|
|
7c4e9983ea | ||
|
|
25f4a1c6b6 | ||
|
|
85dce221c5 | ||
|
|
daf66fd6bc | ||
|
|
b4d70385a6 | ||
|
|
d528c5b475 | ||
|
|
cc88763a9d | ||
|
|
9b6a4a240c | ||
|
|
fb734bb818 | ||
|
|
d4e0974881 | ||
|
|
28c1179e9b | ||
|
|
5c9ccc0344 | ||
|
|
0cf35147ad | ||
|
|
7048fe7ea6 | ||
|
|
8a3dfd4592 | ||
|
|
bae08f2759 | ||
|
|
aae0961c94 | ||
|
|
dda5bd14e1 | ||
|
|
f229f91632 | ||
|
|
6a857f0e07 | ||
|
|
f822911128 | ||
|
|
ad71000777 | ||
|
|
877a219aa4 | ||
|
|
3d6f4a98c4 | ||
|
|
7b3555f8eb | ||
|
|
6c51a81f0e | ||
|
|
d92ef9ed9f | ||
|
|
1941f30b10 | ||
|
|
9e7b726df7 | ||
|
|
5f62a8315e | ||
|
|
80c413ad3d | ||
|
|
1acb6c3b16 | ||
|
|
2b645f5e37 | ||
|
|
c4f6f2eed7 | ||
|
|
67f70465e9 | ||
|
|
9fa7eec378 | ||
|
|
7dcf6d2756 | ||
|
|
f27fe6243c | ||
|
|
162a63d04a | ||
|
|
78109aa4c4 | ||
|
|
8627f3fa0c | ||
|
|
4b510f312f | ||
|
|
9e1d7da22d | ||
|
|
7e4b603ae3 | ||
|
|
669c3fb714 | ||
|
|
b52a8023de | ||
|
|
f23640b6b3 | ||
|
|
03cdffb34d | ||
|
|
0e56f63e63 | ||
|
|
d4aadf6b40 | ||
|
|
ed16e4d50b | ||
|
|
30bde96b6e | ||
|
|
48a9eeb839 | ||
|
|
f6035a888b | ||
|
|
39c6ea1950 | ||
|
|
f21ecbd4ee | ||
|
|
abb3418669 | ||
|
|
784f30ddd7 | ||
|
|
6683f197eb | ||
|
|
d35784d574 | ||
|
|
2dedc283c6 | ||
|
|
63bc1d911b | ||
|
|
7f2d0bcec6 | ||
|
|
7e3eec3e4e | ||
|
|
dd45ec75c6 | ||
|
|
8c659e2be5 | ||
|
|
7e5c372833 | ||
|
|
ddaca5520e | ||
|
|
a6dbe48457 | ||
|
|
41863691a0 | ||
|
|
f783cfaf80 | ||
|
|
ac5ee2ac2c | ||
|
|
c45bf543c0 | ||
|
|
01331c1e4c | ||
|
|
8380b80d12 | ||
|
|
5ce49d7b88 | ||
|
|
492fc29eca | ||
|
|
dce532f17e | ||
|
|
fc2aadbc7e | ||
|
|
107e592704 | ||
|
|
a21e058cdd | ||
|
|
f1261584ff | ||
|
|
d2db762d96 |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"ui-ux-pro-max@ui-ux-pro-max-skill": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
.env
|
||||
*.db
|
||||
master.xprv.enc
|
||||
logs/
|
||||
data/
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
tests/
|
||||
.claude/
|
||||
@@ -0,0 +1,37 @@
|
||||
ELECTRUM_HOST=santantonio.sytes.net
|
||||
ELECTRUM_PORT=50002
|
||||
ELECTRUM_USE_SSL=true
|
||||
|
||||
# Additional Electrum servers to fall back to, comma-separated. Each entry is
|
||||
# `host:port` (TLS, the normal case) or `host:port:notls`. The listener rotates
|
||||
# over the primary above plus these, so one unreachable server costs a single
|
||||
# reconnect attempt instead of an outage — every deposit credit, broadcast and
|
||||
# confirmation goes through this one connection, which makes a single server the
|
||||
# platform's biggest single point of failure. A typo here fails at startup rather
|
||||
# than during the outage when the fallback is what you need.
|
||||
# These are the mainnet bootstrap servers from the PalladiumWallet repo
|
||||
# (src/Core/Chain/ChainProfiles.cs, ChainProfiles.Mainnet.BootstrapServers) — the
|
||||
# same ones the reference wallet falls back to:
|
||||
# Example: ELECTRUM_FALLBACK_SERVERS=173.212.224.67:50002,144.91.120.225:50002,66.94.115.80:50002,89.117.149.130:50002
|
||||
ELECTRUM_FALLBACK_SERVERS=
|
||||
|
||||
# Fernet key protecting the master xprv at rest. Generate with:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
XPRV_ENCRYPTION_KEY=
|
||||
|
||||
# Random secret for JWT session signing. Generate with:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
JWT_SECRET=
|
||||
|
||||
# Bearer token required on the admin endpoints (X-Admin-Token header). Generate with:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
ADMIN_TOKEN=
|
||||
|
||||
# Every business/round parameter (bet amount, round duration/cooldown, min
|
||||
# amount, fee rate, RBF timeout, fee address) is configured live from the
|
||||
# admin panel (/admin) instead of here — see docs/guida-admin.md.
|
||||
|
||||
# Swagger/ReDoc/the raw OpenAPI JSON expose the entire API surface — admin
|
||||
# endpoints included — to anyone who requests them. Off by default; set to
|
||||
# true only for local development, never in production.
|
||||
ENABLE_API_DOCS=false
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.db
|
||||
master.xprv.enc
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
logs/
|
||||
data/
|
||||
flowchart/*.pdf
|
||||
@@ -8,60 +8,241 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
||||
|
||||
## Project status
|
||||
|
||||
This repository is at the **specification stage, not yet implemented**: it currently contains only [flowchart.mmd](flowchart.mmd), which is the source of truth for the project and describes the entire application flow. The tech stack is decided (see below) but no code, build system, or lint/test commands exist yet — once the project is scaffolded, this section must be updated with real commands (install, run, lint, test — including how to run a single test).
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 253 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.
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
## Tech stack (MVP)
|
||||
Two full-codebase audits — 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are **all fixed** as of 2026-07-27, each with its own regression test. They were tracked in a `BUGS.md` that was deleted once the list emptied, so the ~276 `B-nn` markers left in comments across the code are pointers into git history (`git log --all --grep 'B-nn'` finds the commit that fixed one, and `git show f1a1145:BUGS.md`-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (`tests/integration/` is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted **by design** instead. A new finding gets the next B-nn, in its own commit with its own regression test.
|
||||
|
||||
- **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).
|
||||
Before writing code, read the "Architecture" section below in full plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) (the 5-phase flow) and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) (the round/draw lifecycle). Every node **and edge label** (conditions, retries, loops) is a behaviour that must be implemented as described. Regenerate the companion PDFs with `flowchart/render-pdf.sh <file>.mmd` after editing either.
|
||||
|
||||
## PLM network parameters
|
||||
Human-facing guides are in [docs/](docs/), in Italian by explicit request (an exception to the English-only rule): [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). README's Quick start and `docs/running-the-server.md` are Docker-only, matching this file — a bare `uvicorn --reload` workflow was removed from both (B-44).
|
||||
|
||||
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.
|
||||
## Commands
|
||||
|
||||
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`
|
||||
The server always runs via Docker, in dev and prod alike — there is no supported way to run `uvicorn` directly. The venv (`.venv/`) is only for local tooling: tests, Alembic migrations, and the one-time key/secret scripts.
|
||||
|
||||
## MVP business parameters
|
||||
```bash
|
||||
source .venv/bin/activate # venv already created at .venv/
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- 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**.
|
||||
alembic revision --autogenerate -m "message" # after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand
|
||||
|
||||
## What is PLM Lottery
|
||||
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the master xprv (needs XPRV_ENCRYPTION_KEY in .env)
|
||||
PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print it (asks for confirmation)
|
||||
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
|
||||
|
||||
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).
|
||||
python -m pytest # all 253 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
|
||||
```
|
||||
|
||||
## Architecture (from the flowchart subgraphs)
|
||||
`asyncio_mode = "auto"` (`pyproject.toml`), so async tests need no `@pytest.mark.asyncio`.
|
||||
|
||||
The flow is organized into 5 phases, each a subgraph in [flowchart.mmd](flowchart.mmd):
|
||||
`.env` (gitignored) holds the real secrets; `.env.example` documents the required keys and how to generate each. Note it does **not** list `DATABASE_URL` or `MASTER_KEY_PATH`, which the real `.env` does set.
|
||||
|
||||
- **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_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.
|
||||
- **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.
|
||||
## Deployment (Docker + Caddy)
|
||||
|
||||
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.
|
||||
Same `docker-compose.yml` for dev and prod — only `SITE_ADDRESS` differs. Two containers: `app` (this codebase; its startup command refuses to start if the master key file is missing, then runs `alembic upgrade head` and `uvicorn`) and `caddy` (reverse proxy + automatic TLS). The compose file overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted key, logs — gitignored, survive restarts). Set `MASTER_KEY_PATH` in `.env` to the host-side `./data/keys/master.xprv.enc` so the venv scripts write the exact file the container reads — one source of truth for the key.
|
||||
|
||||
```bash
|
||||
mkdir -p data/db data/keys data/logs # one-time
|
||||
docker compose up -d --build # dev and prod alike
|
||||
docker compose logs -f app # also written to ./data/logs/app.log
|
||||
docker compose down
|
||||
```
|
||||
|
||||
`SITE_ADDRESS` unset → `localhost`, Caddy issues a self-signed cert from its internal CA (browser warning on first visit is expected; `curl -k`). `SITE_ADDRESS=lottery.example.com docker compose up -d` → real Let's Encrypt cert, automatically renewed (needs DNS pointing here and ports 80+443 reachable).
|
||||
|
||||
The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and a CSP scoped to `default-src 'self'` plus the Google Fonts `@import` in `style.css`/`admin.css`. `script-src`/`style-src` need `'unsafe-inline'` because both SPAs use inline `onclick` handlers and `style=""` attributes throughout — removing those is a separate, larger refactor, not a header change. `restart: unless-stopped` on `app` means a mid-round crash auto-restarts: `closing` and `paying_out` resume on their own, `drawing` does not (see Known gaps).
|
||||
|
||||
## Tech stack
|
||||
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
## PLM network parameters (mainnet)
|
||||
|
||||
Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumWallet/src/Core/Chain/ChainProfiles.cs), [PalladiumNetworks.cs](../PalladiumWallet/src/Core/Chain/PalladiumNetworks.cs). Re-check there for anything not listed here rather than guessing; its `ChainProfiles.Mainnet.BootstrapServers` is also where `.env.example`'s suggested `ELECTRUM_FALLBACK_SERVERS` come from.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| BIP44/84 coin type | `746` → `m/84'/746'/0'/0/index` |
|
||||
| Bech32 HRP | `plm` |
|
||||
| P2PKH / P2SH version byte | `55` (addresses start with `P`) / `5` |
|
||||
| WIF prefix | `0x80` |
|
||||
| Block time | 120s |
|
||||
| BIP32 ext-key headers | see `ExtKeyHeaders` in `ChainProfiles.cs` |
|
||||
|
||||
## Business parameters
|
||||
|
||||
| Parameter | Value | Where |
|
||||
|---|---|---|
|
||||
| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable |
|
||||
| Prize split | **70% winner / 30% fees**, rounding remainder to fees | **hardcoded** in `rounds/scheduler.py` — a code change, not an admin edit |
|
||||
| Round duration / cooldown | 600s / 30s | `RoundConfig` |
|
||||
| Draw animation | 20s (cosmetic frontend minimum only) | `RoundConfig` |
|
||||
| Fee rate / RBF timeout | 1 sat/vB / 900s | `RoundConfig` |
|
||||
| Min withdrawal | = current `bet_amount_sats` (no separate field) | `withdrawals/service.py` |
|
||||
| Min deposit | none | — |
|
||||
| Min password length | 8 | `auth/security.py:MIN_PASSWORD_LENGTH` |
|
||||
| Confirmations, every tx kind | **1** | hardcoded in `tx/confirmation.py` |
|
||||
| Max inputs per tx | 50 (`MAX_TX_INPUTS`, B-48) — over it the build fails with `too_many_inputs`, it never spends more | hardcoded in `wallet/psbt_builder.py` |
|
||||
|
||||
`GET /rounds/current`'s `jackpot_sats` is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual `bet_amount_sats` (each already net of its own bet fee) rather than `count × current bet amount` — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11).
|
||||
|
||||
**Round cooldown** (`round_cooldown_seconds`, not in the original flowchart): gap after a round closes before the next opens, so players can see the outcome.
|
||||
|
||||
**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner.
|
||||
|
||||
## Code map
|
||||
|
||||
| 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/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) |
|
||||
| `app/deposits/` | crediting / external-spend detection / reinstatement (`service.py`), periodic sweep (`reconcile.py`) |
|
||||
| `app/bets/`, `app/withdrawals/` | build+broadcast services and their confirmation handlers |
|
||||
| `app/rounds/` | `scheduler.py` (close/draw/payout), `service.py` (open/active-round rules), `draw.py` (header math + winner pick), `config.py`, `events.py` (SSE pub/sub) |
|
||||
| `app/tx/` | `broadcast.py` (RBF bumper), `confirmation.py` (poller + handler registry), `reconcile.py`, `locks.py` (per-user locks) |
|
||||
| `app/static/` | the two SPAs (`index.html`/`app.js`/`style.css`, `admin.html`/`admin.js`/`admin.css`) + `i18n.js` |
|
||||
|
||||
## Background tasks
|
||||
|
||||
`app/main.py`'s lifespan starts six long-lived asyncio tasks and cancels them on shutdown. Their cadences determine how fast anything self-heals.
|
||||
|
||||
| Task | File | Cadence | Role |
|
||||
| --- | --- | --- | --- |
|
||||
| `ElectrumListener` | `electrum/listener.py` | reconnect loop, 60s keepalive | the single connection; subscribes headers + every user's scripthash, credits deposits |
|
||||
| `RoundScheduler` | `rounds/scheduler.py` | 5s | opens/closes rounds, draws, triggers and retries payouts |
|
||||
| `ConfirmationPoller` | `tx/confirmation.py` | 10s | `pending` → `confirmed` via per-kind handlers registered by `app/{bets,rounds,withdrawals}/confirmation.py` — imported for that side effect in `main.py`, **don't "clean up" those imports** |
|
||||
| `RbfBumper` | `tx/broadcast.py` | 30s | fee-bumps anything past `rbf_timeout_seconds` |
|
||||
| `PendingTransactionReconciler` | `tx/reconcile.py` | at startup, then 120s | resolves `building`/`pending` rows against the chain |
|
||||
| `DepositReconciler` | `deposits/reconcile.py` | 300s (sleeps first) | re-`refresh_user`s every address, catching a silently-lost subscription (B-30) |
|
||||
|
||||
Chain access goes through `listener.client`, passed as `lambda: listener.client` so a reconnect swaps the client under its consumers; a task finding it `None` skips that cycle instead of failing. `DepositReconciler` takes the whole listener instead, reusing `refresh_user` so the periodic and notification-driven paths can't diverge.
|
||||
|
||||
## Electrum connection
|
||||
|
||||
One connection serves everything — deposit credits, broadcasts, confirmations, the tip the draw waits on — so it's both the biggest single point of failure and, with a hostile server on the other end, the biggest integrity risk. Five defences:
|
||||
|
||||
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
|
||||
- **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets` *while holding the per-user lock*, and could stall the confirmation poller permanently.
|
||||
- **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
|
||||
- **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner.
|
||||
- **A quorum corroborates the two money-moving decisions** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29). No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing.
|
||||
|
||||
On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep.
|
||||
|
||||
## Architecture — the 5 phases
|
||||
|
||||
Diagrams: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifecycle.mmd](flowchart/round-lifecycle.mmd).
|
||||
|
||||
**REG** — on signup the server derives a P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from the encrypted master xprv. Permanent, and doubles as deposit address, winnings address and withdrawal change address.
|
||||
|
||||
**DEP** — the listener subscribes to the user's scripthash; balance is credited after **1 confirmation**, with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
|
||||
|
||||
**PLAY** — fixed cost, **at most one active bet per user**. PSBT user-address → pool-address, always with a **change output back to the same user address** (a user's balance must never exactly equal the bet). Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
|
||||
|
||||
**DRAW** — configurable timer (default 600s):
|
||||
- *Bet cutoff is the round's own deadline* (`opened_at + round_duration_seconds`), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`.
|
||||
- *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
|
||||
- *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount.
|
||||
- *Payout* is signed with the pool key; its **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
|
||||
- *UI, two independent layers.* A generic phase box ("Pagamento al vincitore in corso…") shows to **every** viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by `status`. **Additively**, a personalized "Hai vinto!/Non hai vinto" box appears only where `user_played` is true (computed via `get_optional_user`, since the endpoint is reachable logged-out) — nobody else has anything to reveal.
|
||||
- *Reveal timing.* Delayed by at least `draw_animation_seconds`, anchored to the server's `closes_at` so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for `winner_user_id`. Once revealed it's persisted in `localStorage.plm_persisted_result`, surviving the move to `closed` — at which point `get_active_round` stops returning the round and `winner_user_id` disappears from `GET /rounds/current`. `GET /users/me/last-round-result` is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: `refreshRound`/`checkLastRoundResult` in `app/static/app.js`.
|
||||
|
||||
**WITHDRAW** — the only way out to an external address: PSBT user-address → external + change back to the user, fee deducted from the withdrawn amount, same RBF pattern.
|
||||
|
||||
PLAY and WITHDRAW share a **per-user lock** (`tx/locks.py`): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set.
|
||||
|
||||
**Three separate on-chain confirmations sit between the timer hitting zero and the payout landing** — a common point of confusion:
|
||||
1. **Last bet's confirmation** — the round doesn't even flip to `"closing"` until every broadcast bet has 1 conf (`_tick`'s `pending_count` check). May already have happened before the deadline.
|
||||
2. **The draw block** — `_wait_for_next_block` waits for `tip_height > tip_at_close`, recorded only once step 1 is done, so this is necessarily a later block.
|
||||
3. **Payout confirmation** — built only after step 2's winner is known, so it needs yet another block; the generic `ConfirmationPoller` tracks it.
|
||||
|
||||
At 120s blocks that's ~4–6 min worst case (last bet confirms right at the deadline), ~2–4 min best case — independent of `draw_animation_seconds`.
|
||||
|
||||
## Balance display
|
||||
|
||||
`place_bet`/`request_withdrawal` select whole UTXOs (`select_utxos`, largest-first) and mark each `spent_txid` at broadcast time, long before any confirmation. `cached_balance_sats` (`recompute_balance`) sums only confirmed, unspent UTXOs, so right after a bet it understates the real balance by the whole unconfirmed change — often far more than the amount actually moving.
|
||||
|
||||
`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without changing what's spendable: it decodes the raw tx of every in-flight (`pending`) bet/withdrawal for the user and adds back the outputs paying to the user's own address. `GET /users/me` returns both — `balance_sats` (confirmed only; still what withdrawal-max and spend logic use, since only confirmed UTXOs are spendable) and `pending_balance_sats` + `has_pending` (what the UI shows: green when settled, amber while pending). A withdrawal whose amount is covered by the pending-inclusive balance but not the confirmed one gets `balance_pending_confirmation` instead of a flat `insufficient_balance` (B-37), so the error doesn't contradict what the user is looking at.
|
||||
|
||||
## Real-time updates (SSE)
|
||||
|
||||
`GET /rounds/stream` is **additive to** the polling loops in the two SPAs, not a replacement — a blocked or dropped stream just degrades to the old behaviour. No payload, no auth: it's a "something changed, go refetch" ping, with all personalization (e.g. `user_played`) staying in the authenticated REST endpoints. The generator re-checks `request.is_disconnected()` every 5s and sends a keep-alive comment every 20s, so neither a client that vanished without a clean close nor a proxy idle timeout breaks it silently.
|
||||
|
||||
`rounds/events.py`'s `RoundEventBroadcaster` (singleton `broadcaster`) is in-process pub/sub, one `asyncio.Queue(maxsize=1)` per client so redundant notifications coalesce. `publish()` is called on: a round opening (`rounds/service.py`), every status transition (`scheduler.py`), a bet or withdrawal broadcast, any pending tx confirming (`tx/confirmation.py`), a deposit credited (`deposits/service.py`), and a new tip arriving (`electrum/listener.py` — exactly what the drawing phase waits on). The rollback paths (`_release_failed_bet`, `_release_failed_withdrawal`, the reconciler's abandon) publish too — a rollback moves as much state as the success path, so it must ping the dashboards the same way (B-49).
|
||||
|
||||
Deliberate scope limits, not oversights: **single-process only** (fine for one uvicorn process; a multi-worker deployment needs e.g. Redis pub/sub — don't add it speculatively); **generic broadcast, not per-user** (everyone refetches on every event; acceptable at ~100 concurrent users, and a targeted channel would need auth on the stream plus server-side knowledge of who each event affects); `MAX_SUBSCRIBERS` (500) is defensive only — past it the endpoint returns 503 and `EventSource` falls back to polling, which being global and unauthenticated makes the cap itself a cheap DoS of the realtime feature (B-38).
|
||||
|
||||
Both SPAs refresh on an `update` message *or* on `open` — the latter fires on every automatic reconnect, closing most of the "missed while disconnected" gap.
|
||||
|
||||
## Transaction lifecycle and reconciliation
|
||||
|
||||
Everything that spends money is written **before** it is broadcast and resolved against the chain afterwards; this is what makes the system recover without manual DB edits. `PendingTransaction.status`: `building` → `pending` → `confirmed`, or `failed`.
|
||||
|
||||
- `building` is written first, UTXOs already marked `spent_txid`, and committed *before* the broadcast (`bets/service.py`, `withdrawals/service.py`, and `scheduler.py:_trigger_payout` — the same shape in four phases, so no DB session is ever held across a network call). A crash in that window leaves evidence, not coins spent on-chain with no record.
|
||||
- A refused broadcast releases the UTXOs, restores the balance, removes the participant (or marks the withdrawal `failed`), audit-logs, and raises `broadcast_failed` → **502**, since the network refused it, not the caller.
|
||||
- `tx/reconcile.py` asks the chain about anything still `building`/`pending`: present → promote; positively unknown → `failed` with a `failure_reason`, inputs released, domain row rolled back, `pending_tx_abandoned` logged. Grace differs by state (120s `building`, 6h `pending`, so the bumper gets its attempts first). A *transport* failure never abandons anything — only a server that positively doesn't know the tx, currently inferred by substring-matching the error text (fragile — B-41).
|
||||
|
||||
`UtxoEvent.spent_txid` must always equal the tx's *current* txid, so `bump_fee` retargets it along with `RoundParticipant.bet_txid`, `Withdrawal.txid` and `Round.payout_txid` on every bump. `broadcast_at` is the *first* broadcast and is never rewritten (the reconciler's abandon clock measures from it); `last_broadcast_at` is what a bump updates and `should_bump` reads. Confirmation handlers key off immutable ids (`round_id`/`user_id`, `withdrawal_id`), never the txid, which changes under them.
|
||||
|
||||
**Payouts retry, and are guarded against paying twice.** Every tick re-examines a `paying_out` round: `_retry_payout_if_due` throttles to one attempt per 60s using the latest `payout_failed` audit entry as its clock (a build failure leaves no DB row to throttle on), and every early return in `_trigger_payout` writes one, so `/admin` shows *why* a round is stuck. Before building, `_trigger_payout` refuses if a `building`/`pending` payout already exists for the round, and `_reserved_payout_outpoints` excludes pool UTXOs claimed by any unresolved payout — without both, a retry would pay the winner twice.
|
||||
|
||||
**"At most one active round" is a DB invariant**, not a convention: `ix_rounds_single_active` (unique index over the constant `(1)`, restricted to the active statuses) makes a concurrent second insert fail cleanly, and `open_new_round_if_needed` recovers by adopting the winner's round (max 3 attempts).
|
||||
|
||||
## Frontends
|
||||
|
||||
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.
|
||||
|
||||
Both talk to the same JSON API; there's no admin/user API split beyond `require_admin`.
|
||||
|
||||
## Internationalization (`/` and `/report-bug`)
|
||||
|
||||
`app/static/i18n.js` holds every user-facing string of `/` and `/report-bug` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch. `/` loads it before `app.js`; `/report-bug` loads it before its own inline script — either way `t()` is always available by the time it's called. Language: `localStorage.plm_lang` → `navigator.language` → `en`, shared across both pages since they read/write the same `localStorage` key. On `/` the switcher sits in the **chain-bar, not the navbar**, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it. `/report-bug` has no navbar at all, so its switcher is just a top-right bar of its own.
|
||||
|
||||
- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`) via `applyStaticTranslations(root?)`; anything rendered from server data uses `t()` directly (`app.js`'s `onLanguageChange()`, `report-bug.html`'s own inline equivalent) and is re-rendered on a language switch. An element belongs to one camp or the other, **never both**, or the two mechanisms overwrite each other — that's why `#bet-btn` has no `data-i18n`: its label carries the configurable bet amount, so `renderBetButton()` owns it.
|
||||
- **Every language must have exactly the same key set.** There is no fallback beyond `en`; a missing key renders as the raw key string.
|
||||
- `/report-bug`'s `bugReport.englishNotice` string is itself translated into all 7 languages — it just always *says*, in whichever language the visitor reads, to write the actual bug description in English (so the admin panel, which is Italian-operator-facing and untranslated, doesn't end up with reports in 7 different languages).
|
||||
- `/admin` is intentionally untranslated (operator-facing, Italian), as is `/guida`.
|
||||
|
||||
**API error contract** (`app/api/errors.py`) — the API is single-language by design. Failures answer with a structured `detail`: `{"code", "message", "params"}`, where `message` is English for non-dashboard consumers and `code` is what the frontend maps to `error.<code>` in `i18n.js` (falling back to `message` for an unknown code). `BetError`/`WithdrawalError` subclass `ApiError` and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (`internal_error`), so clients never special-case unexpected errors, and the exception text stays in `logs/app.log`. Adding a user-facing error: give it a code, add `error.<code>` to all 7 languages, and pass values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling) instead of baking them into English text.
|
||||
|
||||
## 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:
|
||||
Explicit design choices, not derivable from any single file — respect them:
|
||||
|
||||
- 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.
|
||||
- Keys are generated and held **server-side**: this is **custodial**. The user controls nothing until they withdraw.
|
||||
- The deposit address *is* the winnings address — there is no separate "winner address".
|
||||
- **1 confirmation** for every tx kind. Don't introduce differing thresholds (3, 6, …) without an explicit decision.
|
||||
- The draw algorithm is a **replaceable component**, not the final design — don't architect around its current form.
|
||||
- `GET /admin/users/{id}/privkey` exporting a raw WIF is **intentional**, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes `admin_privkey_accessed` — don't remove that logging.
|
||||
- Argon2 hashing means **no password recovery, only reset**: `POST /admin/users/{id}/reset-password` sets a new random password, returns it once for the operator to relay, and logs `admin_password_reset`. No self-service reset exists (no email is ever collected); a logged-in user can only *change* their password by supplying the current one.
|
||||
- RBF bumps are paid by whoever's change the tx pays back to — the user for bets/withdrawals, the pool for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only the sender's own change shrinks (`bump_fee`).
|
||||
|
||||
## Known gaps / TODO
|
||||
|
||||
Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here.
|
||||
|
||||
- **`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 — an exact-amount tx or too-small change raises `RbfError`. Not permanent, though: an unbumpable tx that never confirms 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.
|
||||
- **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 anywhere** (register, bet, withdrawal, admin, SSE). For login this is a blocker, not a gap — tracked as B-33.
|
||||
- **`/guida` and `/report-bug` are placeholders** (`app/static/guida.html`, `report-bug.html`) — links work, content is "coming soon".
|
||||
- **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.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# SITE_ADDRESS is the domain to serve (e.g. lottery.example.com) — Caddy
|
||||
# automatically requests a Let's Encrypt certificate for it.
|
||||
#
|
||||
# Left at the default "localhost" (dev mode, no domain), Caddy detects it's
|
||||
# not a public hostname and issues a locally-trusted self-signed certificate
|
||||
# instead, via its internal CA. Browsers will still warn on first visit
|
||||
# unless that CA is explicitly trusted — expected for local/dev use.
|
||||
{$SITE_ADDRESS:localhost} {
|
||||
# gzip buffers output, which would delay delivery on the SSE stream
|
||||
# (/rounds/stream, app/api/routes/rounds.py) — it needs each event flushed
|
||||
# to the client immediately, not batched. Everything else still compresses.
|
||||
@not_sse {
|
||||
not path /rounds/stream
|
||||
}
|
||||
encode @not_sse gzip
|
||||
|
||||
# B-43: Caddy adds none of these on its own. The JWT lives in
|
||||
# localStorage, so any XSS exfiltrates it — CSP is the main mitigation.
|
||||
# script-src/style-src need 'unsafe-inline' because both SPAs
|
||||
# (app/static/index.html, admin.html) use inline onclick handlers and
|
||||
# style="" attributes throughout; removing those is a separate,
|
||||
# larger refactor, not a header change. fonts.googleapis.com/gstatic.com
|
||||
# are the one external asset (the Google Fonts @import in style.css/admin.css).
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
|
||||
}
|
||||
|
||||
reverse_proxy app:8123
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY app ./app
|
||||
COPY migrations ./migrations
|
||||
COPY alembic.ini ./
|
||||
COPY scripts ./scripts
|
||||
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
EXPOSE 8123
|
||||
|
||||
CMD ["/bin/sh", "-c", "test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123"]
|
||||
@@ -0,0 +1,68 @@
|
||||
# PLM Lottery
|
||||
|
||||
A periodic-round lottery system built on PLM, a Bitcoin-like coin (mainnet).
|
||||
Each user gets a dedicated server-derived P2WPKH address; 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).
|
||||
|
||||
This is a **custodial** system: private keys are generated and held
|
||||
server-side, encrypted at rest. See [CLAUDE.md](CLAUDE.md) for the full
|
||||
architecture, domain decisions, and known gaps before treating this as
|
||||
production-ready.
|
||||
|
||||
## Quick start
|
||||
|
||||
The server always runs via Docker (app + Caddy reverse proxy with automatic
|
||||
TLS) — in dev and production alike, with only `SITE_ADDRESS` differing
|
||||
between the two. There's no supported way to run `uvicorn` directly; the
|
||||
venv is only for local tooling (tests, Alembic migrations, the one-time key
|
||||
scripts) — see [CLAUDE.md](CLAUDE.md#commands).
|
||||
|
||||
```bash
|
||||
cp .env.example .env # then fill in the generated secrets, see docs/setup.md
|
||||
mkdir -p data/db data/keys data/logs
|
||||
docker compose run --rm app python scripts/generate_master_key.py
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Open `https://localhost/` for the test UI, `https://localhost/admin` for the
|
||||
admin dashboard (a self-signed-certificate warning on first visit is
|
||||
expected in dev — accept it, or use `curl -k`). The interactive API docs at
|
||||
`/docs` are disabled by default (they'd otherwise expose the whole API
|
||||
surface, admin endpoints included) — set `ENABLE_API_DOCS=true` in `.env` to
|
||||
enable them.
|
||||
|
||||
See [docs/setup.md](docs/setup.md) and
|
||||
[docs/running-the-server.md](docs/running-the-server.md) for the full
|
||||
walkthrough (secrets, master key generation, production TLS with a real
|
||||
domain).
|
||||
|
||||
## Documentation
|
||||
|
||||
- [CLAUDE.md](CLAUDE.md) — architecture, commands, domain decisions, known gaps (for anyone/anything working on the code)
|
||||
- [flowchart.mmd](flowchart.mmd) — the source-of-truth flow diagram the implementation follows node-by-node
|
||||
- [docs/setup.md](docs/setup.md) — one-time setup (secrets, master key, migrations)
|
||||
- [docs/running-the-server.md](docs/running-the-server.md) — how to launch it (local venv vs. Docker+Caddy, dev vs. production TLS)
|
||||
- [docs/guida-utente.md](docs/guida-utente.md) — end-user guide to the test UI (Italian)
|
||||
- [docs/guida-admin.md](docs/guida-admin.md) — admin dashboard guide (Italian)
|
||||
|
||||
## Tech stack
|
||||
|
||||
Python (FastAPI, SQLAlchemy async + Alembic, Argon2 + JWT auth), Electrum
|
||||
protocol for PLM network access (no full node), Docker + Caddy for
|
||||
deployment. See [CLAUDE.md](CLAUDE.md#tech-stack-mvp) for the complete list
|
||||
and the reasoning behind each choice.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
python -m pytest # all tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
```
|
||||
|
||||
232 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
|
||||
the pending-inclusive balance calculation, and the SSE push channel. No
|
||||
automated integration tests against a live Electrum connection — mainnet
|
||||
verification so far has been manual (see CLAUDE.md's "Project status").
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/migrations
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,17 @@
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""The caller's real IP, from Caddy's X-Forwarded-For (see Caddyfile) —
|
||||
request.client.host would otherwise be the reverse proxy's own address, not
|
||||
the caller's. Falls back to request.client.host only if the header is
|
||||
somehow missing (e.g. the app container hit directly, bypassing Caddy).
|
||||
|
||||
Shared by the login/registration throttles (B-33) and the SSE per-IP
|
||||
subscriber cap (B-38) so the two can't drift into different notions of
|
||||
"the client's IP".
|
||||
"""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Machine-readable error codes for user-facing API failures.
|
||||
|
||||
The dashboard is multilingual (app/static/i18n.js) but the API is not: every
|
||||
message produced here stays English. What travels alongside it is a stable
|
||||
`code` the client maps onto its own translated string (`error.<code>`), falling
|
||||
back to `message` for any code it doesn't recognize — so a non-dashboard
|
||||
consumer (curl, tests, a future client) still gets something readable without
|
||||
having to know the code table.
|
||||
|
||||
`detail` is therefore an object rather than the FastAPI-default bare string:
|
||||
|
||||
{"code": "insufficient_balance", "message": "insufficient balance", "params": {}}
|
||||
|
||||
`params` carries the values interpolated into the message (amounts, limits) so
|
||||
the translated string can place them wherever its own grammar needs them,
|
||||
instead of the client having to parse them back out of the English text.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""Domain-layer error carrying the code the client will translate.
|
||||
|
||||
Subclassed per domain (BetError, WithdrawalError) so services keep raising
|
||||
their own exception type. `str(exc)` is still the plain English message.
|
||||
"""
|
||||
|
||||
def __init__(self, code: str, message: str, **params: Any) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.params = params
|
||||
|
||||
def as_detail(self) -> dict[str, Any]:
|
||||
return {"code": self.code, "message": self.message, "params": self.params}
|
||||
|
||||
|
||||
def http_error(status_code: int, code: str, message: str, **params: Any) -> HTTPException:
|
||||
"""HTTPException whose detail is the structured object described above."""
|
||||
return HTTPException(status_code, ApiError(code, message, **params).as_detail())
|
||||
|
||||
|
||||
def from_api_error(status_code: int, exc: ApiError) -> HTTPException:
|
||||
return HTTPException(status_code, exc.as_detail())
|
||||
@@ -0,0 +1,419 @@
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.audit.log import write_audit_log
|
||||
from app.auth.security import hash_password
|
||||
from app.config import settings
|
||||
from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
|
||||
from app.db.session import get_session
|
||||
from app.rounds.config import get_round_config
|
||||
from app.wallet.address import is_valid_plm_address
|
||||
from app.wallet.hd import derive_user_wif
|
||||
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
async def require_admin(x_admin_token: str = Header(default="")) -> None:
|
||||
# An unset ADMIN_TOKEN denies everything — checked first, since compare_digest
|
||||
# on two empty strings returns True and would otherwise open the panel to
|
||||
# anyone on an instance that never configured a token.
|
||||
if not settings.admin_token:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
|
||||
# compare_digest raises TypeError on a str containing non-ASCII characters
|
||||
# (B-46) -- comparing the UTF-8 bytes instead accepts any input safely.
|
||||
if not secrets.compare_digest(x_admin_token.encode(), settings.admin_token.encode()):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
|
||||
|
||||
|
||||
# `paused` is deliberately NOT here: it has its own audit-logged endpoints
|
||||
# (/admin/pause, /admin/resume), and accepting it on PUT /config as well gave the
|
||||
# operator an unlogged way to stop the lottery (B-10). It stays in the response
|
||||
# model, so the dashboard still reads its current value from here.
|
||||
_CONFIG_FIELDS = (
|
||||
"fee_address",
|
||||
"bet_amount_sats",
|
||||
"round_duration_seconds",
|
||||
"round_cooldown_seconds",
|
||||
"fee_rate_sat_vb",
|
||||
"rbf_timeout_seconds",
|
||||
"draw_animation_seconds",
|
||||
)
|
||||
|
||||
|
||||
class RoundConfigResponse(BaseModel):
|
||||
fee_address: str
|
||||
bet_amount_sats: int
|
||||
round_duration_seconds: int
|
||||
round_cooldown_seconds: int
|
||||
fee_rate_sat_vb: int
|
||||
rbf_timeout_seconds: int
|
||||
draw_animation_seconds: int
|
||||
paused: bool
|
||||
|
||||
|
||||
class RoundConfigUpdate(BaseModel):
|
||||
"""Bounds are enforced here rather than trusting the operator: a value like
|
||||
fee_rate_sat_vb=0 produces transactions no node will relay (stalling every bet,
|
||||
payout and withdrawal), and round_duration_seconds=0 expires a round the instant
|
||||
it opens. `paused` is not accepted — see _CONFIG_FIELDS."""
|
||||
|
||||
# An unvalidated fee_address was the worst of the lot: a malformed one wedged the
|
||||
# payout with an unhandled EmbitError, and a well-formed *foreign* one (bc1...)
|
||||
# parses fine as a witness program, so every round's 30 % commission would be
|
||||
# broadcast to a script nobody holds the key for (B-05).
|
||||
fee_address: str | None = None
|
||||
bet_amount_sats: int | None = Field(default=None, gt=0, le=100_000 * 100_000_000)
|
||||
round_duration_seconds: int | None = Field(default=None, ge=30, le=7 * 24 * 3600)
|
||||
round_cooldown_seconds: int | None = Field(default=None, ge=0, le=24 * 3600)
|
||||
fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB)
|
||||
rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600)
|
||||
draw_animation_seconds: int | None = Field(default=None, ge=0, le=600)
|
||||
|
||||
@field_validator("fee_address")
|
||||
@classmethod
|
||||
def _validate_fee_address(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
if not is_valid_plm_address(value):
|
||||
raise ValueError(
|
||||
"fee_address must be a valid PLM bech32 address (plm1...) — an address from "
|
||||
"another chain would send every round's commission somewhere unspendable"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _config_response(config) -> RoundConfigResponse:
|
||||
fields = {field: getattr(config, field) for field in _CONFIG_FIELDS}
|
||||
fields["paused"] = config.paused
|
||||
return RoundConfigResponse(**fields)
|
||||
|
||||
|
||||
@router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
||||
async def read_config(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
|
||||
config = await get_round_config(session)
|
||||
await session.commit()
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
@router.put("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
||||
async def update_config(
|
||||
body: RoundConfigUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> RoundConfigResponse:
|
||||
config = await get_round_config(session)
|
||||
# Diff computed before assignment so the audit entry records both sides. Without
|
||||
# it, the most sensitive setting in the system (fee_address — where 30 % of every
|
||||
# pool goes) could be changed without leaving any trace at all (B-10).
|
||||
changes: dict[str, dict] = {}
|
||||
for field in _CONFIG_FIELDS:
|
||||
value = getattr(body, field)
|
||||
if value is None:
|
||||
continue
|
||||
previous = getattr(config, field)
|
||||
if previous == value:
|
||||
continue
|
||||
changes[field] = {"from": previous, "to": value}
|
||||
setattr(config, field, value)
|
||||
|
||||
if changes:
|
||||
await write_audit_log(session, "config_updated", changes)
|
||||
await session.commit()
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
@router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
||||
async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
|
||||
"""Maintenance switch: the round in progress (if any) still closes, draws,
|
||||
and pays out its winner normally — only opening the *next* round is
|
||||
suppressed until /admin/resume is called (rounds/service.py)."""
|
||||
config = await get_round_config(session)
|
||||
config.paused = True
|
||||
await write_audit_log(session, "lottery_paused", {})
|
||||
await session.commit()
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
@router.post("/resume", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
||||
async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
|
||||
config = await get_round_config(session)
|
||||
config.paused = False
|
||||
await write_audit_log(session, "lottery_resumed", {})
|
||||
await session.commit()
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
class AdminUserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
address: str
|
||||
balance_sats: int
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserResponse], dependencies=[Depends(require_admin)])
|
||||
async def list_users(session: AsyncSession = Depends(get_session)) -> list[AdminUserResponse]:
|
||||
users = (await session.scalars(select(User).order_by(User.id))).all()
|
||||
return [
|
||||
AdminUserResponse(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
address=u.address,
|
||||
balance_sats=u.cached_balance_sats,
|
||||
created_at=isoformat_utc(u.created_at),
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
class AdminPrivkeyResponse(BaseModel):
|
||||
address: str
|
||||
wif: str
|
||||
|
||||
|
||||
@router.get(
|
||||
"/users/{user_id}/privkey", response_model=AdminPrivkeyResponse, dependencies=[Depends(require_admin)]
|
||||
)
|
||||
async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session)) -> AdminPrivkeyResponse:
|
||||
"""Exports a user's raw private key for manual intervention (e.g. sweeping
|
||||
funds back if something's stuck). Every access is audit-logged since this is
|
||||
the most sensitive data the platform holds."""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
|
||||
wif = derive_user_wif(user.derivation_index)
|
||||
await write_audit_log(session, "admin_privkey_accessed", {"user_id": user_id}, user_id=user_id)
|
||||
await session.commit()
|
||||
return AdminPrivkeyResponse(address=user.address, wif=wif)
|
||||
|
||||
|
||||
class AdminPasswordResetResponse(BaseModel):
|
||||
username: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post(
|
||||
"/users/{user_id}/reset-password",
|
||||
response_model=AdminPasswordResetResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def reset_user_password(
|
||||
user_id: int, session: AsyncSession = Depends(get_session)
|
||||
) -> AdminPasswordResetResponse:
|
||||
"""Admin-only password reset for a user who's locked out: passwords are
|
||||
Argon2-hashed (one-way), so an existing password can never be recovered or
|
||||
displayed — this generates and sets a brand new one instead, shown once so
|
||||
the admin can relay it to the user. There is no user-facing self-service
|
||||
reset; only an admin (via /admin, token-gated) can trigger this."""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
|
||||
new_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = hash_password(new_password)
|
||||
# B-34: this endpoint exists precisely for the "account compromised" case —
|
||||
# without bumping token_version, whoever was already logged in (the
|
||||
# attacker, if that's who prompted the reset) stayed logged in on their
|
||||
# existing token until it naturally expired, unaffected by the reset.
|
||||
user.token_version += 1
|
||||
await write_audit_log(session, "admin_password_reset", {"user_id": user_id}, user_id=user_id)
|
||||
await session.commit()
|
||||
return AdminPasswordResetResponse(username=user.username, new_password=new_password)
|
||||
|
||||
|
||||
class AdminRoundResponse(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
opened_at: str
|
||||
closed_at: str | None
|
||||
draw_block_height: int | None
|
||||
draw_block_hash: str | None
|
||||
winner_user_id: int | None
|
||||
winner_username: str | None
|
||||
pool_amount_sats: int | None
|
||||
winner_amount_sats: int | None
|
||||
fee_amount_sats: int | None
|
||||
payout_txid: str | None
|
||||
|
||||
|
||||
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
|
||||
async def list_rounds(
|
||||
session: AsyncSession = Depends(get_session), limit: int = Query(default=50, ge=1, le=500)
|
||||
) -> list[AdminRoundResponse]:
|
||||
rounds = (await session.scalars(select(Round).order_by(Round.id.desc()).limit(limit))).all()
|
||||
winner_ids = {r.winner_user_id for r in rounds if r.winner_user_id is not None}
|
||||
winners = {}
|
||||
if winner_ids:
|
||||
users = (await session.scalars(select(User).where(User.id.in_(winner_ids)))).all()
|
||||
winners = {u.id: u.username for u in users}
|
||||
|
||||
return [
|
||||
AdminRoundResponse(
|
||||
id=r.id,
|
||||
status=r.status,
|
||||
opened_at=isoformat_utc(r.opened_at),
|
||||
closed_at=isoformat_utc(r.closed_at),
|
||||
draw_block_height=r.draw_block_height,
|
||||
draw_block_hash=r.draw_block_hash,
|
||||
winner_user_id=r.winner_user_id,
|
||||
winner_username=winners.get(r.winner_user_id) if r.winner_user_id is not None else None,
|
||||
pool_amount_sats=r.pool_amount_sats,
|
||||
winner_amount_sats=r.winner_amount_sats,
|
||||
fee_amount_sats=r.fee_amount_sats,
|
||||
payout_txid=r.payout_txid,
|
||||
)
|
||||
for r in rounds
|
||||
]
|
||||
|
||||
|
||||
class AdminAuditLogResponse(BaseModel):
|
||||
id: int
|
||||
event_type: str
|
||||
payload: dict
|
||||
user_id: int | None
|
||||
round_id: int | None
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get(
|
||||
"/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)]
|
||||
)
|
||||
async def list_audit_log(
|
||||
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
|
||||
) -> list[AdminAuditLogResponse]:
|
||||
entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all()
|
||||
return [
|
||||
AdminAuditLogResponse(
|
||||
id=e.id,
|
||||
event_type=e.event_type,
|
||||
payload=json.loads(e.payload_json),
|
||||
user_id=e.user_id,
|
||||
round_id=e.round_id,
|
||||
created_at=isoformat_utc(e.created_at),
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
|
||||
|
||||
class AdminPendingTransactionResponse(BaseModel):
|
||||
id: int
|
||||
kind: str
|
||||
status: str
|
||||
round_id: int | None
|
||||
withdrawal_id: int | None
|
||||
user_id: int | None
|
||||
current_txid: str
|
||||
fee_rate_sat_vb: int
|
||||
attempt_count: int
|
||||
broadcast_at: str
|
||||
replaced_by_txid: str | None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pending-transactions",
|
||||
response_model=list[AdminPendingTransactionResponse],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def list_pending_transactions(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
) -> list[AdminPendingTransactionResponse]:
|
||||
query = select(PendingTransaction).order_by(PendingTransaction.id.desc())
|
||||
if status_filter is not None:
|
||||
query = query.where(PendingTransaction.status == status_filter)
|
||||
entries = (await session.scalars(query.limit(limit))).all()
|
||||
return [
|
||||
AdminPendingTransactionResponse(
|
||||
id=p.id,
|
||||
kind=p.kind,
|
||||
status=p.status,
|
||||
round_id=p.round_id,
|
||||
withdrawal_id=p.withdrawal_id,
|
||||
user_id=p.user_id,
|
||||
current_txid=p.current_txid,
|
||||
fee_rate_sat_vb=p.fee_rate_sat_vb,
|
||||
attempt_count=p.attempt_count,
|
||||
broadcast_at=isoformat_utc(p.broadcast_at),
|
||||
replaced_by_txid=p.replaced_by_txid,
|
||||
)
|
||||
for p in entries
|
||||
]
|
||||
|
||||
|
||||
_BUG_REPORT_STATUSES = ("open", "read", "resolved")
|
||||
|
||||
|
||||
class AdminBugReportResponse(BaseModel):
|
||||
id: int
|
||||
description: str
|
||||
contact: str | None
|
||||
user_id: int | None
|
||||
username: str | None
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
def _bug_report_response(report: BugReport, username: str | None) -> AdminBugReportResponse:
|
||||
return AdminBugReportResponse(
|
||||
id=report.id,
|
||||
description=report.description,
|
||||
contact=report.contact,
|
||||
user_id=report.user_id,
|
||||
username=username,
|
||||
status=report.status,
|
||||
created_at=isoformat_utc(report.created_at),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/bug-reports", response_model=list[AdminBugReportResponse], dependencies=[Depends(require_admin)]
|
||||
)
|
||||
async def list_bug_reports(
|
||||
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
|
||||
) -> list[AdminBugReportResponse]:
|
||||
reports = (await session.scalars(select(BugReport).order_by(BugReport.id.desc()).limit(limit))).all()
|
||||
user_ids = {r.user_id for r in reports if r.user_id is not None}
|
||||
usernames = {}
|
||||
if user_ids:
|
||||
users = (await session.scalars(select(User).where(User.id.in_(user_ids)))).all()
|
||||
usernames = {u.id: u.username for u in users}
|
||||
|
||||
return [
|
||||
_bug_report_response(r, usernames.get(r.user_id) if r.user_id is not None else None)
|
||||
for r in reports
|
||||
]
|
||||
|
||||
|
||||
class BugReportStatusUpdate(BaseModel):
|
||||
status: str = Field(pattern="^(open|read|resolved)$")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/bug-reports/{report_id}/status",
|
||||
response_model=AdminBugReportResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def update_bug_report_status(
|
||||
report_id: int, body: BugReportStatusUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> AdminBugReportResponse:
|
||||
report = await session.get(BugReport, report_id)
|
||||
if report is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found")
|
||||
|
||||
report.status = body.status
|
||||
await session.commit()
|
||||
|
||||
username = None
|
||||
if report.user_id is not None:
|
||||
user = await session.get(User, report.user_id)
|
||||
username = user.username if user is not None else None
|
||||
|
||||
return _bug_report_response(report, username)
|
||||
@@ -0,0 +1,53 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import from_api_error, http_error
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.bets.service import BetError, place_bet
|
||||
from app.db.models import User
|
||||
from app.db.session import get_session
|
||||
|
||||
router = APIRouter(prefix="/bets", tags=["bets"])
|
||||
|
||||
|
||||
class BetResponse(BaseModel):
|
||||
round_id: int
|
||||
bet_txid: str
|
||||
bet_amount_sats: int
|
||||
status: str
|
||||
|
||||
|
||||
@router.post("", response_model=BetResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bet(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> BetResponse:
|
||||
listener = request.app.state.electrum_listener
|
||||
if listener.client is None:
|
||||
raise http_error(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"network_unavailable",
|
||||
"not connected to the network, try again shortly",
|
||||
)
|
||||
|
||||
async with request.app.state.user_locks.acquire(user.id):
|
||||
try:
|
||||
participant = await place_bet(session, listener.client, user)
|
||||
except BetError as exc:
|
||||
# A rejected broadcast isn't the client's fault — it's the network
|
||||
# refusing our transaction, so it answers 502 rather than 400 (B-07).
|
||||
code = (
|
||||
status.HTTP_502_BAD_GATEWAY
|
||||
if exc.code == "broadcast_failed"
|
||||
else status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
raise from_api_error(code, exc) from exc
|
||||
|
||||
return BetResponse(
|
||||
round_id=participant.round_id,
|
||||
bet_txid=participant.bet_txid,
|
||||
bet_amount_sats=participant.bet_amount_sats,
|
||||
status=participant.status,
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.auth.dependencies import get_current_user, get_optional_user
|
||||
from app.db.models import BugReport, User
|
||||
from app.db.session import get_session
|
||||
|
||||
router = APIRouter(prefix="/bug-reports", tags=["bug-reports"])
|
||||
|
||||
|
||||
class BugReportCreate(BaseModel):
|
||||
description: str = Field(min_length=1, max_length=2000)
|
||||
contact: str | None = Field(default=None, max_length=256)
|
||||
|
||||
@field_validator("description")
|
||||
@classmethod
|
||||
def _description_not_blank(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("description must not be blank")
|
||||
return value
|
||||
|
||||
@field_validator("contact")
|
||||
@classmethod
|
||||
def _contact_stripped(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class BugReportResponse(BaseModel):
|
||||
id: int
|
||||
|
||||
|
||||
@router.post("", response_model=BugReportResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bug_report(
|
||||
body: BugReportCreate,
|
||||
user: User | None = Depends(get_optional_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> BugReportResponse:
|
||||
report = BugReport(
|
||||
description=body.description,
|
||||
contact=body.contact,
|
||||
user_id=user.id if user is not None else None,
|
||||
)
|
||||
session.add(report)
|
||||
await session.commit()
|
||||
return BugReportResponse(id=report.id)
|
||||
|
||||
|
||||
class MyBugReportResponse(BaseModel):
|
||||
id: int
|
||||
description: str
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get("/mine", response_model=list[MyBugReportResponse])
|
||||
async def list_my_bug_reports(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[MyBugReportResponse]:
|
||||
"""The one user-facing history view for bug reports (anonymous submissions have
|
||||
no user to attribute this to, so this only ever covers ones filed while logged in)."""
|
||||
reports = (
|
||||
await session.scalars(
|
||||
select(BugReport).where(BugReport.user_id == user.id).order_by(BugReport.id.desc())
|
||||
)
|
||||
).all()
|
||||
return [
|
||||
MyBugReportResponse(
|
||||
id=r.id, description=r.description, status=r.status, created_at=isoformat_utc(r.created_at)
|
||||
)
|
||||
for r in reports
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
import io
|
||||
import re
|
||||
|
||||
import qrcode
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import Response
|
||||
|
||||
router = APIRouter(tags=["qr"])
|
||||
|
||||
# PLM P2WPKH addresses: bech32 HRP "plm" + separator + witness program.
|
||||
_ADDRESS_RE = re.compile(r"^plm1[a-z0-9]{10,90}$")
|
||||
|
||||
|
||||
@router.get("/qr/{address}")
|
||||
async def address_qr(address: str) -> Response:
|
||||
if not _ADDRESS_RE.match(address):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid address")
|
||||
|
||||
image = qrcode.make(address)
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return Response(content=buf.getvalue(), media_type="image/png")
|
||||
@@ -0,0 +1,188 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.client_ip import client_ip
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.auth.dependencies import get_optional_user
|
||||
from app.db.models import RoundParticipant, User
|
||||
from app.db.session import get_session
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
|
||||
from app.rounds.service import get_active_round, winner_share
|
||||
|
||||
router = APIRouter(prefix="/rounds", tags=["rounds"])
|
||||
|
||||
# How often request.is_disconnected() gets (re-)checked while idle — bounds
|
||||
# how long a subscriber slot lingers after a client goes away without a clean
|
||||
# TCP close (e.g. the network just vanishes). Kept short since the check
|
||||
# itself is cheap; it does NOT control how often anything is sent on the wire.
|
||||
_SSE_DISCONNECT_CHECK_SECONDS = 5
|
||||
|
||||
# How often to send an SSE keep-alive comment on an otherwise-idle connection —
|
||||
# well under any reasonable reverse-proxy/load-balancer idle-connection timeout
|
||||
# (Caddy's default is 5 minutes) so the stream isn't silently dropped. Expressed
|
||||
# as a multiple of the disconnect-check interval above.
|
||||
_SSE_KEEPALIVE_TICKS = 4 # 4 * 5s = 20s between keep-alive comments
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def round_stream(request: Request) -> Response:
|
||||
"""Server-Sent Events channel: pushes a content-free "update" notification
|
||||
the instant round/bet/balance state changes anywhere (see app/rounds/events.py
|
||||
for the publish() call sites), instead of clients only finding out on their
|
||||
next poll. No payload and no auth: it's a public "go refetch" signal, and
|
||||
the actual data still comes from the normal per-user REST endpoints, which
|
||||
is where authorization and personalization (e.g. user_played) already live.
|
||||
|
||||
Frontend polling (app/static/index.html) is left in place as a fallback —
|
||||
this is purely additive, so a dropped/blocked SSE connection degrades to
|
||||
the pre-existing polling behavior rather than losing updates outright.
|
||||
That's also what happens past MAX_SUBSCRIBERS (app/rounds/events.py): this
|
||||
returns 503 rather than opening a stream, and the browser's EventSource
|
||||
just retries later while the frontend keeps working off polling meanwhile.
|
||||
|
||||
Concurrent streams are additionally capped per client IP (B-38): past
|
||||
MAX_SUBSCRIBERS_PER_IP, opening one more evicts that IP's own oldest
|
||||
connection rather than refusing the new one or letting a single source
|
||||
exhaust the global cap and degrade every other user.
|
||||
"""
|
||||
try:
|
||||
queue = broadcaster.subscribe(client_ip(request))
|
||||
except RoundEventCapacityError:
|
||||
return JSONResponse(status_code=503, content={"detail": "too many concurrent update streams"})
|
||||
|
||||
async def event_generator():
|
||||
ticks_since_keepalive = 0
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
item = await asyncio.wait_for(queue.get(), timeout=_SSE_DISCONNECT_CHECK_SECONDS)
|
||||
if item is EVICTED:
|
||||
break # this IP opened another stream past its per-IP cap
|
||||
yield "event: update\ndata: {}\n\n".format(json.dumps({}))
|
||||
ticks_since_keepalive = 0
|
||||
except asyncio.TimeoutError:
|
||||
ticks_since_keepalive += 1
|
||||
if ticks_since_keepalive >= _SSE_KEEPALIVE_TICKS:
|
||||
yield ": keep-alive\n\n"
|
||||
ticks_since_keepalive = 0
|
||||
finally:
|
||||
broadcaster.unsubscribe(queue)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
class CurrentRoundResponse(BaseModel):
|
||||
server_time: str
|
||||
round_id: int | None = None
|
||||
status: str | None = None
|
||||
opened_at: str | None = None
|
||||
closes_at: str | None = None
|
||||
participant_count: int = 0
|
||||
bet_amount_sats: int
|
||||
jackpot_sats: int = 0
|
||||
draw_animation_seconds: int
|
||||
winner_user_id: int | None = None
|
||||
winner_amount_sats: int | None = None
|
||||
draw_block_height: int | None = None
|
||||
draw_block_hash: str | None = None
|
||||
# B-36: set only while status == "drawing", so the frontend can show "still
|
||||
# waiting for a block" rather than a countdown implying a bounded wait — this
|
||||
# phase has no timeout, only draw_animation_seconds' cosmetic minimum.
|
||||
draw_waiting_since: str | None = None
|
||||
chain_tip_height: int | None = None
|
||||
lottery_paused: bool = False
|
||||
user_played: bool = False
|
||||
|
||||
|
||||
@router.get("/current", response_model=CurrentRoundResponse)
|
||||
async def current_round(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: User | None = Depends(get_optional_user),
|
||||
) -> CurrentRoundResponse:
|
||||
config = await get_round_config(session)
|
||||
round_ = await get_active_round(session)
|
||||
listener = request.app.state.electrum_listener
|
||||
chain_tip_height = listener.tip_height or None
|
||||
if round_ is None:
|
||||
await session.commit()
|
||||
return CurrentRoundResponse(
|
||||
server_time=datetime.now(timezone.utc).isoformat(),
|
||||
bet_amount_sats=config.bet_amount_sats,
|
||||
draw_animation_seconds=config.draw_animation_seconds,
|
||||
chain_tip_height=chain_tip_height,
|
||||
lottery_paused=config.paused,
|
||||
)
|
||||
|
||||
participant_count = await session.scalar(
|
||||
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
|
||||
) or 0
|
||||
# The pool is the sum of what the participants' bets actually paid into the pool
|
||||
# address — each one is already net of that bet's network fee. Deriving it from
|
||||
# participant_count * the *current* bet_amount_sats instead overstated it, and
|
||||
# silently changed the advertised jackpot of a round in progress whenever an
|
||||
# operator edited the bet amount (B-11).
|
||||
pool_amount_sats = await session.scalar(
|
||||
select(func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0)).where(
|
||||
RoundParticipant.round_id == round_.id
|
||||
)
|
||||
) or 0
|
||||
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
|
||||
closes_at = opened_at + timedelta(seconds=config.round_duration_seconds)
|
||||
|
||||
# Lets the frontend show the personalized win/lose reveal only to players in
|
||||
# this round — everyone else (not logged in, or logged in but didn't bet)
|
||||
# just sees the generic phase progress instead of a "non hai vinto" that
|
||||
# wouldn't mean anything to them.
|
||||
user_played = False
|
||||
if user is not None:
|
||||
user_played = (
|
||||
await session.scalar(
|
||||
select(RoundParticipant).where(
|
||||
RoundParticipant.round_id == round_.id, RoundParticipant.user_id == user.id
|
||||
)
|
||||
)
|
||||
) is not None
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Shown to players as "jackpot": the winner's 70% share of the pool (same split
|
||||
# rounds/scheduler.py applies at payout time), not the full pool. It remains an
|
||||
# upper bound by the payout tx's own fee, which is deducted from the winner's
|
||||
# share and isn't knowable until the payout is built — a few hundred sat on a
|
||||
# 1 sat/vB payout, i.e. invisible at PLM amounts, but it is not exact.
|
||||
jackpot_sats = winner_share(pool_amount_sats)
|
||||
|
||||
return CurrentRoundResponse(
|
||||
server_time=datetime.now(timezone.utc).isoformat(),
|
||||
round_id=round_.id,
|
||||
status=round_.status,
|
||||
opened_at=opened_at.isoformat(),
|
||||
closes_at=closes_at.isoformat(),
|
||||
participant_count=participant_count,
|
||||
bet_amount_sats=config.bet_amount_sats,
|
||||
jackpot_sats=jackpot_sats,
|
||||
draw_animation_seconds=config.draw_animation_seconds,
|
||||
winner_user_id=round_.winner_user_id,
|
||||
winner_amount_sats=round_.winner_amount_sats,
|
||||
draw_block_height=round_.draw_block_height,
|
||||
draw_block_hash=round_.draw_block_hash,
|
||||
draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None,
|
||||
chain_tip_height=chain_tip_height,
|
||||
lottery_paused=config.paused,
|
||||
user_played=user_played,
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import http_error
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
|
||||
from app.db.models import Round, RoundParticipant, User
|
||||
from app.db.session import get_session
|
||||
from app.wallet.balance import compute_pending_balance
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
address: str
|
||||
balance_sats: int
|
||||
pending_balance_sats: int
|
||||
has_pending: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MeResponse:
|
||||
pending_balance_sats, has_pending = await compute_pending_balance(session, user)
|
||||
return MeResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
address=user.address,
|
||||
balance_sats=user.cached_balance_sats,
|
||||
pending_balance_sats=pending_balance_sats,
|
||||
has_pending=has_pending,
|
||||
created_at=isoformat_utc(user.created_at),
|
||||
)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class ChangePasswordResponse(BaseModel):
|
||||
access_token: str
|
||||
|
||||
|
||||
@router.post("/me/change-password", response_model=ChangePasswordResponse)
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ChangePasswordResponse:
|
||||
"""Self-service password change — requires the current password, unlike the
|
||||
admin-only /admin/users/{id}/reset-password (which is for a user who's
|
||||
actually locked out and can't provide it)."""
|
||||
if not verify_password(body.current_password, user.password_hash):
|
||||
raise http_error(
|
||||
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
|
||||
)
|
||||
if len(body.new_password) < MIN_PASSWORD_LENGTH:
|
||||
raise http_error(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"password_too_short",
|
||||
f"new password must be at least {MIN_PASSWORD_LENGTH} characters",
|
||||
minimum=MIN_PASSWORD_LENGTH,
|
||||
)
|
||||
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
# B-34: bumping token_version invalidates every token issued before this
|
||||
# point — including this very request's own bearer token, and any an
|
||||
# attacker who knew the old password might be holding. A fresh token is
|
||||
# handed back so *this* session keeps working without forcing a re-login;
|
||||
# every other open session (this user's other devices, or an attacker's)
|
||||
# gets "session_expired" on its next request.
|
||||
user.token_version += 1
|
||||
await session.commit()
|
||||
return ChangePasswordResponse(access_token=create_access_token(user.id, user.token_version))
|
||||
|
||||
|
||||
class LastRoundResultResponse(BaseModel):
|
||||
round_id: int | None = None
|
||||
won: bool = False
|
||||
amount_sats: int | None = None
|
||||
|
||||
|
||||
@router.get("/me/last-round-result", response_model=LastRoundResultResponse)
|
||||
async def last_round_result(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LastRoundResultResponse:
|
||||
"""The most recent *closed* round this user participated in, with its outcome.
|
||||
|
||||
Deliberately independent of /rounds/current: that endpoint only exposes
|
||||
winner_user_id while the round is "paying_out", and drops it entirely once
|
||||
the round flips to "closed" (see rounds/service.get_active_round). A client
|
||||
that misses that narrow window (backgrounded tab, missed poll, page loaded
|
||||
late) would otherwise never learn the outcome of a round it bet in. This
|
||||
endpoint reads the durable DB record instead, so the frontend can always
|
||||
catch up regardless of polling timing."""
|
||||
row = await session.execute(
|
||||
select(Round)
|
||||
.join(RoundParticipant, RoundParticipant.round_id == Round.id)
|
||||
.where(RoundParticipant.user_id == user.id, Round.status == "closed")
|
||||
.order_by(Round.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
round_ = row.scalar_one_or_none()
|
||||
if round_ is None:
|
||||
return LastRoundResultResponse()
|
||||
|
||||
won = round_.winner_user_id == user.id
|
||||
return LastRoundResultResponse(
|
||||
round_id=round_.id,
|
||||
won=won,
|
||||
amount_sats=round_.winner_amount_sats if won else None,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import from_api_error, http_error
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.db.models import User
|
||||
from app.db.session import get_session
|
||||
from app.withdrawals.service import WithdrawalError, request_withdrawal
|
||||
|
||||
router = APIRouter(prefix="/withdrawals", tags=["withdrawals"])
|
||||
|
||||
|
||||
class WithdrawalRequest(BaseModel):
|
||||
external_address: str
|
||||
amount_sats: int
|
||||
|
||||
|
||||
class WithdrawalResponse(BaseModel):
|
||||
txid: str
|
||||
amount_requested_sats: int
|
||||
amount_sent_sats: int
|
||||
status: str
|
||||
|
||||
|
||||
@router.post("", response_model=WithdrawalResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_withdrawal(
|
||||
body: WithdrawalRequest,
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WithdrawalResponse:
|
||||
listener = request.app.state.electrum_listener
|
||||
if listener.client is None:
|
||||
raise http_error(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"network_unavailable",
|
||||
"not connected to the network, try again shortly",
|
||||
)
|
||||
|
||||
async with request.app.state.user_locks.acquire(user.id):
|
||||
try:
|
||||
withdrawal = await request_withdrawal(
|
||||
session, listener.client, user, body.external_address, body.amount_sats
|
||||
)
|
||||
except WithdrawalError as exc:
|
||||
code = (
|
||||
status.HTTP_502_BAD_GATEWAY # the network refused it, not the caller (B-07)
|
||||
if exc.code == "broadcast_failed"
|
||||
else status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
raise from_api_error(code, exc) from exc
|
||||
|
||||
return WithdrawalResponse(
|
||||
txid=withdrawal.txid,
|
||||
amount_requested_sats=withdrawal.amount_requested_sats,
|
||||
amount_sent_sats=withdrawal.amount_sent_sats,
|
||||
status=withdrawal.status,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def isoformat_utc(dt: datetime | None) -> str | None:
|
||||
"""Serialize a datetime for API responses, stamping it UTC first.
|
||||
|
||||
Every DateTime column is written via app.db.models.utcnow() but SQLite/aiosqlite
|
||||
round-trips it as a naive datetime, so a bare .isoformat() drops the "Z"/offset
|
||||
and JavaScript's `new Date()` on the frontend parses the result as local time
|
||||
instead of UTC (B-35). All stored values are UTC in practice, so a naive value
|
||||
can be safely stamped rather than converted.
|
||||
"""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.isoformat()
|
||||
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import AuditLog
|
||||
|
||||
|
||||
async def write_audit_log(
|
||||
session: AsyncSession,
|
||||
event_type: str,
|
||||
payload: dict,
|
||||
user_id: int | None = None,
|
||||
round_id: int | None = None,
|
||||
) -> None:
|
||||
session.add(
|
||||
AuditLog(
|
||||
event_type=event_type,
|
||||
payload_json=json.dumps(payload),
|
||||
user_id=user_id,
|
||||
round_id=round_id,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
from fastapi import Depends, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import http_error
|
||||
from app.auth.security import decode_access_token
|
||||
from app.db.models import User
|
||||
from app.db.session import get_session
|
||||
|
||||
_bearer = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User:
|
||||
try:
|
||||
user_id, token_version = decode_access_token(credentials.credentials)
|
||||
except Exception as exc:
|
||||
raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "invalid token") from exc
|
||||
|
||||
user = await session.scalar(select(User).where(User.id == user_id))
|
||||
if user is None:
|
||||
raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "user not found")
|
||||
if user.token_version != token_version:
|
||||
# B-34: a password change (self-service or admin reset) bumps
|
||||
# token_version, so a token issued before it — including one an
|
||||
# attacker who had the old password is still holding — reads as
|
||||
# expired rather than staying valid until it naturally times out.
|
||||
raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "token has been superseded")
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User | None:
|
||||
"""Like get_current_user, but for endpoints reachable both logged-out and
|
||||
logged-in (e.g. /rounds/current) that need to personalize their response
|
||||
*if* the caller happens to be authenticated, without requiring it."""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
try:
|
||||
user_id, token_version = decode_access_token(auth_header.removeprefix("Bearer "))
|
||||
except Exception:
|
||||
return None
|
||||
user = await session.scalar(select(User).where(User.id == user_id))
|
||||
if user is None or user.token_version != token_version:
|
||||
return None
|
||||
return user
|
||||
@@ -0,0 +1,75 @@
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Bucket:
|
||||
failures: int = 0
|
||||
locked_until: float = 0.0
|
||||
last_failure_at: float = 0.0
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""In-process failed-attempt throttle with exponential backoff, keyed by an
|
||||
arbitrary string (username, IP...). Single-process-only, like UserLocks
|
||||
(app/tx/locks.py) — an accepted MVP constraint; a multi-worker deployment
|
||||
would need a shared store (Redis) instead (B-33).
|
||||
|
||||
Brute-forcing a login here isn't a spammy client to be capped at N req/s —
|
||||
it's an attempt to withdraw someone else's funds — so failures are
|
||||
penalized with a delay that doubles each time past `threshold` free
|
||||
attempts, rather than a flat rate cap. `decay_seconds` ages a bucket back
|
||||
to zero once failures stop, so a shared/NAT IP isn't punished forever for
|
||||
someone else's earlier mistakes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
threshold: int = 5,
|
||||
base_delay: float = 2.0,
|
||||
max_delay: float = 300.0,
|
||||
decay_seconds: float = 900.0,
|
||||
) -> None:
|
||||
self._threshold = threshold
|
||||
self._base_delay = base_delay
|
||||
self._max_delay = max_delay
|
||||
self._decay_seconds = decay_seconds
|
||||
self._buckets: dict[str, _Bucket] = {}
|
||||
|
||||
def retry_after(self, key: str) -> float:
|
||||
bucket = self._buckets.get(key)
|
||||
if bucket is None:
|
||||
return 0.0
|
||||
remaining = bucket.locked_until - time.monotonic()
|
||||
return remaining if remaining > 0 else 0.0
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
bucket = self._buckets.setdefault(key, _Bucket())
|
||||
if bucket.failures and now - bucket.last_failure_at > self._decay_seconds:
|
||||
bucket.failures = 0
|
||||
bucket.failures += 1
|
||||
bucket.last_failure_at = now
|
||||
if bucket.failures >= self._threshold:
|
||||
delay = min(self._max_delay, self._base_delay * 2 ** (bucket.failures - self._threshold))
|
||||
bucket.locked_until = now + delay
|
||||
|
||||
def record_success(self, key: str) -> None:
|
||||
self._buckets.pop(key, None)
|
||||
|
||||
|
||||
class AuthRateLimiters:
|
||||
"""The three throttles B-33 needs, bundled so they can live on `app.state`
|
||||
(like `UserLocks`, see app/tx/locks.py) rather than as module globals.
|
||||
|
||||
A module global would persist for the lifetime of the process — fine in
|
||||
production (one app instance), but wrong in the test suite, where every
|
||||
test builds its own FastAPI app against a fresh in-memory DB and expects a
|
||||
clean slate; a shared global would leak failure counts between unrelated
|
||||
tests. Per-`app.state` state gets a fresh instance per app automatically.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.login = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0)
|
||||
self.login_ip = RateLimiter(threshold=20, base_delay=2.0, max_delay=300.0)
|
||||
self.register_ip = RateLimiter(threshold=5, base_delay=5.0, max_delay=600.0)
|
||||
@@ -0,0 +1,146 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.client_ip import client_ip as _client_ip
|
||||
from app.api.errors import http_error
|
||||
from app.auth.rate_limit import AuthRateLimiters
|
||||
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
|
||||
from app.db.models import User
|
||||
from app.db.session import get_session
|
||||
from app.wallet.hd import derive_user_address
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
_MAX_REGISTER_RETRIES = 5
|
||||
|
||||
|
||||
def _rate_limiters(request: Request) -> AuthRateLimiters:
|
||||
# B-33: no rate limiting on login was a brute-forceable path to withdrawing
|
||||
# someone else's funds. Keyed per-username *and* per-IP so an attacker can't
|
||||
# dodge the throttle by spraying one password across many accounts, nor by
|
||||
# routing one account's guesses through many IPs alone (the username key
|
||||
# still catches that). The IP limiter's threshold is deliberately higher
|
||||
# than the username one: a single account should lock out fast, but a
|
||||
# shared/NAT IP hosting several genuine users shouldn't be punished for one
|
||||
# of them mistyping a password a few times. Registration gets its own,
|
||||
# coarser limiter, IP-only — no username exists yet to key on — mainly to
|
||||
# bound how many accounts one IP can spin up (B-31), not to protect a
|
||||
# secret. Lives on app.state (see AuthRateLimiters) rather than a module
|
||||
# global so each app instance gets its own, isolated throttle state.
|
||||
if not hasattr(request.app.state, "auth_rate_limiters"):
|
||||
request.app.state.auth_rate_limiters = AuthRateLimiters()
|
||||
return request.app.state.auth_rate_limiters
|
||||
|
||||
|
||||
def _rate_limited_error(retry_after: float):
|
||||
return http_error(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"rate_limited",
|
||||
"too many attempts, try again later",
|
||||
retry_after_seconds=int(retry_after) + 1,
|
||||
)
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
"""Registration used to accept an empty username and a one-character password,
|
||||
while /users/me/change-password demanded 8 characters — an odd place to be
|
||||
lenient on a custodial system holding real funds (B-12). MIN_PASSWORD_LENGTH is
|
||||
shared with that endpoint so the two can't drift apart again."""
|
||||
|
||||
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_.-]+$")
|
||||
password: str = Field(min_length=MIN_PASSWORD_LENGTH, max_length=256)
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
address: str
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session)
|
||||
) -> TokenResponse:
|
||||
limiters = _rate_limiters(request)
|
||||
ip_key = f"ip:{_client_ip(request)}"
|
||||
retry_after = limiters.register_ip.retry_after(ip_key)
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
limiters.register_ip.record_failure(ip_key)
|
||||
|
||||
existing = await session.scalar(select(User).where(User.username == body.username))
|
||||
if existing is not None:
|
||||
raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken")
|
||||
|
||||
password_hash = hash_password(body.password)
|
||||
|
||||
for _ in range(_MAX_REGISTER_RETRIES):
|
||||
max_index = await session.scalar(select(func.max(User.derivation_index)))
|
||||
next_index = 0 if max_index is None else max_index + 1
|
||||
address = derive_user_address(next_index)
|
||||
user = User(
|
||||
username=body.username,
|
||||
password_hash=password_hash,
|
||||
derivation_index=next_index,
|
||||
address=address,
|
||||
)
|
||||
session.add(user)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
# Only a derivation-index collision is worth retrying. A username
|
||||
# collision (someone registered the same name between the check above and
|
||||
# this commit) is permanent, and retrying it five times only to report
|
||||
# "derivation_index_conflict" told the user the wrong thing entirely (B-12).
|
||||
if "username" in str(exc.orig).lower():
|
||||
raise http_error(
|
||||
status.HTTP_409_CONFLICT, "username_taken", "username already taken"
|
||||
) from exc
|
||||
continue
|
||||
await session.refresh(user)
|
||||
request.app.state.electrum_listener.address_for_new_user(user.id, user.address)
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.token_version), address=user.address
|
||||
)
|
||||
|
||||
raise http_error(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"derivation_index_conflict",
|
||||
"could not allocate a derivation index, retry",
|
||||
)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
body: LoginRequest, request: Request, session: AsyncSession = Depends(get_session)
|
||||
) -> TokenResponse:
|
||||
limiters = _rate_limiters(request)
|
||||
username_key = f"user:{body.username.lower()}"
|
||||
ip_key = f"ip:{_client_ip(request)}"
|
||||
retry_after = max(limiters.login.retry_after(username_key), limiters.login_ip.retry_after(ip_key))
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
|
||||
user = await session.scalar(select(User).where(User.username == body.username))
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
# Same code path (and therefore the same response) whether the username
|
||||
# doesn't exist or the password is wrong — no enumeration oracle here.
|
||||
limiters.login.record_failure(username_key)
|
||||
limiters.login_ip.record_failure(ip_key)
|
||||
raise http_error(status.HTTP_401_UNAUTHORIZED, "invalid_credentials", "invalid credentials")
|
||||
|
||||
# Only the username bucket resets on success — the IP bucket is left to decay
|
||||
# on its own, so one correct login can't be used to wipe out an IP's failure
|
||||
# count while it's mid-attack against other accounts.
|
||||
limiters.login.record_success(username_key)
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.token_version), address=user.address
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import logging
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerificationError
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Shared by registration (app/auth/routes.py) and the self-service password change
|
||||
# (app/api/routes/users.py) so the two can't enforce different minimums.
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Any failure to verify reads as "wrong password", never as a server error.
|
||||
|
||||
Catching only VerifyMismatchError left the other two cases as unhandled 500s
|
||||
(B-13): VerificationError covers argon2's other verification failures, and
|
||||
InvalidHashError fires when the stored hash can't be parsed at all — which is a
|
||||
data problem worth logging, but from the caller's side it still just means this
|
||||
password does not open this account.
|
||||
"""
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except InvalidHashError:
|
||||
logger.error("stored password hash is unparseable — password verification cannot succeed")
|
||||
return False
|
||||
except VerificationError:
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(user_id: int, token_version: int = 0) -> str:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
# "tv" lets get_current_user (app/auth/dependencies.py) reject a token issued
|
||||
# before the account's password was last changed (B-34): change-password and
|
||||
# the admin reset both bump User.token_version, so every token that still
|
||||
# carries the old value stops working immediately instead of staying valid
|
||||
# for up to jwt_expire_minutes after a compromise is supposedly handled.
|
||||
payload = {"sub": str(user_id), "tv": token_version, "exp": expires_at}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> tuple[int, int]:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
# .get(..., 0) covers tokens issued before "tv" existed (pre-B-34 deploy) —
|
||||
# they carry no claim at all, and 0 is what a freshly migrated user's
|
||||
# token_version starts at, so those sessions keep working across the deploy.
|
||||
return int(payload["sub"]), int(payload.get("tv", 0))
|
||||
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import PendingTransaction, RoundParticipant
|
||||
from app.tx.confirmation import register_handler
|
||||
|
||||
|
||||
async def _on_bet_confirmed(session: AsyncSession, pending: PendingTransaction) -> None:
|
||||
"""Resolved by (round_id, user_id) — the pair is unique per participant and,
|
||||
unlike the txid, cannot change under us. Keying this on bet_txid meant an
|
||||
RBF-bumped bet confirmed under a txid no participant carried, so the row stayed
|
||||
"broadcast" forever and the round could never close (B-02). The txid is kept in
|
||||
step by tx/broadcast.py too, but correctness here no longer depends on it."""
|
||||
participant = await session.scalar(
|
||||
select(RoundParticipant).where(
|
||||
RoundParticipant.round_id == pending.round_id,
|
||||
RoundParticipant.user_id == pending.user_id,
|
||||
)
|
||||
)
|
||||
if participant is None:
|
||||
# Fall back to the txid for rows written before this changed, and for any
|
||||
# pending row missing its round/user link.
|
||||
participant = await session.scalar(
|
||||
select(RoundParticipant).where(RoundParticipant.bet_txid == pending.current_txid)
|
||||
)
|
||||
if participant is not None and participant.status in ("building", "broadcast"):
|
||||
participant.status = "confirmed"
|
||||
participant.confirmed_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
register_handler("bet", _on_bet_confirmed)
|
||||
@@ -0,0 +1,169 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from embit import script
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
|
||||
from app.wallet.balance import recompute_balance
|
||||
from app.wallet.hd import derive_pool_address, derive_user_key
|
||||
from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction
|
||||
|
||||
|
||||
class BetError(ApiError):
|
||||
pass
|
||||
|
||||
|
||||
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
if round_ is None:
|
||||
raise BetError("no_round_open", "no round open right now, please try again shortly")
|
||||
|
||||
config = await get_round_config(session)
|
||||
if not round_accepts_bets(round_, config.round_duration_seconds):
|
||||
raise BetError("round_closing", "the current round is closing, please try again shortly")
|
||||
|
||||
already_playing = await session.scalar(
|
||||
select(RoundParticipant).where(
|
||||
RoundParticipant.round_id == round_.id, RoundParticipant.user_id == user.id
|
||||
)
|
||||
)
|
||||
if already_playing is not None:
|
||||
raise BetError("already_betting", "you already have an active bet in the current round")
|
||||
|
||||
bet_amount = config.bet_amount_sats
|
||||
|
||||
unspent = (
|
||||
await session.scalars(
|
||||
select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None))
|
||||
)
|
||||
).all()
|
||||
if sum(u.amount_sats for u in unspent) < bet_amount:
|
||||
raise BetError("insufficient_balance", "insufficient balance", required_sats=bet_amount)
|
||||
|
||||
user_key = derive_user_key(user.derivation_index)
|
||||
from_script = script.p2wpkh(user_key.to_public())
|
||||
utxos = [Utxo(u.txid, u.vout, u.amount_sats) for u in unspent]
|
||||
|
||||
try:
|
||||
built = build_signed_transaction(
|
||||
signing_key=user_key,
|
||||
from_script=from_script,
|
||||
utxos=utxos,
|
||||
to_address=derive_pool_address(),
|
||||
amount_sats=bet_amount,
|
||||
change_address=user.address,
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
)
|
||||
except InsufficientFundsError as exc:
|
||||
raise BetError(exc.code, str(exc), **exc.params) from exc
|
||||
|
||||
# --- Phase 1: record the intent, *then* broadcast (B-08) --------------------
|
||||
# Broadcasting first meant a failure (or a crash) between the broadcast and the
|
||||
# commit left the coins irreversibly spent on-chain with no trace in the DB: no
|
||||
# participant, so no entry in the draw; no pending row, so no RBF and no
|
||||
# confirmation tracking; and the UTXOs not even marked spent, so the next bet
|
||||
# would try to double-spend them. Writing "building" rows first means the worst
|
||||
# case is a row the reconciler (app/tx/reconcile.py) can resolve either way by
|
||||
# asking the chain whether the tx exists.
|
||||
spent_by_key = {(u.txid, u.vout): u for u in unspent}
|
||||
for spent in built.spent_utxos:
|
||||
spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid
|
||||
await recompute_balance(session, user.id)
|
||||
|
||||
broadcast_at = datetime.now(timezone.utc)
|
||||
participant = RoundParticipant(
|
||||
round_id=round_.id,
|
||||
user_id=user.id,
|
||||
bet_amount_sats=built.recipient_sats,
|
||||
bet_txid=built.txid,
|
||||
broadcast_at=broadcast_at,
|
||||
status="building",
|
||||
)
|
||||
session.add(participant)
|
||||
pending = _pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
|
||||
# --- Phase 2: broadcast, then promote both rows to their live state ---------
|
||||
try:
|
||||
await client.broadcast(built.raw_hex)
|
||||
except Exception as exc:
|
||||
# The node refused it (fee too low, dust, mempool conflict, or simply an
|
||||
# unreachable server) — nothing is on-chain, so undo phase 1 completely and
|
||||
# give the user a translatable failure instead of a bare 500 (B-07).
|
||||
await _release_failed_bet(session, participant, pending, built, user.id, str(exc))
|
||||
raise BetError("broadcast_failed", f"the network refused the transaction: {exc}") from exc
|
||||
|
||||
participant.status = "broadcast"
|
||||
pending.status = "pending"
|
||||
await write_audit_log(
|
||||
session,
|
||||
"bet_placed",
|
||||
{"txid": built.txid, "amount_sats": built.recipient_sats},
|
||||
user_id=user.id,
|
||||
round_id=round_.id,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(participant)
|
||||
broadcaster.publish() # participant_count/jackpot changed — nudge every dashboard to refetch
|
||||
return participant
|
||||
|
||||
|
||||
async def _release_failed_bet(
|
||||
session: AsyncSession,
|
||||
participant: RoundParticipant,
|
||||
pending: PendingTransaction,
|
||||
built: BuiltTransaction,
|
||||
user_id: int,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Undo phase 1 after a failed broadcast: free the UTXOs the build reserved, drop
|
||||
the two rows, and restore the balance. Same shape as what the reconciler does for
|
||||
a tx that turns out never to have made it onto the chain."""
|
||||
for spent in built.spent_utxos:
|
||||
row = await session.scalar(
|
||||
select(UtxoEvent).where(UtxoEvent.txid == spent.txid, UtxoEvent.vout == spent.vout)
|
||||
)
|
||||
if row is not None:
|
||||
row.spent_txid = None
|
||||
await session.delete(participant)
|
||||
await session.delete(pending)
|
||||
await recompute_balance(session, user_id)
|
||||
await write_audit_log(
|
||||
session,
|
||||
"bet_broadcast_failed",
|
||||
{"txid": built.txid, "reason": reason[:200]},
|
||||
user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
# The rollback moved as much state as the successful path did — the balance is
|
||||
# back, the participant is gone, so participant_count and jackpot shrank again.
|
||||
# Without this the dashboards kept showing the phantom bet until their next poll
|
||||
# (B-49); the reconciler's own abandon path has always published here.
|
||||
broadcaster.publish()
|
||||
|
||||
|
||||
def _pending_transaction(
|
||||
round_id: int, user_id: int, built: BuiltTransaction, fee_rate_sat_vb: int
|
||||
) -> PendingTransaction:
|
||||
return PendingTransaction(
|
||||
kind="bet",
|
||||
round_id=round_id,
|
||||
user_id=user_id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=fee_rate_sat_vb,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
# "building" until the broadcast succeeds — see place_bet's two phases. It
|
||||
# matters which one this starts as: the reconciler gives a "building" row a
|
||||
# short grace period (we may have died mid-broadcast) and a "pending" one a
|
||||
# long one (a node accepted it once, so it deserves the RBF attempts first).
|
||||
status="building",
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Minimum JWT signing key length. HS256 keys shorter than the hash output weaken
|
||||
# the MAC, and PyJWT warns about it — enforced here so it fails at startup rather
|
||||
# than being shipped by accident.
|
||||
MIN_JWT_SECRET_LENGTH = 32
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
database_url: str = "sqlite+aiosqlite:///./plm_lottery.db"
|
||||
|
||||
electrum_host: str = "santantonio.sytes.net"
|
||||
electrum_port: int = 50002
|
||||
electrum_use_ssl: bool = True
|
||||
# Additional servers to fall back to, comma-separated `host:port[:notls]`.
|
||||
# The listener rotates over primary + these (app/electrum/listener.py), so one
|
||||
# unreachable server costs a single reconnect attempt instead of an outage:
|
||||
# every deposit credit, broadcast and confirmation goes through this one
|
||||
# connection, which makes a single hardcoded server the platform's biggest
|
||||
# single point of failure. Parsed by electrum.client.parse_endpoints.
|
||||
electrum_fallback_servers: str = ""
|
||||
|
||||
xprv_encryption_key: str = ""
|
||||
master_key_path: str = "./master.xprv.enc"
|
||||
jwt_secret: str = ""
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_minutes: int = 60 * 24
|
||||
admin_token: str = ""
|
||||
|
||||
# Swagger/ReDoc/OpenAPI JSON expose the entire API surface (admin endpoints
|
||||
# included) to anyone who requests them. Off by default (B-42) — set to true
|
||||
# only for local development, never in production.
|
||||
enable_api_docs: bool = False
|
||||
|
||||
# Every business/round parameter (bet amount, round duration/cooldown,
|
||||
# min amount, fee rate, RBF timeout, fee address) lives in the round_config
|
||||
# DB table instead (app/db/models.py RoundConfig, app/rounds/config.py) —
|
||||
# editable live via the admin panel/API, no env var, no restart. Only true
|
||||
# infra/secrets belong in this Settings class.
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""A misconfiguration serious enough that the app must refuse to serve."""
|
||||
|
||||
|
||||
def validate_runtime_secrets(config: Settings | None = None) -> None:
|
||||
"""Fail fast on secrets that would otherwise only break at first use: an empty
|
||||
jwt_secret makes PyJWT raise InvalidKeyError on every login, and an empty
|
||||
xprv_encryption_key makes Fernet fail on the first key derivation. Either way
|
||||
the container comes up looking healthy and breaks the moment a real user
|
||||
touches it.
|
||||
|
||||
Called from the app's lifespan (app/main.py) rather than as a Settings
|
||||
field_validator on purpose: Settings is constructed at import time by every
|
||||
module that reads config, including the test suite, which has no .env and no
|
||||
business holding real secrets. At startup the guarantee still holds where it
|
||||
matters — the server refuses to serve half-configured — without coupling every
|
||||
import to a gitignored file.
|
||||
|
||||
ADMIN_TOKEN is deliberately not fatal: require_admin already denies every
|
||||
request when it's empty, so the effect is a locked admin panel, not an open one.
|
||||
"""
|
||||
config = config or settings
|
||||
problems = []
|
||||
if len(config.jwt_secret) < MIN_JWT_SECRET_LENGTH:
|
||||
problems.append(
|
||||
f"JWT_SECRET must be at least {MIN_JWT_SECRET_LENGTH} characters "
|
||||
'(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")'
|
||||
)
|
||||
if not config.xprv_encryption_key.strip():
|
||||
problems.append(
|
||||
"XPRV_ENCRYPTION_KEY must be set (generate: python -c "
|
||||
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
|
||||
)
|
||||
if problems:
|
||||
raise ConfigError("invalid configuration in .env: " + "; ".join(problems))
|
||||
@@ -0,0 +1,46 @@
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# How long a writer waits for a lock held by another writer before SQLite raises
|
||||
# "database is locked" (B-39). A few seconds is enough to ride out this app's own
|
||||
# five concurrent background tasks (scheduler, confirmation poller, RBF bumper,
|
||||
# two reconcilers) plus HTTP handlers briefly overlapping a write.
|
||||
_SQLITE_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
|
||||
def _register_sqlite_pragmas(target_engine: AsyncEngine) -> None:
|
||||
"""Without WAL, SQLite's default (rollback-journal) mode lets a writer block
|
||||
every reader for the duration of its transaction, and a second writer arriving
|
||||
while one is already active fails immediately rather than waiting at all —
|
||||
realistic under this app's concurrency, and nothing previously handled it.
|
||||
WAL lets readers and writers proceed without blocking each other, and
|
||||
busy_timeout gives a second writer a real window to wait for the first
|
||||
instead of an instant `OperationalError`.
|
||||
|
||||
No-op for any dialect other than sqlite (e.g. a future PostgreSQL
|
||||
DATABASE_URL), which neither needs nor understands these pragmas.
|
||||
"""
|
||||
if target_engine.dialect.name != "sqlite":
|
||||
return
|
||||
|
||||
@event.listens_for(target_engine.sync_engine, "connect")
|
||||
def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
engine = create_async_engine(settings.database_url)
|
||||
_register_sqlite_pragmas(engine)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -0,0 +1,217 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import BigInteger, ForeignKey, Index, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(256))
|
||||
derivation_index: Mapped[int] = mapped_column(unique=True)
|
||||
address: Mapped[str] = mapped_column(String(128), unique=True)
|
||||
# Read cache only; must always be written in the same transaction as the
|
||||
# utxo_events rows it summarizes. Source of truth is utxo_events.
|
||||
cached_balance_sats: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||
# Embedded in every issued JWT (app/auth/security.py) and checked on every
|
||||
# request (app/auth/dependencies.py:get_current_user). Bumped on a
|
||||
# self-service or admin password change so every token issued before that
|
||||
# point stops working immediately, instead of staying valid for up to
|
||||
# jwt_expire_minutes after a compromised account's password is reset (B-34).
|
||||
token_version: Mapped[int] = mapped_column(default=0, server_default="0")
|
||||
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
|
||||
|
||||
class UtxoEvent(Base):
|
||||
__tablename__ = "utxo_events"
|
||||
__table_args__ = (UniqueConstraint("txid", "vout"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
txid: Mapped[str] = mapped_column(String(64))
|
||||
vout: Mapped[int]
|
||||
amount_sats: Mapped[int] = mapped_column(BigInteger)
|
||||
confirmed_height: Mapped[int]
|
||||
confirmed_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
# Set once this UTXO is consumed by an outgoing bet/withdrawal build.
|
||||
spent_txid: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
|
||||
|
||||
_ACTIVE_ROUND_STATUSES_SQL = "'open', 'closing', 'drawing', 'paying_out'"
|
||||
|
||||
|
||||
class Round(Base):
|
||||
__tablename__ = "rounds"
|
||||
|
||||
# At most one round may be active at a time. Rounds never overlap by design,
|
||||
# but that was enforced only by a read-then-insert in
|
||||
# rounds/service.open_new_round_if_needed, which two concurrent callers can
|
||||
# both pass — and a second stuck "open" row blocks every future round forever
|
||||
# (B-09). This is the database-level guarantee: a unique index over a constant
|
||||
# expression, restricted to the active statuses, so the table can hold any
|
||||
# number of closed rounds and only ever one live one.
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_rounds_single_active",
|
||||
text("(1)"),
|
||||
unique=True,
|
||||
sqlite_where=text(f"status IN ({_ACTIVE_ROUND_STATUSES_SQL})"),
|
||||
postgresql_where=text(f"status IN ({_ACTIVE_ROUND_STATUSES_SQL})"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="open")
|
||||
opened_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
# Set once, when status flips to "drawing" (rounds/scheduler.py:_close_and_draw).
|
||||
# Lets both the audit log (B-36's draw_stalled entries) and GET /rounds/current
|
||||
# (draw_waiting_since) measure how long a round has been waiting on a block,
|
||||
# since that wait has no timeout of its own — see _wait_for_next_block.
|
||||
drawing_started_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
draw_block_height: Mapped[int | None] = mapped_column(default=None)
|
||||
draw_block_hash: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
seed_int: Mapped[str | None] = mapped_column(String(128), default=None)
|
||||
winner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
|
||||
pool_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
|
||||
winner_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
|
||||
fee_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
|
||||
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
|
||||
|
||||
class RoundParticipant(Base):
|
||||
__tablename__ = "round_participants"
|
||||
__table_args__ = (UniqueConstraint("round_id", "user_id"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
round_id: Mapped[int] = mapped_column(ForeignKey("rounds.id"), index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
bet_amount_sats: Mapped[int] = mapped_column(BigInteger)
|
||||
bet_txid: Mapped[str] = mapped_column(String(64))
|
||||
# Ordering / tie-break field per spec: broadcast time, not confirmation time.
|
||||
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
status: Mapped[str] = mapped_column(String(16), default="broadcast")
|
||||
|
||||
|
||||
class RoundConfig(Base):
|
||||
"""Single-row operational config, DB-backed so it's editable without a redeploy.
|
||||
|
||||
Everything business/round-related lives here (round timing, bet amount, fee
|
||||
rate, RBF timeout) so an operator can tune it live. Secrets
|
||||
and infra wiring (master key, JWT secret, Electrum host, admin token,
|
||||
database URL) deliberately stay env-var-driven — those require a restart
|
||||
anyway and aren't safe to hot-swap."""
|
||||
|
||||
__tablename__ = "round_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
fee_address: Mapped[str] = mapped_column(String(128))
|
||||
bet_amount_sats: Mapped[int] = mapped_column(BigInteger, default=1_000_000_000)
|
||||
round_duration_seconds: Mapped[int] = mapped_column(default=600)
|
||||
round_cooldown_seconds: Mapped[int] = mapped_column(default=30)
|
||||
# Purely a frontend cue: the minimum time the "estrazione in corso" animation
|
||||
# plays for on every user's dashboard before the winner can be revealed. Does
|
||||
# NOT gate the actual draw, which still waits for a real confirmed block for
|
||||
# its entropy (rounds/scheduler.py) — that can take longer than this value.
|
||||
draw_animation_seconds: Mapped[int] = mapped_column(default=20)
|
||||
fee_rate_sat_vb: Mapped[int] = mapped_column(default=1)
|
||||
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
|
||||
# Maintenance switch: when true, the round currently in progress still runs to
|
||||
# completion (closes, draws, pays out the winner) but no new round is opened
|
||||
# afterwards — see rounds/service.py:open_new_round_if_needed.
|
||||
paused: Mapped[bool] = mapped_column(default=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class PendingTransaction(Base):
|
||||
"""Single source of truth for the RBF timeout->bump->rebroadcast loop, and the
|
||||
row the reconciler (app/tx/reconcile.py) resolves against the chain.
|
||||
|
||||
Status lifecycle:
|
||||
building -> written before the tx is broadcast, so a crash between the two
|
||||
leaves evidence instead of a silently-spent UTXO set (B-08).
|
||||
pending -> broadcast, waiting for its 1st confirmation.
|
||||
confirmed -> terminal, set by app/tx/confirmation.py.
|
||||
failed -> terminal, set by the reconciler when the tx is gone from the
|
||||
chain for good; its UTXOs have been released by then.
|
||||
"""
|
||||
|
||||
__tablename__ = "pending_transactions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
kind: Mapped[str] = mapped_column(String(16)) # bet | payout | withdrawal
|
||||
round_id: Mapped[int | None] = mapped_column(ForeignKey("rounds.id"), default=None)
|
||||
withdrawal_id: Mapped[int | None] = mapped_column(ForeignKey("withdrawals.id"), default=None)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
|
||||
current_txid: Mapped[str] = mapped_column(String(64))
|
||||
fee_rate_sat_vb: Mapped[int]
|
||||
raw_tx_hex: Mapped[str] = mapped_column(Text)
|
||||
# The *first* broadcast — never rewritten by a bump — since this is what the
|
||||
# reconciler's abandon-after-N-hours grace period (app/tx/reconcile.py) measures
|
||||
# from. Bumping used to overwrite this field, which reset that clock on every
|
||||
# bump and meant a repeatedly-bumped-but-never-mined tx was never abandoned
|
||||
# (B-27). last_broadcast_at is the one bump_fee updates, and the one should_bump
|
||||
# (app/tx/broadcast.py) reads to decide whether another bump is due.
|
||||
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
last_broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
# The txid this row had *before* its most recent RBF bump (bump_fee rewrites
|
||||
# current_txid in place). Despite the name reading forwards, it points
|
||||
# backwards: current_txid is the replacement, this is what it replaced.
|
||||
replaced_by_txid: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
attempt_count: Mapped[int] = mapped_column(default=1)
|
||||
# Why the reconciler gave up on this tx — operator-facing, only set on "failed".
|
||||
failure_reason: Mapped[str | None] = mapped_column(String(128), default=None)
|
||||
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class Withdrawal(Base):
|
||||
__tablename__ = "withdrawals"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
external_address: Mapped[str] = mapped_column(String(128))
|
||||
amount_requested_sats: Mapped[int] = mapped_column(BigInteger)
|
||||
amount_sent_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
|
||||
txid: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
|
||||
|
||||
class BugReport(Base):
|
||||
__tablename__ = "bug_reports"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
contact: Mapped[str | None] = mapped_column(String(256), default=None)
|
||||
# Set when the reporter was logged in at submission time; the report page is
|
||||
# reachable both logged-in and logged-out (like GET /rounds/current), so this
|
||||
# stays nullable rather than requiring auth just to file a report.
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
|
||||
# open -> read -> resolved, admin-driven (app/api/routes/admin.py). "read" is a
|
||||
# distinct step from "resolved" so a reporter checking their own status (only
|
||||
# possible when logged in — see GET /bug-reports/mine) can tell "an admin has
|
||||
# seen this" apart from "this has actually been fixed".
|
||||
status: Mapped[str] = mapped_column(String(16), default="open")
|
||||
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
event_type: Mapped[str] = mapped_column(String(32))
|
||||
payload_json: Mapped[str] = mapped_column(Text)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
|
||||
round_id: Mapped[int | None] = mapped_column(ForeignKey("rounds.id"), default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
@@ -0,0 +1,10 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.base import AsyncSessionLocal
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Periodic safety net for deposit crediting and external-spend detection (B-30),
|
||||
independent of scripthash-change notifications.
|
||||
|
||||
Those notifications are the fast path, but nothing else re-verifies a user's
|
||||
balance against the chain if one is ever silently lost: `address_for_new_user`'s
|
||||
subscribe is best-effort (its own failure just logs, see electrum/listener.py),
|
||||
and on an otherwise healthy, long-lived connection there may be no reconnect for
|
||||
days — the only other event that re-subscribes everyone from scratch. Without
|
||||
this, a single lost subscription meant that user's deposits were never credited,
|
||||
indefinitely.
|
||||
|
||||
This mirrors app/tx/reconcile.py's shape (a periodic sweep gated on the Electrum
|
||||
client being connected) but reuses ElectrumListener.refresh_user directly rather
|
||||
than re-implementing crediting/spend-detection, so the notification-driven and
|
||||
periodic paths can never behave differently from each other.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.db.models import User
|
||||
from app.electrum.listener import ElectrumListener
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SWEEP_INTERVAL_SECONDS = 300
|
||||
|
||||
|
||||
class DepositReconciler:
|
||||
def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener):
|
||||
self._session_factory = session_factory
|
||||
self._listener = listener
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(_SWEEP_INTERVAL_SECONDS)
|
||||
if self._listener.client is None:
|
||||
continue
|
||||
try:
|
||||
await self._sweep_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("deposit reconciliation sweep failed")
|
||||
|
||||
async def _sweep_once(self) -> None:
|
||||
"""Round-robins over every user's address rather than only ones missing
|
||||
from the listener's in-memory `_scripthash_to_user` map: that map can't
|
||||
tell "never subscribed" apart from "subscribed, but this server silently
|
||||
stopped delivering notifications for it" — exactly the failure mode this
|
||||
exists to catch. One user failing (a transient network hiccup) must not
|
||||
stop the sweep from reaching the rest, mirroring poll_once's per-item
|
||||
isolation in tx/confirmation.py.
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
users = (await session.scalars(select(User))).all()
|
||||
|
||||
for user in users:
|
||||
if self._listener.client is None:
|
||||
return # connection dropped mid-sweep; the next reconnect's own _subscribe_all_users covers everyone
|
||||
scripthash = address_to_scripthash(user.address)
|
||||
try:
|
||||
await self._listener.refresh_user(user.id, scripthash)
|
||||
except Exception:
|
||||
logger.exception("deposit reconciliation failed for user_id=%s", user.id)
|
||||
@@ -0,0 +1,175 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.models import UtxoEvent
|
||||
from app.rounds.events import broadcaster
|
||||
from app.wallet.balance import recompute_balance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
|
||||
"""Insert utxo_events for newly-confirmed entries from an Electrum
|
||||
`listunspent` response (idempotent on txid+vout), refresh the user's cached
|
||||
balance. Returns the number of newly-credited UTXOs.
|
||||
|
||||
entries: [{"tx_hash": ..., "tx_pos": ..., "height": ..., "value": ...}, ...]
|
||||
height <= 0 means unconfirmed (mempool) per the Electrum protocol convention —
|
||||
skipped, since the spec requires 1 confirmation before crediting.
|
||||
"""
|
||||
existing_keys = {
|
||||
(txid, vout)
|
||||
for txid, vout in (
|
||||
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user_id))
|
||||
).all()
|
||||
}
|
||||
|
||||
newly_credited = 0
|
||||
for entry in entries:
|
||||
if entry["height"] <= 0:
|
||||
continue
|
||||
key = (entry["tx_hash"], entry["tx_pos"])
|
||||
if key in existing_keys:
|
||||
continue
|
||||
session.add(
|
||||
UtxoEvent(
|
||||
user_id=user_id,
|
||||
txid=entry["tx_hash"],
|
||||
vout=entry["tx_pos"],
|
||||
amount_sats=entry["value"],
|
||||
confirmed_height=entry["height"],
|
||||
)
|
||||
)
|
||||
await write_audit_log(
|
||||
session,
|
||||
"deposit_credited",
|
||||
{"txid": entry["tx_hash"], "vout": entry["tx_pos"], "amount_sats": entry["value"]},
|
||||
user_id=user_id,
|
||||
)
|
||||
newly_credited += 1
|
||||
|
||||
if newly_credited:
|
||||
await session.flush()
|
||||
await recompute_balance(session, user_id)
|
||||
await session.commit()
|
||||
broadcaster.publish() # nudges this user's dashboard to refetch its balance instantly
|
||||
|
||||
return newly_credited
|
||||
|
||||
|
||||
_EXTERNAL_SPEND_SENTINEL = "external-spend"
|
||||
|
||||
|
||||
async def reinstate_reappeared_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
|
||||
"""The reverse of a mark applied by find_utxos_missing_from/
|
||||
mark_utxos_spent_externally (B-29): if an outpoint we'd previously flagged as
|
||||
spent outside the platform reappears as unspent in a later listunspent, undo
|
||||
the mark instead of leaving it permanent no matter what the chain says
|
||||
afterwards. Cheap and purely DB-side — always safe to run on every refresh.
|
||||
"""
|
||||
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
|
||||
|
||||
marked_rows = (
|
||||
await session.scalars(
|
||||
select(UtxoEvent).where(
|
||||
UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
reinstated = 0
|
||||
for row in marked_rows:
|
||||
if (row.txid, row.vout) not in current_keys:
|
||||
continue
|
||||
row.spent_txid = None
|
||||
await write_audit_log(
|
||||
session,
|
||||
"utxo_external_spend_reinstated",
|
||||
{"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats},
|
||||
user_id=user_id,
|
||||
)
|
||||
reinstated += 1
|
||||
|
||||
if reinstated:
|
||||
await session.flush()
|
||||
await recompute_balance(session, user_id)
|
||||
await session.commit()
|
||||
broadcaster.publish()
|
||||
|
||||
return reinstated
|
||||
|
||||
|
||||
async def find_utxos_missing_from(session: AsyncSession, user_id: int, entries: list[dict]) -> list[UtxoEvent]:
|
||||
"""Candidates for an external spend (B-29): unspent UTXOs the DB believes this
|
||||
user still holds that are absent from `entries`, this address's current
|
||||
listunspent. Everything the platform itself spends (bets, withdrawals,
|
||||
payouts) sets spent_txid at broadcast time, before the tx ever reaches the
|
||||
chain — so an outpoint still marked unspent in our own DB that Electrum no
|
||||
longer reports as unspent was never on our own radar.
|
||||
|
||||
Returning a row here is *not* proof it was actually spent — only that this one
|
||||
server's reply no longer lists it. A single broken, behind, or malicious
|
||||
server could otherwise zero a user's balance on one bad reply, which is why
|
||||
the caller (electrum/listener.py:refresh_user) must independently
|
||||
corroborate each candidate against other configured servers before treating
|
||||
it as genuine, rather than this function marking anything itself.
|
||||
|
||||
An entirely empty `entries` for an address the DB believes is funded returns
|
||||
no candidates at all: it would otherwise flag every one of this user's UTXOs
|
||||
as missing from a single reply, which is a strong sign of an incomplete or
|
||||
broken response rather than N independent spends landing in the same refresh.
|
||||
"""
|
||||
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
|
||||
|
||||
unspent_rows = (
|
||||
await session.scalars(
|
||||
select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.spent_txid.is_(None))
|
||||
)
|
||||
).all()
|
||||
|
||||
if not entries and unspent_rows:
|
||||
logger.warning(
|
||||
"listunspent for user_id=%s returned no entries at all while %s UTXO(s) are still recorded "
|
||||
"unspent — treating this as an incomplete response rather than a full external sweep",
|
||||
user_id,
|
||||
len(unspent_rows),
|
||||
)
|
||||
return []
|
||||
|
||||
return [row for row in unspent_rows if (row.txid, row.vout) not in current_keys]
|
||||
|
||||
|
||||
async def mark_utxos_spent_externally(session: AsyncSession, user_id: int, utxo_ids: list[int]) -> int:
|
||||
"""Applies the external-spend sentinel to UTXOs the caller has already
|
||||
corroborated against other servers (B-29) — this function does no
|
||||
verification of its own, only persistence, so it never runs with a session
|
||||
held open across the network calls that verification needs.
|
||||
|
||||
Re-checks each row is still unspent before applying the mark: something else
|
||||
may have resolved it (a legitimate platform spend, or a prior refresh) between
|
||||
when the caller read the candidate list and finished corroborating it.
|
||||
"""
|
||||
marked = 0
|
||||
for utxo_id in utxo_ids:
|
||||
row = await session.get(UtxoEvent, utxo_id)
|
||||
if row is None or row.spent_txid is not None:
|
||||
continue
|
||||
row.spent_txid = _EXTERNAL_SPEND_SENTINEL
|
||||
await write_audit_log(
|
||||
session,
|
||||
"utxo_spent_externally",
|
||||
{"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats},
|
||||
user_id=user_id,
|
||||
)
|
||||
marked += 1
|
||||
|
||||
if marked:
|
||||
await session.flush()
|
||||
await recompute_balance(session, user_id)
|
||||
await session.commit()
|
||||
broadcaster.publish()
|
||||
|
||||
return marked
|
||||
@@ -0,0 +1,224 @@
|
||||
import asyncio
|
||||
import itertools
|
||||
import json
|
||||
import ssl
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class ElectrumEndpoint(NamedTuple):
|
||||
"""One server to connect to. The listener rotates over a list of these so a
|
||||
single unreachable or misbehaving server doesn't take the platform down —
|
||||
every consumer of PLM chain data goes through this one connection."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
use_ssl: bool = True
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}"
|
||||
|
||||
|
||||
def parse_endpoints(
|
||||
primary_host: str, primary_port: int, primary_use_ssl: bool, fallback_spec: str
|
||||
) -> list[ElectrumEndpoint]:
|
||||
"""Build the connection rotation: the primary server first, then whatever
|
||||
ELECTRUM_FALLBACK_SERVERS lists.
|
||||
|
||||
`fallback_spec` is comma-separated, each entry `host:port` (TLS, the normal
|
||||
case) or `host:port:notls`. Malformed entries raise ValueError rather than
|
||||
being skipped: a typo in a fallback server is something to fix at startup,
|
||||
not to discover during an outage, when the fallback is what's needed.
|
||||
Duplicates are dropped, keeping first position.
|
||||
"""
|
||||
endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl)]
|
||||
for raw in fallback_spec.split(","):
|
||||
entry = raw.strip()
|
||||
if not entry:
|
||||
continue
|
||||
parts = entry.split(":")
|
||||
if len(parts) not in (2, 3):
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]")
|
||||
host, port = parts[0].strip(), parts[1].strip()
|
||||
if not host or not port.isdigit():
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]")
|
||||
use_ssl = True
|
||||
if len(parts) == 3:
|
||||
flag = parts[2].strip().lower()
|
||||
if flag not in ("ssl", "tls", "notls", "plain"):
|
||||
raise ValueError(f"invalid TLS flag {flag!r} in Electrum server {entry!r}")
|
||||
use_ssl = flag in ("ssl", "tls")
|
||||
endpoints.append(ElectrumEndpoint(host, int(port), use_ssl))
|
||||
|
||||
deduped: list[ElectrumEndpoint] = []
|
||||
for endpoint in endpoints:
|
||||
if endpoint not in deduped:
|
||||
deduped.append(endpoint)
|
||||
return deduped
|
||||
|
||||
|
||||
|
||||
# Every request is bounded: without this, a half-open socket (the peer vanished
|
||||
# without a FIN, or the read loop died — see _read_loop) leaves `await future`
|
||||
# hanging forever, and with it whatever was awaiting the call. That used to be
|
||||
# unbounded, which meant a POST /bets could hang while holding the per-user lock
|
||||
# and the confirmation poller could stop polling permanently.
|
||||
_REQUEST_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
class ElectrumError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ElectrumClient:
|
||||
"""Minimal asyncio Electrum protocol client: line-delimited JSON-RPC over TLS.
|
||||
|
||||
Push notifications (blockchain.headers.subscribe, blockchain.scripthash.subscribe)
|
||||
arrive under the *same* method name as the subscribe call, multiplexed for every
|
||||
scripthash subscribed — callers read `notifications(method)` and, for scripthash
|
||||
pushes, dispatch on `params[0]` (the scripthash) themselves.
|
||||
|
||||
A dead connection is observable rather than silent: `wait_closed()` resolves as
|
||||
soon as the read loop terminates for any reason, which is what lets
|
||||
ElectrumListener notice the drop and reconnect instead of waiting forever on
|
||||
notification queues nobody will ever fill again.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int, use_ssl: bool = True):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.use_ssl = use_ssl
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._id_counter = itertools.count(1)
|
||||
self._pending: dict[int, asyncio.Future] = {}
|
||||
self._subscriptions: dict[str, asyncio.Queue] = {}
|
||||
self._read_task: asyncio.Task | None = None
|
||||
self._closed = asyncio.Event()
|
||||
|
||||
async def connect(self) -> None:
|
||||
# Electrum servers commonly present self-signed certs; the protocol's trust
|
||||
# model is server consensus, not TLS PKI, so we only use SSL for transport
|
||||
# encryption and don't verify the certificate chain/hostname.
|
||||
ssl_context = None
|
||||
if self.use_ssl:
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
self._reader, self._writer = await asyncio.open_connection(self.host, self.port, ssl=ssl_context)
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
await self.request("server.version", ["plm-lottery", "1.4"])
|
||||
|
||||
async def close(self) -> None:
|
||||
self._closed.set()
|
||||
if self._read_task is not None:
|
||||
self._read_task.cancel()
|
||||
if self._writer is not None:
|
||||
self._writer.close()
|
||||
try:
|
||||
await asyncio.wait_for(self._writer.wait_closed(), timeout=2)
|
||||
except (ssl.SSLError, TimeoutError, asyncio.TimeoutError, ConnectionError):
|
||||
pass # some Electrum servers don't send a clean TLS close_notify
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
"""Resolves once this connection is gone — read loop finished (peer closed,
|
||||
protocol error, TLS failure) or close() was called. ElectrumListener races
|
||||
this against its notification consumers so a drop triggers a reconnect."""
|
||||
await self._closed.wait()
|
||||
|
||||
async def request(self, method: str, params: list | None = None) -> object:
|
||||
if self._writer is None or self._closed.is_set():
|
||||
raise ElectrumError("not connected")
|
||||
request_id = next(self._id_counter)
|
||||
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
||||
self._pending[request_id] = future
|
||||
payload = json.dumps({"id": request_id, "method": method, "params": params or []}) + "\n"
|
||||
try:
|
||||
self._writer.write(payload.encode())
|
||||
await self._writer.drain()
|
||||
except (ConnectionError, ssl.SSLError, OSError) as exc:
|
||||
self._pending.pop(request_id, None)
|
||||
self._closed.set()
|
||||
raise ElectrumError(f"write failed: {exc}") from exc
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout=_REQUEST_TIMEOUT_SECONDS)
|
||||
except (TimeoutError, asyncio.TimeoutError) as exc:
|
||||
self._pending.pop(request_id, None)
|
||||
# A server that owes us a reply and never sends one is indistinguishable
|
||||
# from a dead socket, and retrying on the same connection would keep
|
||||
# hitting it — tear it down so the listener reconnects.
|
||||
self._closed.set()
|
||||
raise ElectrumError(f"{method} timed out after {_REQUEST_TIMEOUT_SECONDS}s") from exc
|
||||
|
||||
async def ping(self) -> None:
|
||||
"""Keepalive: Electrum servers drop idle connections (commonly after ~10
|
||||
minutes), which on a quiet instance would otherwise be the normal way this
|
||||
connection dies. Called periodically by ElectrumListener."""
|
||||
await self.request("server.ping")
|
||||
|
||||
def notifications(self, method: str) -> asyncio.Queue:
|
||||
return self._subscriptions.setdefault(method, asyncio.Queue())
|
||||
|
||||
async def subscribe_headers(self) -> dict:
|
||||
self.notifications("blockchain.headers.subscribe")
|
||||
return await self.request("blockchain.headers.subscribe")
|
||||
|
||||
async def subscribe_scripthash(self, scripthash: str) -> str | None:
|
||||
self.notifications("blockchain.scripthash.subscribe")
|
||||
return await self.request("blockchain.scripthash.subscribe", [scripthash])
|
||||
|
||||
async def listunspent(self, scripthash: str) -> list[dict]:
|
||||
return await self.request("blockchain.scripthash.listunspent", [scripthash])
|
||||
|
||||
async def get_history(self, scripthash: str) -> list[dict]:
|
||||
"""Every transaction touching `scripthash`, each as {"tx_hash", "height"} —
|
||||
height > 0 means confirmed at that height, height <= 0 means still in the
|
||||
mempool. Used instead of blockchain.transaction.get's verbose=True mode
|
||||
for confirmation/existence checks (B-41): several Electrum server
|
||||
implementations and versions reject the verbose flag outright ("verbose
|
||||
transactions are currently unsupported"), while get_history is a plain,
|
||||
universally-supported method every server must implement.
|
||||
"""
|
||||
return await self.request("blockchain.scripthash.get_history", [scripthash])
|
||||
|
||||
async def broadcast(self, raw_tx_hex: str) -> str:
|
||||
return await self.request("blockchain.transaction.broadcast", [raw_tx_hex])
|
||||
|
||||
async def get_transaction(self, txid: str, verbose: bool = False) -> object:
|
||||
return await self.request("blockchain.transaction.get", [txid, verbose])
|
||||
|
||||
async def _read_loop(self) -> None:
|
||||
assert self._reader is not None
|
||||
try:
|
||||
while True:
|
||||
line = await self._reader.readline()
|
||||
if not line:
|
||||
break
|
||||
message = json.loads(line)
|
||||
self._dispatch(message)
|
||||
finally:
|
||||
# Whatever ended this loop — clean EOF, protocol error, TLS failure — the
|
||||
# connection is unusable from here on. Setting this is what makes the
|
||||
# death observable to wait_closed(), and so to the listener's reconnect
|
||||
# logic; without it the listener waited on notification queues nobody
|
||||
# would ever fill again, forever (B-01).
|
||||
self._closed.set()
|
||||
error = ElectrumError("connection closed")
|
||||
for future in self._pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(error)
|
||||
self._pending.clear()
|
||||
|
||||
def _dispatch(self, message: dict) -> None:
|
||||
message_id = message.get("id")
|
||||
if message_id is not None and message_id in self._pending:
|
||||
future = self._pending.pop(message_id)
|
||||
if future.done():
|
||||
return
|
||||
if message.get("error"):
|
||||
future.set_exception(ElectrumError(message["error"]))
|
||||
else:
|
||||
future.set_result(message.get("result"))
|
||||
elif "method" in message:
|
||||
queue = self._subscriptions.get(message["method"])
|
||||
if queue is not None:
|
||||
queue.put_nowait(message.get("params"))
|
||||
@@ -0,0 +1,433 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.db.models import User
|
||||
from app.deposits.service import (
|
||||
credit_confirmed_utxos,
|
||||
find_utxos_missing_from,
|
||||
mark_utxos_spent_externally,
|
||||
reinstate_reappeared_utxos,
|
||||
)
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.draw import (
|
||||
HeaderValidationError,
|
||||
header_hex_to_block_hash,
|
||||
header_meets_its_own_target,
|
||||
header_prev_hash,
|
||||
)
|
||||
from app.rounds.events import broadcaster
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How often to ping the server on an otherwise idle connection. Electrum servers
|
||||
# commonly drop idle clients after ~10 minutes, so on a quiet instance this is the
|
||||
# difference between noticing the drop in a minute and never noticing it at all.
|
||||
_PING_INTERVAL_SECONDS = 60
|
||||
|
||||
# How long to wait for any *one* other server's answer when corroborating the
|
||||
# draw's block header (B-28) or a candidate external spend (B-29). Shorter than
|
||||
# the standard request timeout since this is a supplementary check across several
|
||||
# servers at once — a single slow fallback shouldn't hold up the others.
|
||||
_CORROBORATION_TIMEOUT_SECONDS = 10
|
||||
|
||||
# How many users to resubscribe at once on reconnect (B-31), instead of one at a
|
||||
# time. Bounded rather than unlimited so a huge user base doesn't open thousands
|
||||
# of simultaneous in-flight requests against the one active connection.
|
||||
_RESUBSCRIBE_CONCURRENCY = 20
|
||||
|
||||
|
||||
class ElectrumListener:
|
||||
"""Long-lived background task: keeps one Electrum connection open, subscribes
|
||||
every user's address (plus any address added later via add_address), and
|
||||
credits confirmed deposits as scripthash-change notifications arrive.
|
||||
|
||||
Reconnects on any failure; a fresh connection re-subscribes to every user
|
||||
pulled straight from the DB, so no in-memory subscription state is ever a
|
||||
stale source of truth.
|
||||
|
||||
Connections rotate over `endpoints`: after a failed or dropped session the
|
||||
next server in the list is tried immediately, and only once every server has
|
||||
had a turn does the backoff sleep kick in. That way a single dead server costs
|
||||
one attempt rather than an outage, while a genuinely offline network (all
|
||||
servers down) still backs off instead of spinning.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_factory: Callable[[ElectrumEndpoint], ElectrumClient],
|
||||
session_factory: async_sessionmaker,
|
||||
endpoints: list[ElectrumEndpoint] | None = None,
|
||||
):
|
||||
self._client_factory = client_factory
|
||||
self._session_factory = session_factory
|
||||
self._endpoints = list(endpoints or [])
|
||||
self._endpoint_index = 0
|
||||
self._scripthash_to_user: dict[str, int] = {}
|
||||
# Retains address_for_new_user's fire-and-forget subscribe task so it
|
||||
# can't be garbage-collected mid-flight, and so its exception (if any) is
|
||||
# actually observed instead of only reaching asyncio's default "Task
|
||||
# exception was never retrieved" handler (B-30).
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
self.tip_height: int = 0
|
||||
self.tip_header_hex: str | None = None
|
||||
self.client: ElectrumClient | None = None
|
||||
|
||||
@property
|
||||
def current_endpoint(self) -> ElectrumEndpoint | None:
|
||||
"""Which server the next (or current) session uses — for logging and for
|
||||
the admin dashboard's connection status."""
|
||||
if not self._endpoints:
|
||||
return None
|
||||
return self._endpoints[self._endpoint_index]
|
||||
|
||||
def address_for_new_user(self, user_id: int, address: str) -> None:
|
||||
"""Called right after a user registers so their deposit address starts
|
||||
being watched immediately, without waiting for the next reconnect cycle.
|
||||
|
||||
Best-effort, not retried on its own: `self.client` can still become None
|
||||
between the check below and the task actually running (the connection
|
||||
drops in between), which used to raise an AssertionError inside an
|
||||
untracked task and vanish silently (B-30). The exception is now logged
|
||||
instead, and — since a failure here just means this one address stays
|
||||
unsubscribed until the next reconnect's `_subscribe_all_users` or the
|
||||
periodic `DepositReconciler` sweep (also B-30) catches it — that's an
|
||||
acceptable, self-healing outcome rather than something worth its own
|
||||
retry/backoff loop.
|
||||
"""
|
||||
scripthash = address_to_scripthash(address)
|
||||
self._scripthash_to_user[scripthash] = user_id
|
||||
if self.client is not None:
|
||||
task = asyncio.create_task(self._subscribe_and_refresh(scripthash, user_id))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
task.add_done_callback(self._log_subscribe_task_failure)
|
||||
|
||||
def _log_subscribe_task_failure(self, task: asyncio.Task) -> None:
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
logger.warning("could not subscribe a newly-registered user's address: %r", exc)
|
||||
|
||||
async def run(self) -> None:
|
||||
backoff = 1
|
||||
failures_this_cycle = 0
|
||||
while True:
|
||||
endpoint = self.current_endpoint
|
||||
if endpoint is None:
|
||||
logger.error("no Electrum endpoints configured; listener idle")
|
||||
return
|
||||
connected = False
|
||||
try:
|
||||
connected = await self._run_once(endpoint)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Electrum session on %s failed", endpoint)
|
||||
finally:
|
||||
self.client = None
|
||||
|
||||
# Move on to the next server regardless of why this session ended: a
|
||||
# server that just dropped us has no claim on being tried first again.
|
||||
if len(self._endpoints) > 1:
|
||||
self._endpoint_index = (self._endpoint_index + 1) % len(self._endpoints)
|
||||
|
||||
if connected:
|
||||
# We did reach a server, so the network is up — don't let an earlier
|
||||
# streak of failures keep penalizing the next attempt.
|
||||
backoff = 1
|
||||
failures_this_cycle = 0
|
||||
logger.info("Electrum connection to %s ended, reconnecting", endpoint)
|
||||
continue
|
||||
|
||||
failures_this_cycle += 1
|
||||
if failures_this_cycle < len(self._endpoints):
|
||||
continue # other servers untried — go straight to the next one
|
||||
failures_this_cycle = 0
|
||||
logger.warning("all %s Electrum server(s) unreachable, retrying in %ss", len(self._endpoints), backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
|
||||
async def _run_once(self, endpoint: ElectrumEndpoint) -> bool:
|
||||
"""One connection's whole lifetime. Returns True if the connection was
|
||||
actually established (so the caller knows the network is reachable and can
|
||||
reset its backoff), False if it never got that far."""
|
||||
client = self._client_factory(endpoint)
|
||||
await client.connect()
|
||||
self.client = client
|
||||
logger.info("Electrum connected to %s", endpoint)
|
||||
|
||||
try:
|
||||
header = await client.subscribe_headers()
|
||||
self._apply_header(header)
|
||||
|
||||
headers_queue = client.notifications("blockchain.headers.subscribe")
|
||||
scripthash_queue = client.notifications("blockchain.scripthash.subscribe")
|
||||
# The consumers below block on their queues forever by design, so they
|
||||
# can never notice the connection dying — client.wait_closed() and the
|
||||
# keepalive are what make the drop observable. Whichever finishes first
|
||||
# ends the session and sends run() around to the next server.
|
||||
tasks = [
|
||||
asyncio.create_task(self._consume_headers(headers_queue)),
|
||||
asyncio.create_task(self._consume_scripthash(scripthash_queue)),
|
||||
asyncio.create_task(self._keepalive(client)),
|
||||
asyncio.create_task(client.wait_closed()),
|
||||
]
|
||||
# B-31: resubscribing every user is O(users) sequential round-trips —
|
||||
# at thousands of users that's minutes during which, previously,
|
||||
# nothing above had started yet: tip_height was frozen and an
|
||||
# in-flight draw's _wait_for_next_block made zero progress for the
|
||||
# entire resubscribe. Running it as its own background task instead
|
||||
# of awaiting it inline here means tip updates (and notifications for
|
||||
# whichever users are already subscribed) keep flowing throughout.
|
||||
# It's deliberately not one of the raced `tasks` above: unlike those,
|
||||
# it's expected to finish normally, and its own completion must not
|
||||
# look like the session ending. Any failure partway through is
|
||||
# logged the same way address_for_new_user's background task is
|
||||
# (B-30), and it's cancelled below along with everything else once
|
||||
# the session actually does end.
|
||||
subscribe_task = asyncio.create_task(self._subscribe_all_users())
|
||||
subscribe_task.add_done_callback(self._log_subscribe_all_users_failure)
|
||||
try:
|
||||
done, still_running = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
subscribe_task.cancel()
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
for task in done:
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
logger.warning("Electrum session on %s ending: %r", endpoint, exc)
|
||||
finally:
|
||||
self.client = None
|
||||
await client.close()
|
||||
return True
|
||||
|
||||
def _log_subscribe_all_users_failure(self, task: asyncio.Task) -> None:
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
logger.warning("resubscribing all users failed partway through: %r", exc)
|
||||
|
||||
async def _keepalive(self, client: ElectrumClient) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(_PING_INTERVAL_SECONDS)
|
||||
await client.ping() # raises (and so ends the session) on timeout or a dead socket
|
||||
|
||||
async def _subscribe_all_users(self) -> None:
|
||||
"""B-31: subscribes with bounded concurrency (_RESUBSCRIBE_CONCURRENCY at
|
||||
a time) instead of one user at a time — at thousands of users a serial
|
||||
loop meant thousands of sequential round-trips. One user's failure (a
|
||||
single slow or briefly-erroring request) must not stop the rest from
|
||||
being subscribed, mirroring the same per-item isolation used elsewhere
|
||||
(e.g. tx/confirmation.py's poll_once, deposits/reconcile.py's sweep)."""
|
||||
async with self._session_factory() as session:
|
||||
users = (await session.scalars(select(User))).all()
|
||||
|
||||
semaphore = asyncio.Semaphore(_RESUBSCRIBE_CONCURRENCY)
|
||||
|
||||
async def _subscribe_one(user: User) -> None:
|
||||
scripthash = address_to_scripthash(user.address)
|
||||
self._scripthash_to_user[scripthash] = user.id
|
||||
async with semaphore:
|
||||
try:
|
||||
await self._subscribe_and_refresh(scripthash, user.id)
|
||||
except Exception:
|
||||
logger.exception("failed to resubscribe user_id=%s", user.id)
|
||||
|
||||
await asyncio.gather(*(_subscribe_one(user) for user in users))
|
||||
|
||||
async def _subscribe_and_refresh(self, scripthash: str, user_id: int) -> None:
|
||||
assert self.client is not None
|
||||
await self.client.subscribe_scripthash(scripthash)
|
||||
await self.refresh_user(user_id, scripthash)
|
||||
|
||||
def _apply_header(self, header: dict) -> None:
|
||||
"""Record a new chain tip, refusing to move backwards.
|
||||
|
||||
Full reorg handling is out of scope for v1 by explicit design decision, but
|
||||
the tip must never regress: `_wait_for_next_block` waits for
|
||||
`tip_height > tip_at_close`, so a lower height would silently add a block to
|
||||
the draw's wait. height and hex are applied together or not at all —
|
||||
applying a losing header's hex would leave tip_height and tip_header_hex
|
||||
describing different blocks, and that hex is the draw's entropy source.
|
||||
|
||||
Two validation checks guard against a hostile or MITM'd server simply
|
||||
fabricating a header (B-28), since that header is the draw's sole source of
|
||||
entropy: it must satisfy the difficulty target it claims for itself, and —
|
||||
when it's a direct single-block advance from our own current tip, the only
|
||||
case we can check without a full header chain — it must chain from that
|
||||
tip's hash. Either failure raises HeaderValidationError rather than
|
||||
silently ignoring the header, which (via _consume_headers/_run_once) ends
|
||||
this session the same way a dropped connection would, so run() rotates to
|
||||
the next configured server instead of continuing to trust this one.
|
||||
"""
|
||||
height = header["height"]
|
||||
header_hex = header.get("hex")
|
||||
if height < self.tip_height:
|
||||
logger.warning(
|
||||
"ignoring Electrum header at height %s, below the current tip %s (reorg or server switch?)",
|
||||
height,
|
||||
self.tip_height,
|
||||
)
|
||||
return
|
||||
|
||||
if header_hex:
|
||||
if not header_meets_its_own_target(header_hex):
|
||||
raise HeaderValidationError(
|
||||
f"header at height {height} does not satisfy its own claimed difficulty target"
|
||||
)
|
||||
if (
|
||||
self.tip_header_hex
|
||||
and height == self.tip_height + 1
|
||||
and header_prev_hash(header_hex) != header_hex_to_block_hash(self.tip_header_hex)
|
||||
):
|
||||
raise HeaderValidationError(
|
||||
f"header at height {height} does not chain from the current tip (height {self.tip_height})"
|
||||
)
|
||||
|
||||
self.tip_height = height
|
||||
self.tip_header_hex = header_hex
|
||||
|
||||
async def _corroborate_majority(
|
||||
self,
|
||||
ask: Callable[[ElectrumEndpoint], "asyncio.Future"],
|
||||
agrees: Callable[[object], bool],
|
||||
description: str,
|
||||
) -> bool:
|
||||
"""Shared quorum logic behind corroborate_header (B-28) and
|
||||
corroborate_utxo_spent (B-29): ask every *other* configured server (never
|
||||
the currently active one — that's exactly what a hostile server or a MITM
|
||||
would control) and require a strict majority of the ones that actually
|
||||
answer to agree, via `agrees`, with what our own connection reported.
|
||||
|
||||
Returns True with no other servers configured — nothing to corroborate
|
||||
against, a risk accepted when ELECTRUM_FALLBACK_SERVERS was left empty
|
||||
(see CLAUDE.md). Returns False (never silently "passes") if none of the
|
||||
others could be reached, since an unreachable network proves nothing
|
||||
either way.
|
||||
"""
|
||||
others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
|
||||
if not others:
|
||||
return True
|
||||
|
||||
results = await asyncio.gather(*(ask(endpoint) for endpoint in others))
|
||||
responded = [result for result in results if result is not None]
|
||||
if not responded:
|
||||
logger.warning(
|
||||
"could not corroborate %s with any of %s other configured server(s)", description, len(others)
|
||||
)
|
||||
return False
|
||||
|
||||
agreements = sum(1 for result in responded if agrees(result))
|
||||
return agreements * 2 > len(responded)
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
"""B-28: is `expected_hash` — the header our own active connection
|
||||
reported for `height` — corroborated by other configured servers before
|
||||
the draw (rounds/scheduler.py:_wait_for_next_block) treats it as
|
||||
trustworthy entropy? Without this, a single hostile server (or a MITM on
|
||||
the one active connection) can single-handedly decide who wins every
|
||||
round; this raises the bar to controlling a majority of the configured
|
||||
servers. See _corroborate_majority for the shared quorum logic.
|
||||
"""
|
||||
|
||||
async def _ask(endpoint: ElectrumEndpoint) -> str | None:
|
||||
client = self._client_factory(endpoint)
|
||||
try:
|
||||
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
result = await asyncio.wait_for(
|
||||
client.request("blockchain.block.header", [height]),
|
||||
timeout=_CORROBORATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
if not isinstance(result, str):
|
||||
return None
|
||||
return header_hex_to_block_hash(result)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return await self._corroborate_majority(_ask, lambda block_hash: block_hash == expected_hash, f"block {height} header")
|
||||
|
||||
async def corroborate_utxo_spent(self, scripthash: str, txid: str, vout: int) -> bool:
|
||||
"""B-29: before deposits/service.py's find_utxos_missing_from candidates
|
||||
are treated as genuinely spent outside the platform, ask the other
|
||||
configured servers whether *they* also no longer report this outpoint as
|
||||
unspent. A single broken, behind, or malicious server could otherwise zero
|
||||
a user's balance on one incomplete listunspent reply. See
|
||||
_corroborate_majority for the shared quorum logic.
|
||||
"""
|
||||
|
||||
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
|
||||
client = self._client_factory(endpoint)
|
||||
try:
|
||||
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
entries = await asyncio.wait_for(
|
||||
client.listunspent(scripthash), timeout=_CORROBORATION_TIMEOUT_SECONDS
|
||||
)
|
||||
still_unspent = any(e.get("tx_hash") == txid and e.get("tx_pos") == vout for e in entries)
|
||||
return not still_unspent # True = this server agrees the outpoint is gone
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return await self._corroborate_majority(_ask, lambda agrees: agrees, f"outpoint {txid}:{vout}")
|
||||
|
||||
async def _consume_headers(self, queue: asyncio.Queue) -> None:
|
||||
while True:
|
||||
params = await queue.get()
|
||||
for header in params:
|
||||
self._apply_header(header)
|
||||
# A new block is exactly what the "drawing" phase is waiting on
|
||||
# (rounds/scheduler.py:_wait_for_next_block) — nudge dashboards to
|
||||
# refetch instead of waiting for their next poll.
|
||||
broadcaster.publish()
|
||||
|
||||
async def _consume_scripthash(self, queue: asyncio.Queue) -> None:
|
||||
while True:
|
||||
scripthash, _status = await queue.get()
|
||||
user_id = self._scripthash_to_user.get(scripthash)
|
||||
if user_id is not None:
|
||||
await self.refresh_user(user_id, scripthash)
|
||||
|
||||
async def refresh_user(self, user_id: int, scripthash: str) -> None:
|
||||
"""Three phases, so no DB session is held across a network call (B-18),
|
||||
same shape as _trigger_payout: read what's needed, corroborate any
|
||||
candidate external spends against other servers (B-29), then persist.
|
||||
"""
|
||||
assert self.client is not None
|
||||
entries = await self.client.listunspent(scripthash)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
credited = await credit_confirmed_utxos(session, user_id, entries)
|
||||
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
|
||||
candidates = [
|
||||
(row.id, row.txid, row.vout)
|
||||
for row in await find_utxos_missing_from(session, user_id, entries)
|
||||
]
|
||||
|
||||
confirmed_ids = [
|
||||
utxo_id
|
||||
for utxo_id, txid, vout in candidates
|
||||
if await self.corroborate_utxo_spent(scripthash, txid, vout)
|
||||
]
|
||||
|
||||
spent_externally = 0
|
||||
if confirmed_ids:
|
||||
async with self._session_factory() as session:
|
||||
spent_externally = await mark_utxos_spent_externally(session, user_id, confirmed_ids)
|
||||
|
||||
if credited:
|
||||
logger.info("credited %s new UTXO(s) for user_id=%s", credited, user_id)
|
||||
if reinstated:
|
||||
logger.info("reinstated %s previously-flagged UTXO(s) for user_id=%s", reinstated, user_id)
|
||||
if spent_externally:
|
||||
logger.warning("%s UTXO(s) spent outside the platform for user_id=%s", spent_externally, user_id)
|
||||
@@ -0,0 +1,15 @@
|
||||
import hashlib
|
||||
|
||||
from embit.script import Script
|
||||
|
||||
|
||||
def address_to_scripthash(address: str) -> str:
|
||||
"""Electrum protocol scripthash: sha256(scriptPubKey), byte-reversed, hex.
|
||||
|
||||
Uses `.data` (the raw scriptPubKey bytes), not `.serialize()` — the latter
|
||||
prefixes a compact-size length byte meant for embedding the script as pushdata
|
||||
elsewhere (e.g. a P2SH redeemScript), which is not part of the actual on-chain
|
||||
output script and produces a wrong (unmatchable) scripthash if used here.
|
||||
"""
|
||||
script_pubkey = Script.from_address(address).data
|
||||
return hashlib.sha256(script_pubkey).digest()[::-1].hex()
|
||||
@@ -0,0 +1,28 @@
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
||||
LOG_FILE = LOG_DIR / "app.log"
|
||||
|
||||
_FORMAT = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Route all app + uvicorn logging to logs/app.log so errors are traceable
|
||||
without depending on however the process happens to be launched."""
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
|
||||
handler.setFormatter(logging.Formatter(_FORMAT))
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
root.addHandler(handler)
|
||||
|
||||
# "uvicorn.error"/"uvicorn.access" have propagate=False in uvicorn's default
|
||||
# logging config, so they never reach the root handler above and need their
|
||||
# own. The parent "uvicorn" logger is deliberately skipped: uvicorn.error
|
||||
# already bubbles into it, so attaching there too would double every line.
|
||||
for logger_name in ("uvicorn.error", "uvicorn.access"):
|
||||
logging.getLogger(logger_name).addHandler(handler)
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.logging_config import setup_logging
|
||||
|
||||
setup_logging()
|
||||
|
||||
import app.bets.confirmation # noqa: F401 (registers the "bet" confirmation handler)
|
||||
import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmation handler)
|
||||
import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler)
|
||||
from app.api.routes.admin import router as admin_router
|
||||
from app.api.routes.bets import router as bets_router
|
||||
from app.api.routes.bug_reports import router as bug_reports_router
|
||||
from app.api.routes.qr import router as qr_router
|
||||
from app.api.routes.rounds import router as rounds_router
|
||||
from app.api.routes.users import router as users_router
|
||||
from app.api.routes.withdrawals import router as withdrawals_router
|
||||
from app.auth.routes import router as auth_router
|
||||
from app.api.errors import ApiError
|
||||
from app.config import settings, validate_runtime_secrets
|
||||
from app.db.base import AsyncSessionLocal
|
||||
from app.deposits.reconcile import DepositReconciler
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints
|
||||
from app.electrum.listener import ElectrumListener
|
||||
from app.rounds.scheduler import RoundScheduler
|
||||
from app.tx.broadcast import RbfBumper
|
||||
from app.tx.confirmation import ConfirmationPoller
|
||||
from app.tx.locks import UserLocks
|
||||
from app.tx.reconcile import PendingTransactionReconciler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient:
|
||||
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Refuses to serve rather than starting up half-configured (B-15).
|
||||
validate_runtime_secrets()
|
||||
|
||||
endpoints = parse_endpoints(
|
||||
settings.electrum_host,
|
||||
settings.electrum_port,
|
||||
settings.electrum_use_ssl,
|
||||
settings.electrum_fallback_servers,
|
||||
)
|
||||
logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints))
|
||||
|
||||
listener = ElectrumListener(_make_electrum_client, AsyncSessionLocal, endpoints)
|
||||
app.state.electrum_listener = listener
|
||||
app.state.user_locks = UserLocks()
|
||||
|
||||
scheduler = RoundScheduler(AsyncSessionLocal, listener)
|
||||
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client)
|
||||
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client)
|
||||
# Resolves in-flight transactions against the chain — the piece that lets the
|
||||
# system recover on its own from a broadcast that never confirmed (B-04/B-08).
|
||||
reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client)
|
||||
# Periodic safety net for deposit crediting/external-spend detection,
|
||||
# independent of scripthash-change notifications — catches a subscription
|
||||
# silently lost on an otherwise healthy connection (B-30).
|
||||
deposit_reconciler = DepositReconciler(AsyncSessionLocal, listener)
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(listener.run()),
|
||||
asyncio.create_task(scheduler.run()),
|
||||
asyncio.create_task(poller.run()),
|
||||
asyncio.create_task(bumper.run()),
|
||||
asyncio.create_task(reconciler.run()),
|
||||
asyncio.create_task(deposit_reconciler.run()),
|
||||
]
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if listener.client is not None:
|
||||
await listener.client.close()
|
||||
|
||||
|
||||
# Swagger/ReDoc/the raw OpenAPI JSON enumerate the entire API surface, admin
|
||||
# endpoints included, to anyone who requests them (B-42) — disabled unless
|
||||
# ENABLE_API_DOCS is explicitly set, which should only happen in development.
|
||||
app = FastAPI(
|
||||
title="PLM Lottery",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if settings.enable_api_docs else None,
|
||||
redoc_url="/redoc" if settings.enable_api_docs else None,
|
||||
openapi_url="/openapi.json" if settings.enable_api_docs else None,
|
||||
)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(bets_router)
|
||||
app.include_router(withdrawals_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(bug_reports_router)
|
||||
app.include_router(qr_router)
|
||||
app.include_router(rounds_router)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Answers with the same structured `detail` shape as every deliberate failure
|
||||
(app/api/errors.py) so clients never have to special-case unexpected errors.
|
||||
The exception itself stays in logs/app.log only — never in the response body."""
|
||||
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": ApiError("internal_error", "internal server error").as_detail()},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
_NO_STORE_HEADERS = {"Cache-Control": "no-store"}
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def index_page() -> FileResponse:
|
||||
return FileResponse("app/static/index.html", headers=_NO_STORE_HEADERS)
|
||||
|
||||
|
||||
@app.get("/admin", include_in_schema=False)
|
||||
async def admin_panel() -> FileResponse:
|
||||
return FileResponse("app/static/admin.html", headers=_NO_STORE_HEADERS)
|
||||
|
||||
|
||||
@app.get("/guida", include_in_schema=False)
|
||||
async def user_guide() -> FileResponse:
|
||||
return FileResponse("app/static/guida.html", headers=_NO_STORE_HEADERS)
|
||||
|
||||
|
||||
@app.get("/report-bug", include_in_schema=False)
|
||||
async def report_bug_page() -> FileResponse:
|
||||
return FileResponse("app/static/report-bug.html", headers=_NO_STORE_HEADERS)
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory="app/static", html=True), name="static")
|
||||
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import RoundConfig
|
||||
|
||||
|
||||
async def get_round_config(session: AsyncSession) -> RoundConfig:
|
||||
"""Single-row operational config, lazily created on first use with the
|
||||
column defaults declared on RoundConfig itself (app/db/models.py) — no env
|
||||
var involved. fee_address starts empty until an operator sets it via the
|
||||
admin panel/API — payouts must refuse to run until it's set."""
|
||||
config = await session.scalar(select(RoundConfig))
|
||||
if config is None:
|
||||
config = RoundConfig(fee_address="")
|
||||
session.add(config)
|
||||
await session.flush()
|
||||
return config
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import PendingTransaction, Round
|
||||
from app.tx.confirmation import register_handler
|
||||
|
||||
|
||||
async def _on_payout_confirmed(session: AsyncSession, pending: PendingTransaction) -> None:
|
||||
# Resolved by round_id rather than by payout_txid: an RBF-bumped payout confirms
|
||||
# under a different txid than the one first recorded (B-02).
|
||||
round_ = None
|
||||
if pending.round_id is not None:
|
||||
round_ = await session.get(Round, pending.round_id)
|
||||
if round_ is None:
|
||||
round_ = await session.scalar(select(Round).where(Round.payout_txid == pending.current_txid))
|
||||
if round_ is not None and round_.status == "paying_out":
|
||||
round_.status = "closed"
|
||||
# closed_at is what round_cooldown_seconds counts from (service.py's
|
||||
# open_new_round_if_needed) — re-stamp it here at actual payout
|
||||
# confirmation time rather than leaving it at the earlier "closing"
|
||||
# timestamp, so a short cooldown (e.g. 20s) is a real pause after the
|
||||
# winner's tx confirms, not swallowed by the ~2 block-time draw+payout wait.
|
||||
round_.closed_at = datetime.now(timezone.utc)
|
||||
# The winner's own address is already watched by the Electrum listener, so
|
||||
# their balance is credited by the normal deposit path once this confirms.
|
||||
|
||||
|
||||
register_handler("payout", _on_payout_confirmed)
|
||||
@@ -0,0 +1,88 @@
|
||||
import hashlib
|
||||
|
||||
# Byte offsets of a standard 80-byte block header: version(4) + prev_block(32) +
|
||||
# merkle_root(32) + timestamp(4) + bits(4) + nonce(4).
|
||||
_HEADER_LENGTH_BYTES = 80
|
||||
_PREV_BLOCK_OFFSET = 4
|
||||
_PREV_BLOCK_LENGTH = 32
|
||||
_BITS_OFFSET = 72
|
||||
_BITS_LENGTH = 4
|
||||
|
||||
|
||||
class HeaderValidationError(Exception):
|
||||
"""Raised by ElectrumListener._apply_header (B-28) when a header either doesn't
|
||||
satisfy the difficulty target it claims for itself, or doesn't chain from the
|
||||
previously accepted tip. Letting this propagate out of the header-consuming
|
||||
task ends the current Electrum session the same way a dropped connection would
|
||||
(see ElectrumListener._run_once), so the listener rotates to the next
|
||||
configured server instead of trusting a header a server just forged."""
|
||||
|
||||
|
||||
def header_hex_to_block_hash(header_hex: str) -> str:
|
||||
"""Block hash from a raw Electrum header: sha256d, byte-reversed, hex.
|
||||
Verified against a real mainnet block (blockchain.transaction.get's own
|
||||
reported blockhash) during development."""
|
||||
header_bytes = bytes.fromhex(header_hex)
|
||||
digest = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest()
|
||||
return digest[::-1].hex()
|
||||
|
||||
|
||||
def header_prev_hash(header_hex: str) -> str:
|
||||
"""The header's `prev_block` field, byte-reversed to the same conventional
|
||||
(display) order as header_hex_to_block_hash's return value, so the two can be
|
||||
compared directly to check that one header actually chains from another."""
|
||||
header_bytes = bytes.fromhex(header_hex)
|
||||
prev = header_bytes[_PREV_BLOCK_OFFSET : _PREV_BLOCK_OFFSET + _PREV_BLOCK_LENGTH]
|
||||
return prev[::-1].hex()
|
||||
|
||||
|
||||
def _target_from_bits(bits: int) -> int:
|
||||
"""Decompress Bitcoin-style compact `nBits` difficulty encoding into the full
|
||||
256-bit target a valid header's hash must be less than or equal to."""
|
||||
exponent = bits >> 24
|
||||
mantissa = bits & 0xFFFFFF
|
||||
if exponent <= 3:
|
||||
return mantissa >> (8 * (3 - exponent))
|
||||
return mantissa << (8 * (exponent - 3))
|
||||
|
||||
|
||||
def header_meets_its_own_target(header_hex: str) -> bool:
|
||||
"""Whether this header's hash satisfies the difficulty target *it claims for
|
||||
itself* (the `bits` field). Rejects a header that was never actually mined —
|
||||
e.g. one fabricated wholesale by a hostile or MITM'd Electrum server (B-28),
|
||||
since satisfying a self-chosen target still requires real proof-of-work.
|
||||
|
||||
This does NOT — and, short of downloading and validating the full header
|
||||
chain's difficulty-retarget history, cannot — catch a header honestly mined at
|
||||
a real but implausibly low self-chosen difficulty: a server could still declare
|
||||
an easy target and grind it out with modest hardware. That residual risk is why
|
||||
the draw additionally requires the winning block's header to be corroborated by
|
||||
the *other* configured servers before using it as the seed (see
|
||||
ElectrumListener.corroborate_header and rounds/scheduler.py:_wait_for_next_block)
|
||||
rather than relying on this check alone.
|
||||
"""
|
||||
header_bytes = bytes.fromhex(header_hex)
|
||||
if len(header_bytes) != _HEADER_LENGTH_BYTES:
|
||||
return False
|
||||
bits = int.from_bytes(header_bytes[_BITS_OFFSET : _BITS_OFFSET + _BITS_LENGTH], "little")
|
||||
target = _target_from_bits(bits)
|
||||
if target <= 0:
|
||||
return False
|
||||
digest = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest()
|
||||
# The hash as the integer comparable against `target`: this is the same digest
|
||||
# header_hex_to_block_hash reverses into the conventional display hex, so
|
||||
# reading it byte-reversed as a big-endian int is equivalent to reading the
|
||||
# original digest bytes as little-endian — both give the same integer.
|
||||
hash_int = int.from_bytes(digest, "little")
|
||||
return hash_int <= target
|
||||
|
||||
|
||||
def draw_winner(participants: list[str], block_hash_hex: str) -> str:
|
||||
"""v1 draw algorithm (flowchart.mmd, node R): seed = block hash as an integer,
|
||||
index = seed mod participant_count, winner = participants[index]. Anyone can
|
||||
recompute and verify it from public data. Deliberately simple/replaceable."""
|
||||
if not participants:
|
||||
raise ValueError("no participants to draw from")
|
||||
seed = int(block_hash_hex, 16)
|
||||
index = seed % len(participants)
|
||||
return participants[index]
|
||||
@@ -0,0 +1,90 @@
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
|
||||
# Defensive backstop on concurrent SSE subscribers overall, regardless of source
|
||||
# — expected load is on the order of ~100 concurrent users, so this is set well
|
||||
# above that. The real defense against a single abusive source is the per-IP cap
|
||||
# below (B-38): a global-only cap was trivially exhausted by one client opening
|
||||
# MAX_SUBSCRIBERS connections, degrading every other user to polling — the
|
||||
# comment used to call it "defensive"; it was actually the vector.
|
||||
MAX_SUBSCRIBERS = 500
|
||||
|
||||
# How many concurrent streams a single client IP may hold. Deliberately small —
|
||||
# a real browser tab needs at most one, occasionally two briefly across a
|
||||
# reload — since this bounds one source's share of the global capacity, not a
|
||||
# legitimate per-user concurrency limit.
|
||||
MAX_SUBSCRIBERS_PER_IP = 5
|
||||
|
||||
# Put on a to-be-evicted subscriber's queue (B-38) to wake its generator
|
||||
# (app/api/routes/rounds.py:round_stream) promptly so it closes the connection
|
||||
# instead of lingering, silently uncounted, until the client's own network
|
||||
# timeout or the next keep-alive tick.
|
||||
EVICTED = object()
|
||||
|
||||
|
||||
class RoundEventCapacityError(Exception):
|
||||
"""Raised by subscribe() when MAX_SUBSCRIBERS — the global backstop — is
|
||||
already reached. The per-IP cap never raises this; it evicts instead (see
|
||||
subscribe())."""
|
||||
|
||||
|
||||
class RoundEventBroadcaster:
|
||||
"""In-process pub/sub so SSE clients (GET /rounds/stream) get pushed a
|
||||
notification the instant round/bet/balance state changes, instead of only
|
||||
finding out on their next poll. The message carries no payload — it's just
|
||||
a "something changed, go refetch" signal; the client re-hits the existing
|
||||
per-user REST endpoints (/rounds/current, /users/me, ...) for the actual
|
||||
data, so this never needs to know what changed or who's allowed to see it.
|
||||
|
||||
Single-process only (no cross-worker fan-out) — fine for this deployment
|
||||
(one uvicorn process, see docker-compose.yml). A multi-worker deployment
|
||||
would need a shared channel (e.g. Redis pub/sub) instead.
|
||||
"""
|
||||
|
||||
def __init__(self, max_subscribers: int = MAX_SUBSCRIBERS, max_per_ip: int = MAX_SUBSCRIBERS_PER_IP):
|
||||
self._ip_by_queue: dict[asyncio.Queue, str] = {}
|
||||
self._queues_by_ip: dict[str, list[asyncio.Queue]] = defaultdict(list)
|
||||
self.max_subscribers = max_subscribers
|
||||
self.max_per_ip = max_per_ip
|
||||
|
||||
def subscribe(self, client_ip: str = "unknown") -> asyncio.Queue:
|
||||
if len(self._ip_by_queue) >= self.max_subscribers:
|
||||
raise RoundEventCapacityError(f"already at the {self.max_subscribers}-subscriber cap")
|
||||
|
||||
ip_queues = self._queues_by_ip[client_ip]
|
||||
if len(ip_queues) >= self.max_per_ip:
|
||||
# B-38: evict this IP's own oldest connection rather than refusing
|
||||
# the new one — bounds one source's footprint without turning a
|
||||
# legitimate reconnect storm (a flaky network retrying EventSource)
|
||||
# into an outright block, and without letting one abusive IP crowd
|
||||
# out unrelated clients the way the old global-only cap did.
|
||||
oldest = ip_queues.pop(0)
|
||||
self._ip_by_queue.pop(oldest, None)
|
||||
if not oldest.full():
|
||||
oldest.put_nowait(EVICTED)
|
||||
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=1)
|
||||
self._ip_by_queue[queue] = client_ip
|
||||
ip_queues.append(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, queue: asyncio.Queue) -> None:
|
||||
client_ip = self._ip_by_queue.pop(queue, None)
|
||||
if client_ip is None:
|
||||
return
|
||||
ip_queues = self._queues_by_ip.get(client_ip)
|
||||
if ip_queues is None:
|
||||
return
|
||||
if queue in ip_queues:
|
||||
ip_queues.remove(queue)
|
||||
if not ip_queues:
|
||||
self._queues_by_ip.pop(client_ip, None)
|
||||
|
||||
def publish(self) -> None:
|
||||
for queue in self._ip_by_queue:
|
||||
if queue.full():
|
||||
continue # a not-yet-delivered notification already covers this one
|
||||
queue.put_nowait(None)
|
||||
|
||||
|
||||
broadcaster = RoundEventBroadcaster()
|
||||
@@ -0,0 +1,470 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from embit import script
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User
|
||||
from app.electrum.listener import ElectrumListener
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.draw import draw_winner, header_hex_to_block_hash
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.service import open_new_round_if_needed, winner_share
|
||||
from app.wallet.hd import derive_pool_key
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TICK_INTERVAL_SECONDS = 5
|
||||
|
||||
# B-26: how long to wait after a payout failure before automatically retrying it.
|
||||
# Long enough that a persistently-broken payout (misconfigured fee_address,
|
||||
# insufficient pool UTXOs) doesn't re-attempt — and re-write a payout_failed audit
|
||||
# entry — every _TICK_INTERVAL_SECONDS; short enough that a transient failure
|
||||
# (a dropped Electrum connection, a momentarily-empty pool) self-heals quickly.
|
||||
_PAYOUT_RETRY_INTERVAL_SECONDS = 60
|
||||
|
||||
# B-36: _wait_for_next_block has no timeout of its own — a round can legitimately
|
||||
# wait several PLM blocks (120s each) for its draw entropy, and re-waits on a
|
||||
# corroboration failure. These only make an already-long wait *observable*, they
|
||||
# never cut it short.
|
||||
_DRAW_PROGRESS_LOG_INTERVAL_SECONDS = 60
|
||||
_DRAW_STALL_THRESHOLD_SECONDS = 360 # a few multiples of PLM's 120s block time
|
||||
|
||||
|
||||
class RoundScheduler:
|
||||
"""Background task implementing flowchart.mmd's DRAW subgraph: closes the
|
||||
round on its timer (once any in-flight bets have confirmed), draws a winner
|
||||
from the next confirmed block, and broadcasts the payout. The next round only
|
||||
opens once this one is fully closed (rounds/service.get_active_round)."""
|
||||
|
||||
def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener):
|
||||
self._session_factory = session_factory
|
||||
self._listener = listener
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self._tick()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("round scheduler tick failed")
|
||||
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
|
||||
|
||||
async def _tick(self) -> None:
|
||||
if self._listener.client is None:
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
if round_ is None:
|
||||
return # still in the cooldown window after the last round closed
|
||||
round_id, status, opened_at = round_.id, round_.status, round_.opened_at
|
||||
round_duration_seconds = (await get_round_config(session)).round_duration_seconds
|
||||
|
||||
if status == "paying_out":
|
||||
# B-26: _trigger_payout used to run exactly once, from _close_and_draw —
|
||||
# any failure after that (no Electrum client, insufficient pool UTXOs, a
|
||||
# rejected broadcast) or a process restart while paying_out left the round
|
||||
# wedged here forever. Every tick now re-checks and retries, throttled by
|
||||
# _retry_payout_if_due so a persistent failure doesn't retry on every tick.
|
||||
await self._retry_payout_if_due(round_id)
|
||||
return
|
||||
|
||||
if status not in ("open", "closing"):
|
||||
return # "drawing" — progress happens inside the in-flight _close_and_draw call
|
||||
|
||||
if status == "open":
|
||||
opened_at = opened_at.replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds):
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
# No new bets from here on, regardless of how long the pending-bet
|
||||
# wait below takes — flip to "closing" immediately so it's observable
|
||||
# via /rounds/current (e.g. "round closed, waiting for jackpot
|
||||
# confirmation") instead of silently staying "open" past the deadline.
|
||||
round_.status = "closing"
|
||||
round_.closed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
broadcaster.publish()
|
||||
|
||||
async with self._session_factory() as session:
|
||||
# "building" counts as in-flight too: it's a bet mid-broadcast (see
|
||||
# bets/service.py's two-phase write). A bet that never confirms is
|
||||
# eventually removed by app/tx/reconcile.py, which is what stops this
|
||||
# wait from being unbounded.
|
||||
pending_count = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(RoundParticipant)
|
||||
.where(
|
||||
RoundParticipant.round_id == round_id,
|
||||
RoundParticipant.status.in_(("building", "broadcast")),
|
||||
)
|
||||
)
|
||||
if pending_count:
|
||||
return # wait for in-flight bets to confirm before closing; stays "closing"
|
||||
|
||||
await self._close_and_draw(round_id)
|
||||
|
||||
async def _close_and_draw(self, round_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
|
||||
participants = (
|
||||
await session.scalars(
|
||||
select(RoundParticipant)
|
||||
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed")
|
||||
.order_by(RoundParticipant.broadcast_at)
|
||||
)
|
||||
).all()
|
||||
|
||||
if not participants:
|
||||
round_.status = "closed"
|
||||
await write_audit_log(session, "round_closed", {"participants": 0}, round_id=round_id)
|
||||
await session.commit()
|
||||
broadcaster.publish()
|
||||
logger.info("round %s closed with no participants", round_id)
|
||||
return
|
||||
|
||||
pool_amount = sum(p.bet_amount_sats for p in participants)
|
||||
addresses: list[str] = []
|
||||
user_by_address: dict[str, int] = {}
|
||||
for p in participants:
|
||||
user = await session.get(User, p.user_id)
|
||||
addresses.append(user.address)
|
||||
user_by_address[user.address] = user.id
|
||||
|
||||
round_.status = "drawing"
|
||||
drawing_started_at = datetime.now(timezone.utc)
|
||||
round_.drawing_started_at = drawing_started_at
|
||||
await session.commit()
|
||||
broadcaster.publish()
|
||||
|
||||
tip_at_close = self._listener.tip_height
|
||||
block_height, block_hash = await self._wait_for_next_block(round_id, tip_at_close, drawing_started_at)
|
||||
winner_address = draw_winner(addresses, block_hash)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
round_.draw_block_height = block_height
|
||||
round_.draw_block_hash = block_hash
|
||||
round_.seed_int = str(int(block_hash, 16))
|
||||
round_.winner_user_id = user_by_address[winner_address]
|
||||
round_.pool_amount_sats = pool_amount
|
||||
round_.status = "paying_out"
|
||||
await write_audit_log(
|
||||
session,
|
||||
"winner_drawn",
|
||||
{
|
||||
"winner_address": winner_address,
|
||||
"pool_amount_sats": pool_amount,
|
||||
"block_height": block_height,
|
||||
"block_hash": block_hash,
|
||||
"participants": len(addresses),
|
||||
},
|
||||
user_id=user_by_address[winner_address],
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
broadcaster.publish()
|
||||
|
||||
logger.info("round %s: winner=%s pool=%s", round_id, winner_address, pool_amount)
|
||||
await self._trigger_payout(round_id)
|
||||
|
||||
async def _wait_for_next_block(
|
||||
self, round_id: int, tip_at_close: int, waiting_since: datetime
|
||||
) -> tuple[int, str]:
|
||||
"""Waits for a block after tip_at_close and, before handing it back as the
|
||||
draw's entropy source, requires it to be corroborated by the other
|
||||
configured Electrum servers (B-28) — our own active connection is exactly
|
||||
the thing a hostile server or a MITM would control, so its header alone is
|
||||
not enough to seed a payout. A candidate that fails corroboration is never
|
||||
used: this keeps waiting for a further block and tries corroborating that
|
||||
one instead, logging why every time so a stuck draw is visible in
|
||||
/admin's audit log rather than a silent, unexplained wait.
|
||||
|
||||
This wait has no timeout — it can't, since the draw's entropy genuinely
|
||||
depends on a future block. B-36: what it lacked was *visibility*, so a
|
||||
connection that stopped advancing the tip left the round silently frozen
|
||||
in "drawing" with nothing in the logs or /admin to explain why. Progress
|
||||
is now logged periodically, and past _DRAW_STALL_THRESHOLD_SECONDS a
|
||||
draw_stalled audit entry is written (and re-written every threshold
|
||||
interval for as long as the stall continues) so the wait shows up next
|
||||
to the draw_header_corroboration_failed entries above.
|
||||
"""
|
||||
next_progress_log_at = waiting_since + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
|
||||
next_stall_audit_at = waiting_since + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
||||
while True:
|
||||
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
|
||||
now = datetime.now(timezone.utc)
|
||||
if now >= next_progress_log_at:
|
||||
logger.info(
|
||||
"round %s: still waiting for a block past height %s (%.0fs since drawing started)",
|
||||
round_id,
|
||||
tip_at_close,
|
||||
(now - waiting_since).total_seconds(),
|
||||
)
|
||||
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
|
||||
if now >= next_stall_audit_at:
|
||||
async with self._session_factory() as session:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"draw_stalled",
|
||||
{
|
||||
"tip_at_close": tip_at_close,
|
||||
"current_tip_height": self._listener.tip_height,
|
||||
"elapsed_seconds": int((now - waiting_since).total_seconds()),
|
||||
},
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
||||
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
|
||||
height = self._listener.tip_height
|
||||
block_hash = header_hex_to_block_hash(self._listener.tip_header_hex)
|
||||
if await self._listener.corroborate_header(height, block_hash):
|
||||
return height, block_hash
|
||||
logger.error(
|
||||
"round %s: block %s header %s could not be corroborated by other Electrum servers; "
|
||||
"waiting for a further block",
|
||||
round_id,
|
||||
height,
|
||||
block_hash,
|
||||
)
|
||||
async with self._session_factory() as session:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"draw_header_corroboration_failed",
|
||||
{"height": height, "reported_hash": block_hash},
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
tip_at_close = height
|
||||
|
||||
async def _retry_payout_if_due(self, round_id: int) -> None:
|
||||
"""B-26: whether a "paying_out" round is due for another payout attempt.
|
||||
|
||||
Throttled by the most recent payout_failed audit entry for this round
|
||||
(written by _log_payout_failure on every early return in _trigger_payout,
|
||||
including ones that used to fail silently) rather than by any new DB state,
|
||||
since a failed attempt doesn't necessarily leave a PendingTransaction behind
|
||||
(a build failure like a missing fee_address never gets that far). No entry
|
||||
yet means this round hasn't failed before — either it's a fresh "paying_out"
|
||||
(the very first call already happened from _close_and_draw and hasn't had a
|
||||
chance to fail yet) or the process restarted before ever recording one —
|
||||
either way it's due immediately.
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
last_failure_at = await session.scalar(
|
||||
select(AuditLog.created_at)
|
||||
.where(AuditLog.event_type == "payout_failed", AuditLog.round_id == round_id)
|
||||
.order_by(AuditLog.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if last_failure_at is not None:
|
||||
last_failure_at = last_failure_at.replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) < last_failure_at + timedelta(seconds=_PAYOUT_RETRY_INTERVAL_SECONDS):
|
||||
return # too soon — avoid hammering a persistently-broken payout
|
||||
await self._trigger_payout(round_id)
|
||||
|
||||
async def _trigger_payout(self, round_id: int) -> None:
|
||||
"""Four phases, so no DB session is held across a network call (B-18): read
|
||||
what's needed, build the tx, persist the intent, then broadcast.
|
||||
|
||||
The persist happens *before* the broadcast (B-25) — the same two-phase shape
|
||||
as place_bet/request_withdrawal (B-08): a crash between building the payout
|
||||
and recording it used to leave money on-chain with zero trace in the DB (no
|
||||
payout_txid, no PendingTransaction), so a manual retry would have paid the
|
||||
winner a second time. Now the worst case is a "building" PendingTransaction
|
||||
the reconciler (app/tx/reconcile.py) can resolve either way by asking the
|
||||
chain whether the tx exists, exactly like it already does for bets and
|
||||
withdrawals.
|
||||
"""
|
||||
client = self._listener.client
|
||||
if client is None:
|
||||
logger.error("round %s payout deferred: not connected", round_id)
|
||||
await self._log_payout_failure(round_id, None, "electrum client not connected")
|
||||
return
|
||||
|
||||
# --- Phase 1: read (session closed before any network I/O) ---------------
|
||||
async with self._session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
already_in_flight = await session.scalar(
|
||||
select(PendingTransaction).where(
|
||||
PendingTransaction.round_id == round_id,
|
||||
PendingTransaction.kind == "payout",
|
||||
PendingTransaction.status.in_(("building", "pending")),
|
||||
)
|
||||
)
|
||||
if already_in_flight is not None:
|
||||
# A payout for this round is already building or broadcast — this
|
||||
# must not build a second one, or a retry (manual, or a future
|
||||
# automatic one) would pay the winner twice. Confirmation/
|
||||
# reconciliation already owns resolving that row.
|
||||
logger.info(
|
||||
"round %s payout already in flight (pending_transaction %s), skipping",
|
||||
round_id,
|
||||
already_in_flight.id,
|
||||
)
|
||||
return
|
||||
reserved_outpoints = await _reserved_payout_outpoints(session)
|
||||
config = await get_round_config(session)
|
||||
fee_address = config.fee_address
|
||||
fee_rate = config.fee_rate_sat_vb
|
||||
pool_amount_sats = round_.pool_amount_sats
|
||||
winner_user_id = round_.winner_user_id
|
||||
winner = await session.get(User, winner_user_id)
|
||||
winner_address = winner.address if winner is not None else None
|
||||
await session.commit() # get_round_config may have created the row
|
||||
|
||||
if not fee_address:
|
||||
logger.error(
|
||||
"round %s payout blocked: no fee_address configured (set it via the admin endpoint)", round_id
|
||||
)
|
||||
await self._log_payout_failure(round_id, winner_user_id, "no fee_address configured")
|
||||
return
|
||||
if winner_address is None:
|
||||
logger.error("round %s payout blocked: winner user %s not found", round_id, winner_user_id)
|
||||
await self._log_payout_failure(round_id, winner_user_id, "winner user not found")
|
||||
return
|
||||
|
||||
winner_sats = winner_share(pool_amount_sats)
|
||||
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
|
||||
|
||||
# --- Phase 2: build (network read only, no DB write yet) -----------------
|
||||
try:
|
||||
pool_key = derive_pool_key()
|
||||
pool_script_obj = script.p2wpkh(pool_key.to_public())
|
||||
pool_address = pool_script_obj.address(network=PLM_MAINNET)
|
||||
entries = await client.listunspent(address_to_scripthash(pool_address))
|
||||
utxos = [
|
||||
Utxo(e["tx_hash"], e["tx_pos"], e["value"])
|
||||
for e in entries
|
||||
if e["height"] > 0 and (e["tx_hash"], e["tx_pos"]) not in reserved_outpoints
|
||||
]
|
||||
|
||||
built = build_payout_transaction(
|
||||
signing_key=pool_key,
|
||||
from_script=pool_script_obj,
|
||||
utxos=utxos,
|
||||
winner_address=winner_address,
|
||||
winner_share_sats=winner_sats,
|
||||
fee_address=fee_address,
|
||||
commission_sats=commission_share,
|
||||
change_address=pool_address,
|
||||
fee_rate_sat_vb=fee_rate,
|
||||
)
|
||||
except InsufficientFundsError as exc:
|
||||
# Includes the B-48 "too_many_inputs" case: the pool holds enough, but spread
|
||||
# over more UTXOs than one transaction may spend, so /admin has to say which.
|
||||
reason = "insufficient pool UTXOs" if exc.code == "insufficient_balance" else exc.code
|
||||
logger.exception("round %s payout failed: %s", round_id, reason)
|
||||
await self._log_payout_failure(round_id, winner_user_id, reason)
|
||||
return
|
||||
except Exception:
|
||||
# Anything else — a malformed fee_address (EmbitError) or similar. This
|
||||
# used to escape all the way to run()'s catch-all, which logged it
|
||||
# without recording anything, leaving no trace of *why* the round was
|
||||
# stuck (B-05). _retry_payout_if_due (B-26) is what turns this recorded
|
||||
# failure into an automatic retry instead of a dead end.
|
||||
logger.exception("round %s payout build failed", round_id)
|
||||
await self._log_payout_failure(round_id, winner_user_id, "payout build failed")
|
||||
return
|
||||
|
||||
# --- Phase 3: persist the intent, *then* broadcast (B-25) -----------------
|
||||
async with self._session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
round_.winner_amount_sats = built.winner_sats
|
||||
round_.fee_amount_sats = built.commission_sats
|
||||
round_.payout_txid = built.txid
|
||||
pending = PendingTransaction(
|
||||
kind="payout",
|
||||
round_id=round_id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=fee_rate,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
# "building" until the broadcast succeeds, exactly like place_bet's
|
||||
# two phases — see the reconciler, which gives this a short grace
|
||||
# period before asking the chain whether it made it out after all.
|
||||
status="building",
|
||||
)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
pending_id = pending.id
|
||||
|
||||
# --- Phase 4: broadcast, then promote the pending row --------------------
|
||||
try:
|
||||
await client.broadcast(built.raw_hex)
|
||||
except Exception:
|
||||
# The row stays "building": the reconciler will ask the chain about it
|
||||
# and, finding nothing, abandon it and clear payout_txid (B-25) — instead
|
||||
# of the round being stuck with a payout_txid that never went anywhere.
|
||||
logger.exception("round %s payout broadcast failed", round_id)
|
||||
await self._log_payout_failure(round_id, winner_user_id, "broadcast rejected")
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
pending.status = "pending"
|
||||
await write_audit_log(
|
||||
session,
|
||||
"payout_sent",
|
||||
{"txid": built.txid, "winner_sats": built.winner_sats, "commission_sats": built.commission_sats},
|
||||
user_id=winner_user_id,
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info("round %s payout broadcast: txid=%s", round_id, built.txid)
|
||||
|
||||
async def _log_payout_failure(self, round_id: int, winner_user_id: int | None, reason: str) -> None:
|
||||
"""Leaves an operator-visible trace in the audit log for a round stuck in
|
||||
"paying_out" — the logs alone don't show up in /admin. Called from every
|
||||
early return in _trigger_payout (B-26), not just the generic exception
|
||||
branch as before, so _retry_payout_if_due always has an entry to throttle
|
||||
against and /admin always shows *why* a round is stuck rather than just
|
||||
that it is."""
|
||||
try:
|
||||
async with self._session_factory() as session:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"payout_failed",
|
||||
{"round_id": round_id, "reason": reason},
|
||||
user_id=winner_user_id,
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("could not record the payout failure of round %s", round_id)
|
||||
|
||||
|
||||
async def _reserved_payout_outpoints(session: AsyncSession) -> set[tuple[str, int]]:
|
||||
"""Pool UTXOs already claimed by a payout that hasn't resolved yet — this
|
||||
round's own in-flight payout (guarded against separately in _trigger_payout) or
|
||||
a stale one from an earlier round the reconciler hasn't abandoned yet (B-25).
|
||||
These must be excluded from selection, or a retry would double-spend the same
|
||||
coins into two payouts before the reconciler gets a chance to release them."""
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(PendingTransaction).where(
|
||||
PendingTransaction.kind == "payout",
|
||||
PendingTransaction.status.in_(("building", "pending")),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
reserved: set[tuple[str, int]] = set()
|
||||
for row in rows:
|
||||
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
|
||||
for vin in tx.vin:
|
||||
reserved.add((vin.txid.hex(), vin.vout))
|
||||
return reserved
|
||||
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Round
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out")
|
||||
|
||||
# Bounded: a conflict means someone else is opening a round right now, so a couple
|
||||
# of retries is plenty. Unbounded retries could spin if the invariant were ever
|
||||
# broken in a way we don't anticipate.
|
||||
_OPEN_ROUND_ATTEMPTS = 3
|
||||
|
||||
# 70% winner / 30% fees. Hardcoded by design (see CLAUDE.md) — changing the split
|
||||
# is a code change, not an admin-editable setting. Single source of truth so the
|
||||
# advertised jackpot (rounds.py) and the actual payout (scheduler.py) can't diverge.
|
||||
|
||||
|
||||
def winner_share(pool_amount_sats: int) -> int:
|
||||
return pool_amount_sats * 70 // 100
|
||||
|
||||
|
||||
async def get_active_round(session: AsyncSession) -> Round | None:
|
||||
"""The round currently in progress (in any non-closed state), if any. Rounds
|
||||
never overlap: a new round only opens once the previous one is fully closed
|
||||
(payout confirmed, or no participants to pay out).
|
||||
|
||||
The database enforces "at most one active round" (ix_rounds_single_active, see
|
||||
app/db/models.py), so the ordering below is belt-and-braces; if it ever does
|
||||
see two, that's a broken invariant and worth a loud log rather than silently
|
||||
picking one."""
|
||||
active = (
|
||||
await session.scalars(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc()))
|
||||
).all()
|
||||
if len(active) > 1:
|
||||
logger.error(
|
||||
"invariant violated: %s rounds are active at once (ids=%s) — using the newest",
|
||||
len(active),
|
||||
[r.id for r in active],
|
||||
)
|
||||
return active[0] if active else None
|
||||
|
||||
|
||||
def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
|
||||
"""The authoritative "yellow light" check: once a round's timer has expired,
|
||||
no new bet may be accepted, even though its DB status is still "open" (the
|
||||
scheduler only flips it to "closing" on its next tick, up to
|
||||
_TICK_INTERVAL_SECONDS later — see rounds/scheduler.py). Bets already placed
|
||||
before the deadline are unaffected: the round still waits for them to confirm
|
||||
before actually closing."""
|
||||
if round_.status != "open":
|
||||
return False
|
||||
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
|
||||
return datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds)
|
||||
|
||||
|
||||
async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
|
||||
"""Returns the active round if one exists (whatever its status). Otherwise
|
||||
opens a fresh one, unless the last closed round's cooldown (ROUND_COOLDOWN_SECONDS)
|
||||
hasn't elapsed yet, or the lottery is paused for maintenance — in either case
|
||||
returns None. Callers that need to attach a bet must additionally check the
|
||||
returned round's status == "open" — a round in closing/drawing/paying_out
|
||||
isn't accepting new bets, but a new round can't open until it's done.
|
||||
|
||||
Pausing never touches a round already in progress: it only suppresses opening
|
||||
the *next* one, so the current round still closes, draws, and pays out the
|
||||
winner normally (see admin.py's /admin/pause and /admin/resume)."""
|
||||
active = await get_active_round(session)
|
||||
if active is not None:
|
||||
return active
|
||||
|
||||
config = await get_round_config(session)
|
||||
if config.paused:
|
||||
return None
|
||||
|
||||
last_closed = await session.scalar(select(Round).where(Round.status == "closed").order_by(Round.id.desc()))
|
||||
if last_closed is not None and last_closed.closed_at is not None:
|
||||
closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=config.round_cooldown_seconds):
|
||||
return None
|
||||
|
||||
for attempt in range(_OPEN_ROUND_ATTEMPTS):
|
||||
round_ = Round(status="open")
|
||||
session.add(round_)
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError:
|
||||
# Another caller (the scheduler tick, or a concurrent place_bet) got
|
||||
# there first — ix_rounds_single_active turns what used to be two live
|
||||
# rounds into a clean failure here. Roll our insert back and use theirs.
|
||||
# Safe to roll back: this runs before its callers have written anything
|
||||
# else in this session.
|
||||
await session.rollback()
|
||||
existing = await get_active_round(session)
|
||||
if existing is not None:
|
||||
logger.info("lost the race to open a round; using round %s", existing.id)
|
||||
return existing
|
||||
# Nothing active *and* the insert conflicted: the winner's transaction
|
||||
# hadn't committed yet when we looked. Try again rather than failing the
|
||||
# caller — a bet shouldn't 500 because of a scheduler tick's timing.
|
||||
logger.info("round-open conflict with nothing active yet (attempt %s), retrying", attempt + 1)
|
||||
continue
|
||||
# Published pre-commit (the caller commits right after) — acceptable: this
|
||||
# only tells subscribers "go refetch", and by the time an SSE client's
|
||||
# refetch request actually lands, this in-process commit (microseconds
|
||||
# away) has essentially always already happened.
|
||||
broadcaster.publish()
|
||||
return round_
|
||||
|
||||
logger.error("could not open a round after %s attempts", _OPEN_ROUND_ATTEMPTS)
|
||||
return await get_active_round(session)
|
||||
@@ -0,0 +1,190 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@500;600&family=Fira+Sans:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--color-background: #F8FAFC;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-foreground: #0F172A;
|
||||
--color-muted-foreground: #64748B;
|
||||
--color-border: #E2E8F0;
|
||||
--color-primary: #F59E0B;
|
||||
--color-on-primary: #0F172A;
|
||||
--color-destructive: #DC2626;
|
||||
--color-destructive-bg: #FEF2F2;
|
||||
--color-success: #16A34A;
|
||||
--color-success-bg: #F0FDF4;
|
||||
--color-ring: #F59E0B;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Fira Sans', system-ui, sans-serif;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mono { font-family: 'Fira Code', monospace; }
|
||||
|
||||
/* --- login screen: narrow, centered --- */
|
||||
#login-section { max-width: 420px; margin: 15vh auto 0; padding: 0 20px; }
|
||||
#login-section header { margin-bottom: 24px; }
|
||||
#login-section header h1 { font-size: 1.375rem; font-weight: 700; margin: 0; letter-spacing: -0.01em; }
|
||||
#login-section header p { color: var(--color-muted-foreground); font-size: 0.9rem; margin: 4px 0 0; }
|
||||
|
||||
/* --- dashboard: navbar + content --- */
|
||||
.navbar {
|
||||
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
|
||||
background: var(--color-surface); border-bottom: 1px solid var(--color-border);
|
||||
padding: 0 20px; position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.navbar .brand { font-weight: 700; font-size: 1.05rem; padding: 14px 16px 14px 0; white-space: nowrap; }
|
||||
.navbar .nav-tab {
|
||||
padding: 16px 14px; font-size: 0.9rem; font-weight: 600; cursor: pointer;
|
||||
color: var(--color-muted-foreground); border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px; transition: color 150ms, border-color 150ms; white-space: nowrap;
|
||||
}
|
||||
.navbar .nav-tab.active { color: var(--color-foreground); border-bottom-color: var(--color-primary); }
|
||||
.navbar .nav-tab:hover { color: var(--color-foreground); }
|
||||
.navbar .spacer { flex: 1; }
|
||||
|
||||
.chain-status-pill { display: inline-flex; align-items: center; gap: 7px; font-weight: 600; font-size: 0.82rem; white-space: nowrap; }
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--color-muted-foreground);
|
||||
}
|
||||
.status-dot.status-open {
|
||||
background: var(--color-success);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 18%, transparent);
|
||||
}
|
||||
.status-dot.status-drawing {
|
||||
background: var(--color-primary);
|
||||
animation: status-dot-pulse 1400ms ease-in-out infinite;
|
||||
}
|
||||
.status-dot.status-waiting { background: var(--color-muted-foreground); }
|
||||
@keyframes status-dot-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 45%, transparent); }
|
||||
50% { box-shadow: 0 0 0 5px transparent; }
|
||||
}
|
||||
.chain-block { color: var(--color-muted-foreground); font-size: 0.82rem; white-space: nowrap; }
|
||||
|
||||
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
#maintenance-btn.btn-stop {
|
||||
background: var(--color-destructive-bg); color: var(--color-destructive); border-color: var(--color-destructive);
|
||||
}
|
||||
.status-dot.status-paused { background: var(--color-destructive); }
|
||||
|
||||
main { max-width: 960px; margin: 0 auto; padding: 24px 20px 80px; }
|
||||
|
||||
.view { display: none; }
|
||||
.view.active { display: block; }
|
||||
|
||||
h2.section-title { font-size: 1.15rem; font-weight: 700; margin: 0 0 4px; }
|
||||
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card .hint { color: var(--color-muted-foreground); font-size: 0.85rem; margin: 0 0 14px; }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0 20px; }
|
||||
|
||||
label { display: block; font-size: 0.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 12px; margin-bottom: 6px; }
|
||||
label:first-child { margin-top: 0; }
|
||||
|
||||
input {
|
||||
width: 100%; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
|
||||
border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-surface);
|
||||
color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms;
|
||||
}
|
||||
input:focus {
|
||||
outline: none; border-color: var(--color-ring);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent);
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
min-height: 44px; padding: 0 18px; margin-top: 16px; width: 100%;
|
||||
font-family: inherit; font-size: 0.95rem; font-weight: 600;
|
||||
background: var(--color-primary); color: var(--color-on-primary);
|
||||
border: none; border-radius: 8px; cursor: pointer;
|
||||
transition: filter 150ms, transform 150ms;
|
||||
}
|
||||
button:hover { filter: brightness(0.94); }
|
||||
button:active { transform: scale(0.98); }
|
||||
button:disabled { opacity: 0.6; cursor: default; }
|
||||
button:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
|
||||
|
||||
button.secondary {
|
||||
width: auto; margin-top: 0; min-height: 36px; padding: 0 14px;
|
||||
background: var(--color-background); color: var(--color-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--color-border); vertical-align: top; }
|
||||
th { color: var(--color-muted-foreground); font-weight: 500; }
|
||||
td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; max-width: 200px; }
|
||||
td.payload-cell { max-width: 360px; white-space: pre-wrap; word-break: break-word; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
.badge {
|
||||
display: inline-block; font-size: 0.72rem; font-weight: 600; padding: 2px 8px;
|
||||
border-radius: 999px; background: var(--color-background); border: 1px solid var(--color-border);
|
||||
}
|
||||
.badge.status-open, .badge.status-confirmed, .badge.status-closed { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
|
||||
.badge.status-pending, .badge.status-drawing, .badge.status-paying_out, .badge.status-closing, .badge.status-broadcast {
|
||||
background: #FEF3C7; color: #92400E; border-color: #F59E0B;
|
||||
}
|
||||
|
||||
.badge.bug-status-open { background: #FEF3C7; color: #92400E; border-color: #F59E0B; }
|
||||
.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); }
|
||||
.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
|
||||
|
||||
.bug-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||
.bug-actions button { width: auto; margin-top: 0; min-height: 30px; padding: 4px 10px; font-size: 0.78rem; }
|
||||
|
||||
button.reveal {
|
||||
width: auto; margin-top: 0; padding: 4px 10px; min-height: 30px; font-size: 0.78rem;
|
||||
background: var(--color-destructive-bg); color: var(--color-destructive); border: 1px solid var(--color-destructive);
|
||||
}
|
||||
|
||||
.privkey-box {
|
||||
margin-top: 6px; padding: 8px; border-radius: 6px; font-size: 0.78rem;
|
||||
background: var(--color-destructive-bg); border: 1px solid var(--color-destructive);
|
||||
word-break: break-all; font-family: 'Fira Code', monospace; color: var(--color-foreground);
|
||||
}
|
||||
.warning-banner {
|
||||
background: var(--color-destructive-bg); border: 1px solid var(--color-destructive); color: var(--color-destructive);
|
||||
border-radius: 8px; padding: 10px 12px; font-size: 0.8rem; margin-bottom: 14px; font-weight: 500;
|
||||
}
|
||||
|
||||
pre.payload {
|
||||
background: var(--color-background); border: 1px solid var(--color-border); border-radius: 6px;
|
||||
padding: 6px 8px; font-size: 0.75rem; margin: 0; white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
|
||||
#toast-container {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; flex-direction: column; gap: 8px; z-index: 100; width: calc(100% - 40px); max-width: 440px;
|
||||
}
|
||||
.toast {
|
||||
padding: 12px 14px; border-radius: 8px; font-size: 0.85rem; font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
|
||||
animation: toast-in 200ms ease-out;
|
||||
}
|
||||
.toast.success { background: var(--color-success-bg); color: var(--color-success); }
|
||||
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation: none !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PLM Lottery — Admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<section id="login-section">
|
||||
<header>
|
||||
<h1>PLM Lottery — Admin</h1>
|
||||
<p>Accesso riservato</p>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<label for="admin-token">Admin token</label>
|
||||
<input id="admin-token" type="password" placeholder="valore di ADMIN_TOKEN" autofocus>
|
||||
<button onclick="adminLogin()" id="login-btn">Accedi</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="dashboard-section" class="hidden">
|
||||
<nav class="navbar">
|
||||
<span class="brand">PLM Lottery — Admin</span>
|
||||
<span class="nav-tab active" id="nav-parametri" onclick="switchView('parametri')">Parametri</span>
|
||||
<span class="nav-tab" id="nav-utenti" onclick="switchView('utenti')">Utenti</span>
|
||||
<span class="nav-tab" id="nav-round" onclick="switchView('round')">Round</span>
|
||||
<span class="nav-tab" id="nav-pending" onclick="switchView('pending')">Transazioni pendenti</span>
|
||||
<span class="nav-tab" id="nav-audit" onclick="switchView('audit')">Audit log</span>
|
||||
<span class="nav-tab" id="nav-bugreports" onclick="switchView('bugreports')">Segnalazioni bug</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="chain-status-pill">
|
||||
<span class="status-dot" id="chain-status-dot"></span>
|
||||
<span id="chain-status-label">Connessione…</span>
|
||||
</span>
|
||||
<span class="chain-block mono" id="chain-block">Blocco —</span>
|
||||
<button class="secondary" style="margin:8px 0 8px 14px" onclick="adminLogout()">Esci</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
|
||||
<div class="view active" id="view-parametri">
|
||||
<h2 class="section-title">Parametri</h2>
|
||||
<p class="hint">Configurazione operativa, salvata nel database — modificabile in qualsiasi momento senza riavviare il server.</p>
|
||||
|
||||
<div class="card" id="maintenance-card">
|
||||
<h2>Manutenzione</h2>
|
||||
<p class="hint" id="maintenance-hint">
|
||||
Interrompe l'apertura di nuovi round dopo quello in corso, senza troncare il round attuale — chiusura,
|
||||
estrazione e pagamento del vincitore avvengono normalmente. Gli utenti vedono un avviso di manutenzione.
|
||||
</p>
|
||||
<div class="row-between">
|
||||
<span class="chain-status-pill">
|
||||
<span class="status-dot" id="maintenance-dot"></span>
|
||||
<span id="maintenance-status-label">—</span>
|
||||
</span>
|
||||
<button id="maintenance-btn" class="secondary" style="width:auto;margin-top:0" onclick="toggleMaintenance()">…</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<label for="admin-fee-address">Fee address (dove finisce il 30% di ogni round)</label>
|
||||
<input id="admin-fee-address" class="mono" placeholder="plm1q...">
|
||||
<label for="admin-bet-amount">Bet amount (PLM)</label>
|
||||
<input id="admin-bet-amount" inputmode="decimal" placeholder="es. 10">
|
||||
</div>
|
||||
<div>
|
||||
<label for="admin-round-duration">Durata round (secondi)</label>
|
||||
<input id="admin-round-duration" inputmode="numeric" placeholder="es. 600">
|
||||
<label for="admin-round-cooldown">Pausa tra un round e il successivo (secondi)</label>
|
||||
<input id="admin-round-cooldown" inputmode="numeric" placeholder="es. 30">
|
||||
<label for="admin-draw-animation">Durata animazione estrazione (secondi)</label>
|
||||
<input id="admin-draw-animation" inputmode="numeric" placeholder="es. 20">
|
||||
<label for="admin-fee-rate">Fee rate di rete (sat/vB)</label>
|
||||
<input id="admin-fee-rate" inputmode="numeric" placeholder="es. 1">
|
||||
<label for="admin-rbf-timeout">Timeout prima del fee-bump RBF (secondi)</label>
|
||||
<input id="admin-rbf-timeout" inputmode="numeric" placeholder="es. 900">
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="adminSave()" id="save-btn">Salva</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-utenti">
|
||||
<h2 class="section-title">Utenti</h2>
|
||||
<p class="hint">Elenco utenti registrati, con saldo interno, accesso alla chiave privata per interventi manuali (es. restituire fondi bloccati) e reset password per chi resta bloccato fuori dall'account.</p>
|
||||
|
||||
<div class="warning-banner">
|
||||
⚠ La chiave privata dà accesso completo ai fondi dell'utente: ogni visualizzazione viene registrata nell'audit log, non condividerla né salvarla altrove. La password esistente di un utente non è mai recuperabile (è salvata solo come hash Argon2) — "Reset" ne genera una nuova al posto della vecchia, anche questo audit-loggato.
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Username</th><th>Indirizzo</th><th>Saldo (PLM)</th><th>Registrato</th><th>Chiave</th><th>Password</th></tr>
|
||||
</thead>
|
||||
<tbody id="users-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-round">
|
||||
<h2 class="section-title">Round</h2>
|
||||
<p class="hint">Ultimi round: stato, vincitore, importi e transazione di payout.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Stato</th><th>Apertura</th><th>Vincitore</th><th>Pool (PLM)</th><th>Vincita (PLM)</th><th>Fee (PLM)</th><th>Payout txid</th></tr>
|
||||
</thead>
|
||||
<tbody id="rounds-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-pending">
|
||||
<h2 class="section-title">Transazioni pendenti</h2>
|
||||
<p class="hint">Bet, payout e prelievi non ancora confermati — candidati al fee-bump RBF se scade il timeout.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Tipo</th><th>Stato</th><th>Txid</th><th>Fee rate</th><th>Tentativi</th><th>Trasmessa</th></tr>
|
||||
</thead>
|
||||
<tbody id="pending-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-audit">
|
||||
<h2 class="section-title">Audit log</h2>
|
||||
<p class="hint">Ultimi eventi registrati dal sistema (config, bet, payout, accessi a chiavi private, ecc.).</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Evento</th><th>Dettagli</th><th>Utente</th><th>Round</th><th>Quando</th></tr>
|
||||
</thead>
|
||||
<tbody id="audit-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-bugreports">
|
||||
<h2 class="section-title">Segnalazioni bug</h2>
|
||||
<p class="hint">Segnalazioni inviate dagli utenti tramite la pagina "Segnala un bug".</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Descrizione</th><th>Contatto</th><th>Utente</th><th>Quando</th><th>Stato</th></tr>
|
||||
</thead>
|
||||
<tbody id="bugreports-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast-container" aria-live="polite"></div>
|
||||
|
||||
<script src="/admin.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,443 @@
|
||||
const SATS_PER_PLM = 100000000;
|
||||
|
||||
// Display formatter: a raw sats/SATS_PER_PLM division renders binary
|
||||
// floating-point artefacts (0.7000000000000001) in the tables below (B-22).
|
||||
// Input fields keep the raw value — they have to stay parseable.
|
||||
function fmtPlm(sats) {
|
||||
if (sats === null || sats === undefined) return '—';
|
||||
return new Intl.NumberFormat('it-IT', { maximumFractionDigits: 8 }).format(sats / SATS_PER_PLM);
|
||||
}
|
||||
let adminToken = sessionStorage.getItem('plm_admin_token');
|
||||
|
||||
function toast(message, type) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
el.textContent = message;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
async function withLoading(button, label, fn) {
|
||||
const original = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = label;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = original;
|
||||
}
|
||||
}
|
||||
|
||||
async function callAdmin(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken };
|
||||
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
// detail is a bare string on the admin endpoints, but the shared dependencies
|
||||
// (auth) answer with the structured {code, message} form of app/api/errors.py.
|
||||
if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
function badge(status) {
|
||||
return `<span class="badge status-${escapeHtml(status)}">${escapeHtml(status)}</span>`;
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('it-IT');
|
||||
}
|
||||
|
||||
const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out'];
|
||||
const CHAIN_STATUS_LABELS = {
|
||||
waiting: 'In attesa del prossimo round',
|
||||
open: 'Round aperto',
|
||||
drawing: 'Estrazione in corso',
|
||||
};
|
||||
let chainStatusInterval = null;
|
||||
|
||||
async function refreshChainStatus() {
|
||||
try {
|
||||
const res = await fetch('/rounds/current');
|
||||
const data = await res.json();
|
||||
let statusKey;
|
||||
if (!data.round_id) statusKey = 'waiting';
|
||||
else if (DRAWING_STATUSES.includes(data.status)) statusKey = 'drawing';
|
||||
else statusKey = 'open';
|
||||
document.getElementById('chain-status-dot').className = 'status-dot status-' + statusKey;
|
||||
document.getElementById('chain-status-label').textContent = CHAIN_STATUS_LABELS[statusKey];
|
||||
document.getElementById('chain-block').textContent =
|
||||
'Blocco ' + (data.chain_tip_height != null ? '#' + data.chain_tip_height : '—');
|
||||
} catch (e) {
|
||||
// leave the last-known status on screen rather than blanking it out
|
||||
}
|
||||
}
|
||||
|
||||
function startChainStatusPolling() {
|
||||
refreshChainStatus();
|
||||
clearInterval(chainStatusInterval);
|
||||
chainStatusInterval = setInterval(refreshChainStatus, 15000);
|
||||
}
|
||||
|
||||
function stopChainStatusPolling() {
|
||||
clearInterval(chainStatusInterval);
|
||||
chainStatusInterval = null;
|
||||
}
|
||||
|
||||
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit', 'bugreports'];
|
||||
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports };
|
||||
let currentAdminView = 'parametri';
|
||||
|
||||
function switchView(name) {
|
||||
currentAdminView = name;
|
||||
for (const key of VIEWS) {
|
||||
document.getElementById('nav-' + key).classList.toggle('active', key === name);
|
||||
document.getElementById('view-' + key).classList.toggle('active', key === name);
|
||||
}
|
||||
if (VIEW_LOADERS[name]) VIEW_LOADERS[name]();
|
||||
}
|
||||
|
||||
function showDashboard() {
|
||||
document.getElementById('login-section').classList.add('hidden');
|
||||
document.getElementById('dashboard-section').classList.remove('hidden');
|
||||
startChainStatusPolling();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog(), loadBugReports()]);
|
||||
}
|
||||
|
||||
async function adminLogin() {
|
||||
const btn = document.getElementById('login-btn');
|
||||
adminToken = document.getElementById('admin-token').value;
|
||||
await withLoading(btn, 'Verifica…', async () => {
|
||||
try {
|
||||
await callAdmin('GET', '/admin/config');
|
||||
sessionStorage.setItem('plm_admin_token', adminToken);
|
||||
showDashboard();
|
||||
await loadDashboard();
|
||||
} catch (e) {
|
||||
adminToken = null;
|
||||
toast('Token non valido.', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function adminLogout() {
|
||||
stopChainStatusPolling();
|
||||
sessionStorage.removeItem('plm_admin_token');
|
||||
adminToken = null;
|
||||
document.getElementById('admin-token').value = '';
|
||||
document.getElementById('dashboard-section').classList.add('hidden');
|
||||
document.getElementById('login-section').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function adminLoadConfig() {
|
||||
try {
|
||||
const data = await callAdmin('GET', '/admin/config');
|
||||
document.getElementById('admin-fee-address').value = data.fee_address;
|
||||
document.getElementById('admin-bet-amount').value = data.bet_amount_sats / SATS_PER_PLM;
|
||||
document.getElementById('admin-round-duration').value = data.round_duration_seconds;
|
||||
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
|
||||
document.getElementById('admin-draw-animation').value = data.draw_animation_seconds;
|
||||
document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb;
|
||||
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
|
||||
renderMaintenanceState(data.paused);
|
||||
} catch (e) {
|
||||
toast('Errore nel caricamento configurazione: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderMaintenanceState(paused) {
|
||||
const dot = document.getElementById('maintenance-dot');
|
||||
const label = document.getElementById('maintenance-status-label');
|
||||
const btn = document.getElementById('maintenance-btn');
|
||||
btn.dataset.paused = paused ? '1' : '0';
|
||||
if (paused) {
|
||||
dot.className = 'status-dot status-paused';
|
||||
label.textContent = 'In pausa: nessun nuovo round verrà aperto';
|
||||
btn.textContent = 'Riprendi lotteria';
|
||||
btn.classList.remove('btn-stop');
|
||||
} else {
|
||||
dot.className = 'status-dot status-open';
|
||||
label.textContent = 'Attiva: i round si susseguono normalmente';
|
||||
btn.textContent = 'Interrompi dopo questo round';
|
||||
btn.classList.add('btn-stop');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMaintenance() {
|
||||
const btn = document.getElementById('maintenance-btn');
|
||||
const isPaused = btn.dataset.paused === '1';
|
||||
const path = isPaused ? '/admin/resume' : '/admin/pause';
|
||||
if (!isPaused && !window.confirm(
|
||||
"Nessun nuovo round verrà aperto dopo quello in corso, fino a quando non riprendi la lotteria. " +
|
||||
"Il round attuale (se presente) verrà comunque completato e il vincitore pagato. Continuare?"
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const data = await callAdmin('POST', path, {});
|
||||
renderMaintenanceState(data.paused);
|
||||
toast(data.paused ? 'Lotteria in pausa.' : 'Lotteria ripresa.', 'success');
|
||||
refreshChainStatus();
|
||||
} catch (e) {
|
||||
toast('Errore: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function adminSave() {
|
||||
const btn = document.getElementById('save-btn');
|
||||
const feeAddress = document.getElementById('admin-fee-address').value;
|
||||
const betAmountPlm = parseFloat(document.getElementById('admin-bet-amount').value);
|
||||
const body = {
|
||||
fee_address: feeAddress,
|
||||
bet_amount_sats: Math.round(betAmountPlm * SATS_PER_PLM),
|
||||
round_duration_seconds: parseInt(document.getElementById('admin-round-duration').value, 10),
|
||||
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
|
||||
draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10),
|
||||
fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10),
|
||||
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
|
||||
};
|
||||
await withLoading(btn, 'Salvataggio…', async () => {
|
||||
try {
|
||||
await callAdmin('PUT', '/admin/config', body);
|
||||
toast('Configurazione salvata.', 'success');
|
||||
} catch (e) {
|
||||
toast('Errore nel salvataggio: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const users = await callAdmin('GET', '/admin/users');
|
||||
const tbody = document.getElementById('users-tbody');
|
||||
tbody.innerHTML = users.map((u) => `
|
||||
<tr>
|
||||
<td>${u.id}</td>
|
||||
<td>${escapeHtml(u.username)}</td>
|
||||
<td class="addr">${escapeHtml(u.address)}</td>
|
||||
<td>${fmtPlm(u.balance_sats)}</td>
|
||||
<td>${fmtDate(u.created_at)}</td>
|
||||
<td>
|
||||
<button class="reveal" onclick="revealPrivkey(${u.id}, this)">Mostra</button>
|
||||
<div class="privkey-box hidden" id="privkey-${u.id}"></div>
|
||||
</td>
|
||||
<td>
|
||||
<button class="secondary" style="width:auto;margin-top:0;min-height:30px;padding:4px 10px;font-size:0.78rem" onclick="resetUserPassword(${u.id}, this)">Reset</button>
|
||||
<div class="privkey-box hidden" id="newpass-${u.id}"></div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="7" class="hint">Nessun utente registrato.</td></tr>';
|
||||
} catch (e) {
|
||||
toast('Errore nel caricamento utenti: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function revealPrivkey(userId, button) {
|
||||
const box = document.getElementById('privkey-' + userId);
|
||||
if (!box.classList.contains('hidden')) {
|
||||
box.classList.add('hidden');
|
||||
box.textContent = '';
|
||||
button.textContent = 'Mostra';
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('Stai per visualizzare la chiave privata di questo utente. L\'accesso verrà registrato nell\'audit log. Continuare?')) {
|
||||
return;
|
||||
}
|
||||
await withLoading(button, '…', async () => {
|
||||
try {
|
||||
const data = await callAdmin('GET', '/admin/users/' + userId + '/privkey');
|
||||
box.textContent = data.wif;
|
||||
box.classList.remove('hidden');
|
||||
button.textContent = 'Nascondi';
|
||||
} catch (e) {
|
||||
toast('Errore: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function resetUserPassword(userId, button) {
|
||||
if (!window.confirm(
|
||||
"Verrà generata una nuova password casuale per questo utente, che non potrà più accedere con quella vecchia. " +
|
||||
"L'azione viene registrata nell'audit log. Continuare?"
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
const box = document.getElementById('newpass-' + userId);
|
||||
await withLoading(button, '…', async () => {
|
||||
try {
|
||||
const data = await callAdmin('POST', '/admin/users/' + userId + '/reset-password');
|
||||
box.textContent = 'Nuova password per ' + data.username + ': ' + data.new_password;
|
||||
box.classList.remove('hidden');
|
||||
toast('Password reimpostata.', 'success');
|
||||
} catch (e) {
|
||||
toast('Errore: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRounds() {
|
||||
try {
|
||||
const rounds = await callAdmin('GET', '/admin/rounds');
|
||||
const tbody = document.getElementById('rounds-tbody');
|
||||
tbody.innerHTML = rounds.map((r) => `
|
||||
<tr>
|
||||
<td>${r.id}</td>
|
||||
<td>${badge(r.status)}</td>
|
||||
<td>${fmtDate(r.opened_at)}</td>
|
||||
<td>${r.winner_username ? escapeHtml(r.winner_username) : '—'}</td>
|
||||
<td>${fmtPlm(r.pool_amount_sats)}</td>
|
||||
<td>${fmtPlm(r.winner_amount_sats)}</td>
|
||||
<td>${fmtPlm(r.fee_amount_sats)}</td>
|
||||
<td class="txid">${r.payout_txid ? escapeHtml(r.payout_txid) : '—'}</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="8" class="hint">Nessun round ancora.</td></tr>';
|
||||
} catch (e) {
|
||||
toast('Errore nel caricamento round: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPending() {
|
||||
try {
|
||||
const items = await callAdmin('GET', '/admin/pending-transactions');
|
||||
const tbody = document.getElementById('pending-tbody');
|
||||
tbody.innerHTML = items.map((p) => `
|
||||
<tr>
|
||||
<td>${p.id}</td>
|
||||
<td>${escapeHtml(p.kind)}</td>
|
||||
<td>${badge(p.status)}</td>
|
||||
<td class="txid">${escapeHtml(p.current_txid)}</td>
|
||||
<td>${p.fee_rate_sat_vb} sat/vB</td>
|
||||
<td>${p.attempt_count}</td>
|
||||
<td>${fmtDate(p.broadcast_at)}</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="7" class="hint">Nessuna transazione pendente.</td></tr>';
|
||||
} catch (e) {
|
||||
toast('Errore nel caricamento transazioni pendenti: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuditLog() {
|
||||
try {
|
||||
const entries = await callAdmin('GET', '/admin/audit-log');
|
||||
const tbody = document.getElementById('audit-tbody');
|
||||
tbody.innerHTML = entries.map((e) => `
|
||||
<tr>
|
||||
<td>${e.id}</td>
|
||||
<td>${escapeHtml(e.event_type)}</td>
|
||||
<td><pre class="payload">${escapeHtml(JSON.stringify(e.payload))}</pre></td>
|
||||
<td>${e.user_id ?? '—'}</td>
|
||||
<td>${e.round_id ?? '—'}</td>
|
||||
<td>${fmtDate(e.created_at)}</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="6" class="hint">Nessun evento registrato.</td></tr>';
|
||||
} catch (e) {
|
||||
toast('Errore nel caricamento audit log: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const BUG_REPORT_STATUS_LABELS = { open: 'Da leggere', read: 'Presa in carico', resolved: 'Risolta' };
|
||||
|
||||
function bugReportBadge(status) {
|
||||
return `<span class="badge bug-status-${escapeHtml(status)}">${escapeHtml(BUG_REPORT_STATUS_LABELS[status] || status)}</span>`;
|
||||
}
|
||||
|
||||
async function loadBugReports() {
|
||||
try {
|
||||
const reports = await callAdmin('GET', '/admin/bug-reports');
|
||||
const tbody = document.getElementById('bugreports-tbody');
|
||||
tbody.innerHTML = reports.map((r) => `
|
||||
<tr>
|
||||
<td>${r.id}</td>
|
||||
<td class="payload-cell">${escapeHtml(r.description)}</td>
|
||||
<td>${r.contact ? escapeHtml(r.contact) : '—'}</td>
|
||||
<td>${r.username ? escapeHtml(r.username) : '—'}</td>
|
||||
<td>${fmtDate(r.created_at)}</td>
|
||||
<td>
|
||||
${bugReportBadge(r.status)}
|
||||
<div class="bug-actions">
|
||||
${r.status === 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'read', this)">Segna come presa in carico</button>` : ''}
|
||||
${r.status !== 'resolved' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'resolved', this)">Segna come risolta</button>` : ''}
|
||||
${r.status !== 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'open', this)">Riapri</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="6" class="hint">Nessuna segnalazione ricevuta.</td></tr>';
|
||||
} catch (e) {
|
||||
toast('Errore nel caricamento segnalazioni: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function setBugReportStatus(reportId, newStatus, button) {
|
||||
await withLoading(button, '…', async () => {
|
||||
try {
|
||||
await callAdmin('POST', '/admin/bug-reports/' + reportId + '/status', { status: newStatus });
|
||||
await loadBugReports();
|
||||
} catch (e) {
|
||||
toast('Errore: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('admin-token').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') adminLogin();
|
||||
});
|
||||
|
||||
function initAuthState() {
|
||||
adminToken = sessionStorage.getItem('plm_admin_token');
|
||||
if (!adminToken) {
|
||||
document.getElementById('dashboard-section').classList.add('hidden');
|
||||
document.getElementById('login-section').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
callAdmin('GET', '/admin/config')
|
||||
.then(() => { showDashboard(); return loadDashboard(); })
|
||||
.catch(() => adminLogout());
|
||||
}
|
||||
|
||||
// Bfcache can restore a frozen snapshot of this page (DOM/JS state as it was
|
||||
// before navigating away) without re-running any of this script — so a stale
|
||||
// view could survive across back/forward navigation, e.g. showing a dashboard
|
||||
// for a token that's since been rotated or explicitly logged out of. Cache-
|
||||
// Control: no-store on this response should already prevent that, but
|
||||
// re-validate here too as a safety net for browsers that ignore it.
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (event.persisted) initAuthState();
|
||||
});
|
||||
|
||||
// Same server-push channel as app/static/index.html (see app/rounds/events.py):
|
||||
// a content-free "something changed" ping. Here it refreshes the chain-status
|
||||
// bar immediately, and reloads whichever admin section is currently open
|
||||
// (Utenti/Round/Transazioni pendenti/Audit log) so it doesn't need a manual
|
||||
// switch-away-and-back to pick up a new row. Polling stays in place as a
|
||||
// fallback if this connection is ever blocked or drops.
|
||||
let adminEventSource = null;
|
||||
|
||||
function onAdminServerEvent() {
|
||||
if (!adminToken) return;
|
||||
refreshChainStatus();
|
||||
if (VIEW_LOADERS[currentAdminView]) VIEW_LOADERS[currentAdminView]();
|
||||
}
|
||||
|
||||
function connectAdminEvents() {
|
||||
if (adminEventSource) return;
|
||||
adminEventSource = new EventSource('/rounds/stream');
|
||||
adminEventSource.addEventListener('update', onAdminServerEvent);
|
||||
// Fires on the initial connection AND every successful auto-reconnect —
|
||||
// re-syncs immediately instead of waiting for the next event or poll tick
|
||||
// to notice whatever changed while this connection was down.
|
||||
adminEventSource.addEventListener('open', onAdminServerEvent);
|
||||
}
|
||||
|
||||
connectAdminEvents();
|
||||
initAuthState();
|
||||
@@ -0,0 +1,876 @@
|
||||
const SATS_PER_PLM = 100000000;
|
||||
|
||||
// Every amount displayed goes through here. A bare sats/SATS_PER_PLM division
|
||||
// leaks binary floating-point artefacts into the UI — a 0.7 PLM jackpot rendering
|
||||
// as 0.7000000000000001 (B-22). Trailing zeros are trimmed so ordinary amounts
|
||||
// stay readable, and grouping follows the selected language.
|
||||
// Amounts sent *to* the server must NOT use this — they keep going through
|
||||
// Math.round(x * SATS_PER_PLM), since this returns a formatted string.
|
||||
function formatPlm(sats) {
|
||||
if (sats === null || sats === undefined || Number.isNaN(sats)) return '—';
|
||||
return new Intl.NumberFormat(currentDateLocale(), {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 8,
|
||||
}).format(sats / SATS_PER_PLM);
|
||||
}
|
||||
|
||||
let token = localStorage.getItem('plm_token');
|
||||
let username = localStorage.getItem('plm_username');
|
||||
let address = localStorage.getItem('plm_address');
|
||||
|
||||
function toast(message, type) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
el.textContent = message;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
// innerHTML, not textContent: several of these buttons wrap an <svg> icon and a
|
||||
// <span data-i18n=...>, both of which a textContent round-trip would flatten away
|
||||
// — losing the icon for good and, worse, stripping the data-i18n hook so the
|
||||
// button would stop following later language changes.
|
||||
//
|
||||
// Only the outermost call owns the markup. refreshMe() is fired from the SSE
|
||||
// handler, the poll chain, placeBet, withdraw and showDashboard, all sharing
|
||||
// #refresh-btn: two overlapping calls used to make the second one snapshot the
|
||||
// *loading* label and then restore it permanently, leaving the button stuck on
|
||||
// "Aggiornamento…" (B-23). A nested call now just awaits the one already running.
|
||||
const _loadingByButton = new WeakMap();
|
||||
|
||||
async function withLoading(button, label, fn) {
|
||||
const inFlight = _loadingByButton.get(button);
|
||||
if (inFlight) {
|
||||
await inFlight.catch(() => {}); // its own caller reports the failure
|
||||
return fn();
|
||||
}
|
||||
const original = button.innerHTML;
|
||||
button.disabled = true;
|
||||
button.textContent = label;
|
||||
const run = (async () => {
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.innerHTML = original;
|
||||
applyStaticTranslations(button); // the snapshot may predate a language switch made while loading
|
||||
_loadingByButton.delete(button);
|
||||
}
|
||||
})();
|
||||
_loadingByButton.set(button, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
// Without a timeout, a single request that never resolves (server-side hang —
|
||||
// stuck DB session, unresponsive Electrum connection...) would stall the whole
|
||||
// sequential polling chain forever: the UI just freezes on whatever was last
|
||||
// rendered, with no error and no "connessione persa" (that only fires on a
|
||||
// rejected fetch, never on one that's merely stuck).
|
||||
async function call(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal });
|
||||
} catch (e) {
|
||||
throw new Error(e.name === 'AbortError' ? t('toast.requestTimeout') : e.message);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
// A token the server no longer accepts can't be recovered from by retrying:
|
||||
// without this every poll keeps failing against a dashboard that still looks
|
||||
// logged in, toasting "session expired" forever. Drop back to the login form.
|
||||
if (res.status === 401 && data.detail?.code === 'session_expired' && token) logout();
|
||||
throw new Error(apiErrorMessage(data.detail) || res.statusText);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// The API is single-language by design: it answers with a stable machine code
|
||||
// plus an English message (app/api/errors.py), and picking the words is the
|
||||
// client's job. Unknown code (older/newer server, an endpoint not converted
|
||||
// yet) → show the English message rather than nothing.
|
||||
function apiErrorMessage(detail) {
|
||||
if (!detail) return null;
|
||||
if (typeof detail === 'string') return detail; // endpoints still returning a bare string
|
||||
// FastAPI's own request-validation failures (422) use a list of field errors
|
||||
// instead, in English and phrased for an API client ("Input should be a valid
|
||||
// integer"). Nothing here can act on which field it was, so say the one useful
|
||||
// thing — the request was malformed — in the user's language.
|
||||
if (Array.isArray(detail)) return t('error.invalid_request');
|
||||
return tOrNull('error.' + detail.code, errorParams(detail.params)) || detail.message || null;
|
||||
}
|
||||
|
||||
// Amounts cross the wire in sats (`*_sats`); every translated string wants PLM,
|
||||
// so expose both and let each language's phrasing pick. Done generically here
|
||||
// so a new *_sats param needs no client change.
|
||||
function errorParams(params) {
|
||||
const out = { ...(params || {}) };
|
||||
for (const [key, value] of Object.entries(params || {})) {
|
||||
if (key.endsWith('_sats') && typeof value === 'number') {
|
||||
out[key.slice(0, -5) + '_plm'] = formatPlm(value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function switchTab(name) {
|
||||
document.getElementById('tab-login').classList.toggle('active', name === 'login');
|
||||
document.getElementById('tab-register').classList.toggle('active', name === 'register');
|
||||
document.getElementById('panel-login').classList.toggle('active', name === 'login');
|
||||
document.getElementById('panel-register').classList.toggle('active', name === 'register');
|
||||
}
|
||||
|
||||
function switchPanel(name) {
|
||||
for (const key of ['deposit', 'bet', 'withdraw', 'profile']) {
|
||||
document.getElementById('nav-' + key).classList.toggle('active', key === name);
|
||||
document.getElementById('panel-' + key).classList.toggle('active', key === name);
|
||||
}
|
||||
}
|
||||
|
||||
// Bumped on every logout/login so an in-flight refreshRound() started under a
|
||||
// previous session can detect it's now stale — a fetch can still be awaiting
|
||||
// its response after logout() clears the timeout-based poll chain, and without
|
||||
// this guard it would re-arm scheduleNextRoundPoll() and resurrect a "zombie"
|
||||
// dashboard poll running in parallel with the logged-out chain-only poll.
|
||||
let sessionEpoch = 0;
|
||||
let roundCloseAt = null;
|
||||
let serverTimeOffsetMs = 0; // serverNow - clientNow, so every client's countdown agrees regardless of local clock skew
|
||||
function serverNow() { return new Date(Date.now() + serverTimeOffsetMs); }
|
||||
// refreshRound() is triggered from several independent sources (poll timer, timer-hits-zero,
|
||||
// visibilitychange, placeBet, showDashboard) whose requests can resolve out of order over the
|
||||
// network. Track the latest applied response so a slow, stale one can never revert the UI to an
|
||||
// older round's state after a newer response has already moved it forward.
|
||||
let roundRequestSeq = 0;
|
||||
let roundAppliedSeq = 0;
|
||||
let roundTimerInterval = null;
|
||||
let roundPollTimeout = null;
|
||||
let lastResultInterval = null;
|
||||
|
||||
const ROUND_STATUS_KEYS = {
|
||||
open: 'round.status.open',
|
||||
closing: 'round.status.closing',
|
||||
drawing: 'round.status.drawing',
|
||||
paying_out: 'round.status.paying_out',
|
||||
};
|
||||
|
||||
const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out'];
|
||||
|
||||
// One distinct message per DRAW sub-phase (see CLAUDE.md's "three separate
|
||||
// on-chain confirmations" note) instead of a single generic spinner label —
|
||||
// takes the round data so the drawing phase can surface the draw block once known.
|
||||
function drawingLabelFor(data) {
|
||||
if (data.status === 'closing') {
|
||||
return t('draw.closing');
|
||||
}
|
||||
if (data.status === 'drawing') {
|
||||
return t('draw.drawing');
|
||||
}
|
||||
// paying_out
|
||||
if (data.draw_block_height != null) {
|
||||
return t('draw.payingOutBlock', { height: data.draw_block_height });
|
||||
}
|
||||
return t('draw.payingOut');
|
||||
}
|
||||
|
||||
// One label per real round status, not just the coarse open/drawing/waiting
|
||||
// grouping — the status bar should show the same phase distinction as the
|
||||
// draw-state panel (drawingLabelFor above), just condensed to a short phrase.
|
||||
const CHAIN_STATUS_KEYS = {
|
||||
waiting: 'chain.status.waiting',
|
||||
open: 'chain.status.open',
|
||||
closing: 'chain.status.closing',
|
||||
drawing: 'chain.status.drawing',
|
||||
paying_out: 'chain.status.paying_out',
|
||||
};
|
||||
|
||||
// The bar is rendered from remembered state rather than straight from the
|
||||
// response that triggered it, so a language switch can repaint it immediately
|
||||
// instead of waiting for the next poll. That wait used to make it lie: with the
|
||||
// connection down, switching language reset the label to "connecting" until a
|
||||
// further fetch failed.
|
||||
let lastChainData = null;
|
||||
let chainOffline = false;
|
||||
|
||||
function updateChainStatusBar(data) {
|
||||
lastChainData = data;
|
||||
chainOffline = false;
|
||||
renderChainStatusBar();
|
||||
}
|
||||
|
||||
function renderChainStatusBar() {
|
||||
const dot = document.getElementById('chain-status-dot');
|
||||
const label = document.getElementById('chain-status-label');
|
||||
const block = document.getElementById('chain-block');
|
||||
|
||||
if (chainOffline) {
|
||||
dot.className = 'status-dot status-offline';
|
||||
label.textContent = t('chain.connectionLost');
|
||||
return; // block height deliberately left showing its last known value
|
||||
}
|
||||
if (lastChainData === null) {
|
||||
label.textContent = t('chain.connecting');
|
||||
return;
|
||||
}
|
||||
const data = lastChainData;
|
||||
|
||||
// The dot's color/pulse only distinguishes waiting/open/drawing (that's all
|
||||
// the CSS defines) — closing and paying_out both pulse like drawing, they
|
||||
// just get their own text label below.
|
||||
let dotKey;
|
||||
if (!data.round_id) dotKey = 'waiting';
|
||||
else if (DRAWING_STATUSES.includes(data.status)) dotKey = 'drawing';
|
||||
else dotKey = 'open';
|
||||
|
||||
const labelKey = data.round_id && data.status in CHAIN_STATUS_KEYS ? data.status : 'waiting';
|
||||
|
||||
dot.className = 'status-dot status-' + dotKey;
|
||||
label.textContent = t(CHAIN_STATUS_KEYS[labelKey]);
|
||||
block.textContent = t('chain.block', { n: data.chain_tip_height != null ? '#' + data.chain_tip_height : '—' });
|
||||
|
||||
document.getElementById('maintenance-banner').classList.toggle('hidden', !data.lottery_paused);
|
||||
}
|
||||
|
||||
// After a couple of consecutive failed polls (network blip, server restart,
|
||||
// tab suspended too long...), say so explicitly instead of silently leaving
|
||||
// whatever status happened to be on screen — a frozen "Round aperto" that's
|
||||
// actually minutes stale is worse than an honest "connessione persa".
|
||||
const STALE_AFTER_FAILURES = 2;
|
||||
let consecutiveFetchFailures = 0;
|
||||
|
||||
function showConnectionLost() {
|
||||
chainOffline = true;
|
||||
renderChainStatusBar();
|
||||
}
|
||||
|
||||
function noteFetchOutcome(ok) {
|
||||
if (ok) {
|
||||
consecutiveFetchFailures = 0;
|
||||
return;
|
||||
}
|
||||
consecutiveFetchFailures++;
|
||||
if (consecutiveFetchFailures >= STALE_AFTER_FAILURES) showConnectionLost();
|
||||
}
|
||||
|
||||
let chainOnlyInterval = null;
|
||||
|
||||
async function refreshChainStatusOnly() {
|
||||
try {
|
||||
const data = await call('GET', '/rounds/current');
|
||||
updateChainStatusBar(data);
|
||||
noteFetchOutcome(true);
|
||||
} catch (e) {
|
||||
noteFetchOutcome(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startChainOnlyPolling() {
|
||||
refreshChainStatusOnly();
|
||||
clearInterval(chainOnlyInterval);
|
||||
chainOnlyInterval = setInterval(refreshChainStatusOnly, 15000);
|
||||
}
|
||||
|
||||
function stopChainOnlyPolling() {
|
||||
clearInterval(chainOnlyInterval);
|
||||
chainOnlyInterval = null;
|
||||
}
|
||||
|
||||
// Background tabs get their timers throttled hard by the browser (sometimes to
|
||||
// once a minute or less) — waiting for the next lazy tick after the user comes
|
||||
// back could show a stale round state for a while. Refresh immediately instead
|
||||
// as soon as the tab becomes visible again.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (chainOnlyInterval !== null) {
|
||||
refreshChainStatusOnly();
|
||||
} else if (token) {
|
||||
refreshRound();
|
||||
checkLastRoundResult();
|
||||
}
|
||||
});
|
||||
|
||||
// The win/lose box's content lives in localStorage, not just in-memory state —
|
||||
// a page reload (or a completely fresh tab) must be able to redraw it exactly
|
||||
// as it was, without waiting for a new poll or re-running the reveal
|
||||
// animation. This is the single source of truth for "what result box (if any)
|
||||
// is currently shown"; refreshRound() and checkLastRoundResult() below both
|
||||
// read/write it instead of keeping their own separate notion of "revealed".
|
||||
const PERSISTED_RESULT_KEY = 'plm_persisted_result';
|
||||
|
||||
function getPersistedResult() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(PERSISTED_RESULT_KEY));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistResult(roundId, won, amountSats) {
|
||||
localStorage.setItem(PERSISTED_RESULT_KEY, JSON.stringify({ round_id: roundId, won, amount_sats: amountSats }));
|
||||
}
|
||||
|
||||
function clearPersistedResult() {
|
||||
localStorage.removeItem(PERSISTED_RESULT_KEY);
|
||||
}
|
||||
|
||||
function renderPersistedResult(result) {
|
||||
setRoundInfoVisible(false);
|
||||
setResultBoxVisible(
|
||||
true,
|
||||
result.won ? t('result.win', { amount: formatPlm(result.amount_sats) }) : t('result.lose'),
|
||||
result.won ? 'win' : 'lose'
|
||||
);
|
||||
}
|
||||
|
||||
// The most recent round_id refreshRound() actually saw from the server (null
|
||||
// meaning "confirmed no active round"; undefined meaning "haven't polled yet").
|
||||
// Lets checkLastRoundResult() below avoid clobbering a round that's already
|
||||
// known to be open/in-progress by the time its own (slower, DB-backed) request
|
||||
// resolves.
|
||||
let currentRoundIdSeen;
|
||||
|
||||
// Backstop for the live reveal in refreshRound(): that one only works if a poll
|
||||
// happens to land while the round is still "paying_out" (winner_user_id is
|
||||
// dropped from /rounds/current the instant the round flips to "closed" — see
|
||||
// rounds/service.get_active_round). A backgrounded tab, a missed poll, or a
|
||||
// late page load can miss that window entirely, in which case the live path
|
||||
// never fires and the player would otherwise never learn the outcome. This
|
||||
// reads GET /users/me/last-round-result, which reports the durable DB record
|
||||
// instead of an ephemeral snapshot, so it always catches up eventually.
|
||||
async function checkLastRoundResult() {
|
||||
if (!token) return;
|
||||
let data;
|
||||
try {
|
||||
data = await call('GET', '/users/me/last-round-result');
|
||||
} catch (e) {
|
||||
return; // silent — this is a backstop, refreshRound()'s own error handling already covers the primary path
|
||||
}
|
||||
if (data.round_id == null) return;
|
||||
const persisted = getPersistedResult();
|
||||
if (persisted && persisted.round_id === data.round_id) return; // already showing/known
|
||||
if (currentRoundIdSeen != null && currentRoundIdSeen !== data.round_id) return; // a newer round is already in progress on screen
|
||||
|
||||
persistResult(data.round_id, data.won, data.amount_sats);
|
||||
renderPersistedResult({ won: data.won, amount_sats: data.amount_sats });
|
||||
if (data.won) {
|
||||
const won = formatPlm(data.amount_sats);
|
||||
toast(t('toast.roundWon', { id: data.round_id, amount: won }), 'success');
|
||||
refreshMe();
|
||||
}
|
||||
}
|
||||
|
||||
let lastJackpotValue = null;
|
||||
|
||||
let timerHitZero = false;
|
||||
|
||||
function updateRoundTimer() {
|
||||
const el = document.getElementById('round-timer');
|
||||
if (!roundCloseAt) { el.textContent = '--:--'; timerHitZero = false; return; }
|
||||
const rawSec = Math.floor((roundCloseAt - serverNow()) / 1000);
|
||||
const totalSec = Math.max(0, rawSec);
|
||||
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
|
||||
const ss = String(totalSec % 60).padStart(2, '0');
|
||||
el.textContent = mm + ':' + ss;
|
||||
|
||||
// The countdown alone can't know the round actually closed server-side — poll
|
||||
// right away instead of waiting up to 15s for the next scheduled tick, so the
|
||||
// card doesn't sit on "00:00 · aperto" longer than necessary.
|
||||
if (rawSec <= 0 && !timerHitZero) {
|
||||
timerHitZero = true;
|
||||
refreshRound();
|
||||
} else if (rawSec > 0) {
|
||||
timerHitZero = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The round's normal info (title/timer/players/jackpot) vs. the drawing-phase
|
||||
// spinner box vs. the personalized win/lose box are three independently
|
||||
// toggled pieces, not three mutually-exclusive "screens" — during closing/
|
||||
// drawing/paying_out, EVERY viewer sees the drawing box (generic phase
|
||||
// progress), and a player who bet in that round ALSO sees the win/lose box at
|
||||
// the same time once revealed, instead of the two fighting over one slot.
|
||||
function setRoundInfoVisible(show) {
|
||||
document.getElementById('round-normal-row').classList.toggle('hidden', !show);
|
||||
document.getElementById('round-stats-row').classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
function setDrawingBoxVisible(show, label) {
|
||||
document.getElementById('draw-state').classList.toggle('active', show);
|
||||
if (show && label) document.getElementById('draw-label').textContent = label;
|
||||
}
|
||||
|
||||
function setResultBoxVisible(show, html, cls) {
|
||||
const el = document.getElementById('draw-result');
|
||||
if (show) {
|
||||
el.className = 'draw-result ' + cls;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
el.classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
// RoundConfig.bet_amount_sats is admin-editable at runtime, so the button label
|
||||
// can't be a fixed "(10 PLM)" string in the translation files — it's rendered
|
||||
// from whatever /rounds/current last reported, in the current language.
|
||||
let betAmountSats = null;
|
||||
|
||||
function renderBetButton() {
|
||||
const btn = document.getElementById('bet-btn');
|
||||
// Skipped while the button is showing its loading label: withLoading restores
|
||||
// the pre-click markup on its own, and the next poll re-renders anyway.
|
||||
if (btn.disabled) return;
|
||||
btn.textContent = betAmountSats === null
|
||||
? t('bet.buttonNoAmount')
|
||||
: t('bet.button', { amount: formatPlm(betAmountSats) });
|
||||
}
|
||||
|
||||
function showNormalState() {
|
||||
setRoundInfoVisible(true);
|
||||
setDrawingBoxVisible(false);
|
||||
setResultBoxVisible(false);
|
||||
}
|
||||
|
||||
async function refreshRound() {
|
||||
const seq = ++roundRequestSeq;
|
||||
const epoch = sessionEpoch;
|
||||
try {
|
||||
const data = await call('GET', '/rounds/current');
|
||||
if (epoch !== sessionEpoch) return; // session ended (or a new one started) while this was in flight
|
||||
if (seq < roundAppliedSeq) return; // a newer refreshRound() call already applied its result
|
||||
roundAppliedSeq = seq;
|
||||
noteFetchOutcome(true);
|
||||
updateChainStatusBar(data);
|
||||
document.getElementById('round-title').textContent = data.round_id
|
||||
? t('round.title', { id: data.round_id, status: data.status in ROUND_STATUS_KEYS ? t(ROUND_STATUS_KEYS[data.status]) : data.status })
|
||||
: t('round.none');
|
||||
betAmountSats = data.bet_amount_sats;
|
||||
renderBetButton();
|
||||
document.getElementById('round-players').textContent = data.participant_count;
|
||||
const jackpotEl = document.getElementById('round-jackpot');
|
||||
const jackpotValue = data.jackpot_sats / SATS_PER_PLM;
|
||||
jackpotEl.textContent = formatPlm(data.jackpot_sats);
|
||||
if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) {
|
||||
jackpotEl.classList.remove('jackpot-bump');
|
||||
void jackpotEl.offsetWidth; // restart the animation
|
||||
jackpotEl.classList.add('jackpot-bump');
|
||||
}
|
||||
lastJackpotValue = jackpotValue;
|
||||
if (data.server_time) serverTimeOffsetMs = new Date(data.server_time) - new Date();
|
||||
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
|
||||
updateRoundTimer();
|
||||
|
||||
currentRoundIdSeen = data.round_id || null;
|
||||
|
||||
const isDrawing = data.round_id && DRAWING_STATUSES.includes(data.status);
|
||||
document.getElementById('round-card').classList.toggle('drawing-glow', !!isDrawing);
|
||||
const persisted = getPersistedResult();
|
||||
|
||||
if (isDrawing) {
|
||||
setRoundInfoVisible(false);
|
||||
// The drawing-phase box (spinner + phase label) is generic status info —
|
||||
// every viewer sees it for the whole closing/drawing/paying_out phase,
|
||||
// regardless of whether they played in this round.
|
||||
setDrawingBoxVisible(true, drawingLabelFor(data));
|
||||
|
||||
// The cosmetic reveal delay is anchored to the server's closes_at, not to
|
||||
// any client-side "when did I first see this" timestamp — a page reload
|
||||
// (or repeated reloads) can never reset it, since it's derived purely
|
||||
// from server-provided values that don't change for this round.
|
||||
const elapsedMs = serverNow() - new Date(data.closes_at);
|
||||
const minMs = data.draw_animation_seconds * 1000;
|
||||
const alreadyKnown = persisted && persisted.round_id === data.round_id;
|
||||
// myUserId may not be loaded yet on the very first tick after a reload
|
||||
// (refreshMe() and refreshRound() run concurrently) — fall back to the
|
||||
// persisted result rather than risk showing nothing or the wrong side.
|
||||
// winner_user_id is committed as soon as the draw picks a winner, but
|
||||
// winner_amount_sats isn't set until the payout tx is built afterwards
|
||||
// (a real Electrum round-trip later) — revealing a win before then would
|
||||
// show "+— PLM". Only the winner's own reveal needs to wait for it.
|
||||
const iWon = myUserId != null && data.winner_user_id === myUserId;
|
||||
const amountReady = !iWon || data.winner_amount_sats != null;
|
||||
const canReveal =
|
||||
data.user_played &&
|
||||
data.winner_user_id != null &&
|
||||
(alreadyKnown || elapsedMs >= minMs) &&
|
||||
myUserId != null &&
|
||||
amountReady;
|
||||
|
||||
if (canReveal) {
|
||||
const won = iWon;
|
||||
if (!alreadyKnown) {
|
||||
persistResult(data.round_id, won, data.winner_amount_sats);
|
||||
if (won) {
|
||||
const wonAmount = formatPlm(data.winner_amount_sats);
|
||||
toast(t('toast.roundWon', { id: data.round_id, amount: wonAmount }), 'success');
|
||||
refreshMe(); // the win toast is useless if the balance card still shows the pre-payout amount
|
||||
}
|
||||
}
|
||||
renderPersistedResult({ won, amount_sats: data.winner_amount_sats });
|
||||
} else if (alreadyKnown) {
|
||||
renderPersistedResult(persisted);
|
||||
} else {
|
||||
setResultBoxVisible(false);
|
||||
}
|
||||
} else {
|
||||
setDrawingBoxVisible(false);
|
||||
if (data.round_id && (!persisted || data.round_id !== persisted.round_id)) {
|
||||
// a genuinely new round is open — clear any previous result and go back to normal
|
||||
clearPersistedResult();
|
||||
showNormalState();
|
||||
} else if (!data.round_id && !persisted) {
|
||||
// nothing has ever been revealed and there's no active round — plain empty state
|
||||
showNormalState();
|
||||
} else if (persisted) {
|
||||
// no active round right now (cooldown, or a page reload after the round
|
||||
// fully closed) — keep the persisted result on screen regardless, until
|
||||
// a genuinely new round replaces it above.
|
||||
renderPersistedResult(persisted);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNextRoundPoll(isDrawing);
|
||||
} catch (e) {
|
||||
if (epoch !== sessionEpoch) return; // session ended (or a new one started) while this was in flight
|
||||
noteFetchOutcome(false);
|
||||
scheduleNextRoundPoll(false);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextRoundPoll(fast) {
|
||||
clearTimeout(roundPollTimeout);
|
||||
roundPollTimeout = setTimeout(refreshRound, fast ? 3000 : 15000);
|
||||
}
|
||||
|
||||
async function showDashboard() {
|
||||
sessionEpoch++; // invalidate any dashboard poll chain left over from a previous login
|
||||
stopChainOnlyPolling();
|
||||
document.getElementById('landing-hero').classList.add('hidden');
|
||||
document.getElementById('auth-section').classList.add('hidden');
|
||||
document.getElementById('app-navbar').classList.remove('hidden');
|
||||
document.getElementById('dashboard-section').classList.remove('hidden');
|
||||
document.getElementById('dash-username').textContent = username;
|
||||
document.getElementById('dash-address').textContent = address;
|
||||
document.getElementById('dash-qr').src = '/qr/' + encodeURIComponent(address);
|
||||
// Render instantly from localStorage, before the network round-trip below —
|
||||
// otherwise a reload right after a win/lose flashes an empty round card for
|
||||
// a moment. refreshRound()'s own response reconciles this shortly after
|
||||
// (e.g. hides it again if a new round has since opened).
|
||||
const persisted = getPersistedResult();
|
||||
if (persisted) renderPersistedResult(persisted);
|
||||
// Awaited so myUserId is populated before refreshRound() decides whether
|
||||
// data.winner_user_id === myUserId — otherwise that comparison could race
|
||||
// against an unset myUserId right after a reload.
|
||||
await refreshMe();
|
||||
// Awaited too, and before refreshRound(): on a brand-new browser/device that
|
||||
// never saw this round live (nothing in localStorage), this is the only
|
||||
// thing that knows the outcome once the round has fully closed. Resolving
|
||||
// it first means refreshRound() finds the answer already in place instead
|
||||
// of momentarily rendering "no result" and then flipping to the win/lose
|
||||
// box a moment later once this backstop catches up.
|
||||
await checkLastRoundResult();
|
||||
refreshRound();
|
||||
clearInterval(lastResultInterval);
|
||||
lastResultInterval = setInterval(checkLastRoundResult, 20000);
|
||||
clearInterval(roundTimerInterval);
|
||||
roundTimerInterval = setInterval(updateRoundTimer, 1000);
|
||||
}
|
||||
|
||||
function persistSession(data, u) {
|
||||
token = data.access_token; username = u; address = data.address;
|
||||
localStorage.setItem('plm_token', token);
|
||||
localStorage.setItem('plm_username', username);
|
||||
localStorage.setItem('plm_address', address);
|
||||
}
|
||||
|
||||
async function register() {
|
||||
const btn = document.getElementById('register-btn');
|
||||
const u = document.getElementById('reg-username').value;
|
||||
const p = document.getElementById('reg-password').value;
|
||||
const pConfirm = document.getElementById('reg-password-confirm').value;
|
||||
if (p !== pConfirm) {
|
||||
toast(t('toast.passwordMismatch'), 'error');
|
||||
return;
|
||||
}
|
||||
// Mirrors what the server now enforces (app/auth/routes.py's RegisterRequest),
|
||||
// so the failure is immediate and translated instead of a generic 422 (B-12).
|
||||
if (p.length < 8) {
|
||||
toast(t('toast.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
await withLoading(btn, t('loading.creating'), async () => {
|
||||
try {
|
||||
const data = await call('POST', '/auth/register', { username: u, password: p });
|
||||
persistSession(data, u);
|
||||
toast(t('toast.accountCreated'), 'success');
|
||||
showDashboard();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const btn = document.getElementById('login-btn');
|
||||
const u = document.getElementById('login-username').value;
|
||||
const p = document.getElementById('login-password').value;
|
||||
await withLoading(btn, t('loading.loggingIn'), async () => {
|
||||
try {
|
||||
const data = await call('POST', '/auth/login', { username: u, password: p });
|
||||
persistSession(data, u);
|
||||
toast(t('toast.loginSuccess'), 'success');
|
||||
showDashboard();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetToLoggedOutUI() {
|
||||
sessionEpoch++; // invalidate any refreshRound() still in flight from the dashboard we're leaving
|
||||
token = username = address = null;
|
||||
myUserId = null;
|
||||
currentRoundIdSeen = undefined;
|
||||
clearInterval(roundTimerInterval);
|
||||
clearTimeout(roundPollTimeout);
|
||||
clearInterval(lastResultInterval);
|
||||
lastResultInterval = null;
|
||||
document.getElementById('app-navbar').classList.add('hidden');
|
||||
document.getElementById('dashboard-section').classList.add('hidden');
|
||||
document.getElementById('auth-section').classList.remove('hidden');
|
||||
document.getElementById('landing-hero').classList.remove('hidden');
|
||||
startChainOnlyPolling();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
// The chosen language is a device preference, not session state — clearing it
|
||||
// on logout would drop the user back to the browser-detected default on the
|
||||
// very screen where they'd have to find the switcher again.
|
||||
const lang = localStorage.getItem(LANG_STORAGE_KEY);
|
||||
localStorage.clear();
|
||||
if (lang) localStorage.setItem(LANG_STORAGE_KEY, lang);
|
||||
resetToLoggedOutUI();
|
||||
}
|
||||
|
||||
// Fires in every OTHER tab of this origin when one tab clears/changes plm_token
|
||||
// (e.g. via logout()) — keeps all open tabs in sync instead of leaving stale
|
||||
// ones showing a dashboard for a session that no longer exists anywhere else.
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === 'plm_token' && !event.newValue) {
|
||||
resetToLoggedOutUI();
|
||||
}
|
||||
});
|
||||
|
||||
// Bfcache restores a frozen snapshot of the DOM/JS state from before the user
|
||||
// navigated away, without re-running this script — so a stale "logged in" (or
|
||||
// stale "logged out") view could persist across back/forward navigation. Cache-
|
||||
// Control: no-store on this response should already prevent that, but re-derive
|
||||
// the UI from storage here too as a safety net for browsers that ignore it.
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (event.persisted) initAuthState();
|
||||
});
|
||||
|
||||
function initAuthState() {
|
||||
token = localStorage.getItem('plm_token');
|
||||
username = localStorage.getItem('plm_username');
|
||||
address = localStorage.getItem('plm_address');
|
||||
if (token) {
|
||||
showDashboard();
|
||||
} else {
|
||||
resetToLoggedOutUI();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAddress() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
toast(t('toast.addressCopied'), 'success');
|
||||
} catch (e) {
|
||||
toast(t('toast.copyFailed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
let myUserId = null;
|
||||
let myBalanceSats = 0; // confirmed, spendable balance — what withdrawals/bets can actually draw from
|
||||
|
||||
// Shows the pending-inclusive balance (confirmed + own change still unconfirmed
|
||||
// in a broadcast bet/withdrawal — see compute_pending_balance in
|
||||
// app/wallet/balance.py) so the number doesn't drop by more than the amount
|
||||
// actually spent while a tx is in flight. Green once settled, amber while
|
||||
// has_pending is true so it's clear the figure isn't final yet.
|
||||
function setBalanceDisplay(elementId, pendingBalanceSats, hasPending) {
|
||||
const el = document.getElementById(elementId);
|
||||
el.textContent = formatPlm(pendingBalanceSats);
|
||||
el.classList.toggle('balance-pending', hasPending);
|
||||
el.classList.toggle('balance-confirmed', !hasPending);
|
||||
}
|
||||
|
||||
async function refreshMe() {
|
||||
const btn = document.getElementById('refresh-btn');
|
||||
await withLoading(btn, t('loading.refreshing'), async () => {
|
||||
try {
|
||||
const data = await call('GET', '/users/me');
|
||||
myUserId = data.id;
|
||||
myBalanceSats = data.balance_sats;
|
||||
setBalanceDisplay('dash-balance', data.pending_balance_sats, data.has_pending);
|
||||
document.getElementById('navbar-balance').textContent = formatPlm(data.pending_balance_sats) + ' PLM';
|
||||
document.getElementById('navbar-balance').classList.toggle('balance-pending', data.has_pending);
|
||||
document.getElementById('navbar-balance').classList.toggle('balance-confirmed', !data.has_pending);
|
||||
document.getElementById('profile-username').textContent = data.username;
|
||||
document.getElementById('profile-address').textContent = data.address;
|
||||
setBalanceDisplay('profile-balance', data.pending_balance_sats, data.has_pending);
|
||||
document.getElementById('profile-created-at').textContent = new Date(data.created_at).toLocaleDateString(currentDateLocale());
|
||||
document.getElementById('wd-full-amount-value').textContent = formatPlm(data.balance_sats);
|
||||
if (document.getElementById('wd-full-amount').checked) {
|
||||
document.getElementById('wd-amount').value = data.balance_sats / SATS_PER_PLM;
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleWithdrawFullAmount() {
|
||||
const checked = document.getElementById('wd-full-amount').checked;
|
||||
const amountInput = document.getElementById('wd-amount');
|
||||
amountInput.disabled = checked;
|
||||
if (checked) amountInput.value = myBalanceSats / SATS_PER_PLM;
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const btn = document.getElementById('change-password-btn');
|
||||
const currentPassword = document.getElementById('settings-current-password').value;
|
||||
const newPassword = document.getElementById('settings-new-password').value;
|
||||
const newPasswordConfirm = document.getElementById('settings-new-password-confirm').value;
|
||||
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
toast(t('toast.newPasswordMismatch'), 'error');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast(t('toast.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await withLoading(btn, t('loading.updating'), async () => {
|
||||
try {
|
||||
const data = await call('POST', '/users/me/change-password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
// The server just invalidated every previously issued token (B-34) —
|
||||
// including the one this very request was authenticated with — and
|
||||
// handed back a fresh one so this tab doesn't get logged out too.
|
||||
token = data.access_token;
|
||||
localStorage.setItem('plm_token', token);
|
||||
document.getElementById('settings-current-password').value = '';
|
||||
document.getElementById('settings-new-password').value = '';
|
||||
document.getElementById('settings-new-password-confirm').value = '';
|
||||
toast(t('toast.passwordUpdated'), 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function placeBet() {
|
||||
const btn = document.getElementById('bet-btn');
|
||||
await withLoading(btn, t('loading.sendingBet'), async () => {
|
||||
try {
|
||||
const data = await call('POST', '/bets', {});
|
||||
toast(t('toast.betPlaced', { id: data.round_id }), 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
});
|
||||
refreshMe();
|
||||
refreshRound();
|
||||
}
|
||||
|
||||
async function withdraw() {
|
||||
const btn = document.getElementById('withdraw-btn');
|
||||
const ext = document.getElementById('wd-address').value;
|
||||
const isFullAmount = document.getElementById('wd-full-amount').checked;
|
||||
const amount = parseFloat(document.getElementById('wd-amount').value);
|
||||
// Caught here rather than left to the server: an empty or non-numeric field
|
||||
// parses to NaN, which JSON.stringify sends as null, which comes back as a
|
||||
// 422 whose only readable text is an English HTTP status line.
|
||||
if (!isFullAmount && !(amount > 0)) {
|
||||
toast(t('error.invalid_amount'), 'error');
|
||||
return;
|
||||
}
|
||||
const amtSats = isFullAmount ? myBalanceSats : Math.round(amount * SATS_PER_PLM);
|
||||
await withLoading(btn, t('loading.sending'), async () => {
|
||||
try {
|
||||
await call('POST', '/withdrawals', { external_address: ext, amount_sats: amtSats });
|
||||
toast(t('toast.withdrawSent'), 'success');
|
||||
document.getElementById('wd-full-amount').checked = false;
|
||||
toggleWithdrawFullAmount();
|
||||
document.getElementById('wd-amount').value = '';
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
});
|
||||
refreshMe();
|
||||
}
|
||||
|
||||
// Server push: an SSE channel that notifies the instant round/bet/balance
|
||||
// state changes anywhere (see app/rounds/events.py), instead of everyone
|
||||
// waiting for their next poll tick. The message carries no payload — it just
|
||||
// means "something changed", so we react by immediately re-running the same
|
||||
// refreshes the polling loop would eventually do on its own. Polling is left
|
||||
// completely in place as a fallback: if this connection is blocked/dropped
|
||||
// (proxy, browser setting, flaky network), the page keeps working exactly as
|
||||
// before, just without the instant nudge.
|
||||
let roundEventSource = null;
|
||||
|
||||
function onRoundServerEvent() {
|
||||
if (token) {
|
||||
refreshRound();
|
||||
refreshMe();
|
||||
checkLastRoundResult();
|
||||
} else {
|
||||
refreshChainStatusOnly();
|
||||
}
|
||||
}
|
||||
|
||||
function connectRoundEvents() {
|
||||
if (roundEventSource) return;
|
||||
roundEventSource = new EventSource('/rounds/stream');
|
||||
roundEventSource.addEventListener('update', onRoundServerEvent);
|
||||
// Fires on the initial connection AND every successful auto-reconnect (the
|
||||
// browser retries this on its own after a drop) — re-syncs immediately
|
||||
// instead of leaving the page on whatever it last knew until the next event
|
||||
// or poll tick, which would otherwise widen the "missed while disconnected"
|
||||
// window to the full reconnect gap.
|
||||
roundEventSource.addEventListener('open', onRoundServerEvent);
|
||||
}
|
||||
|
||||
// Called by i18n.js's setLanguage() after applying static [data-i18n] translations —
|
||||
// re-renders the dynamic bits that live outside that mechanism (status labels,
|
||||
// round title, draw-phase label, persisted win/lose box, profile date) since
|
||||
// those are built from server data + t() rather than fixed markup.
|
||||
function onLanguageChange() {
|
||||
renderBetButton();
|
||||
renderChainStatusBar(); // repaints from remembered state, without waiting for the next poll
|
||||
if (token) {
|
||||
refreshRound();
|
||||
refreshMe();
|
||||
} else {
|
||||
refreshChainStatusOnly();
|
||||
}
|
||||
const persisted = getPersistedResult();
|
||||
if (persisted && !document.getElementById('draw-result').classList.contains('hidden')) {
|
||||
renderPersistedResult(persisted);
|
||||
}
|
||||
}
|
||||
|
||||
renderBetButton();
|
||||
renderChainStatusBar();
|
||||
connectRoundEvents();
|
||||
initAuthState();
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Guida — PLM Lottery</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<h1>Guida utente</h1>
|
||||
<p>Questa pagina è un placeholder. La guida completa sarà pubblicata qui a breve.</p>
|
||||
<p><a class="link" href="/">← Torna alla home</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+1195
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PLM Lottery</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="hidden" id="app-navbar" data-i18n-aria-label="nav.ariaSections" aria-label="Sezioni">
|
||||
<div class="app-navbar-top">
|
||||
<div class="app-navbar-top-inner">
|
||||
<span class="brand">
|
||||
<img class="brand-mark" src="/logo.svg" alt="">
|
||||
PLM Lottery
|
||||
</span>
|
||||
<div class="app-navbar-account">
|
||||
<span class="navbar-username" id="dash-username"></span>
|
||||
<span class="navbar-balance mono">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v16"/></svg>
|
||||
<span id="navbar-balance">— PLM</span>
|
||||
</span>
|
||||
<a class="link icon-link" href="/guida" target="_blank" rel="noopener" data-i18n-title="nav.guideTitle" title="Guida" data-i18n-aria-label="nav.guideAria" aria-label="Apri la guida utente">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 2-3 4"/><path d="M12 17h.01"/></svg>
|
||||
</a>
|
||||
<a class="link icon-link" href="/report-bug" target="_blank" rel="noopener" data-i18n-title="nav.bugReport" title="Segnala un bug" data-i18n-aria-label="nav.bugReport" aria-label="Segnala un bug">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v5"/><path d="M12 16h.01"/></svg>
|
||||
</a>
|
||||
<button class="link icon-link" onclick="logout()" data-i18n-title="nav.logoutTitle" title="Esci" data-i18n-aria-label="nav.logoutAria" aria-label="Esci dall'account">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-navbar-tabs">
|
||||
<button class="navbar-tab active" id="nav-deposit" onclick="switchPanel('deposit')">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v16"/></svg>
|
||||
<span data-i18n="nav.deposit">Deposito</span>
|
||||
</button>
|
||||
<button class="navbar-tab" id="nav-bet" onclick="switchPanel('bet')">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 12h.01M12 12h.01M18 12h.01"/></svg>
|
||||
<span data-i18n="nav.bet">Bet</span>
|
||||
</button>
|
||||
<button class="navbar-tab" id="nav-withdraw" onclick="switchPanel('withdraw')">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
|
||||
<span data-i18n="nav.withdraw">Prelievo</span>
|
||||
</button>
|
||||
<button class="navbar-tab" id="nav-profile" onclick="switchPanel('profile')">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
<span data-i18n="nav.profile">Profilo</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-shell">
|
||||
|
||||
<div class="chain-bar" id="chain-bar">
|
||||
<span class="chain-status-pill">
|
||||
<span class="status-dot" id="chain-status-dot"></span>
|
||||
<!-- No data-i18n on this one or on #draw-label below: both are written by
|
||||
app.js from live state, and letting applyStaticTranslations() also own
|
||||
them made a language switch flash (or, here, assert) a stale value. -->
|
||||
<span id="chain-status-label">Connecting…</span>
|
||||
</span>
|
||||
<span class="chain-bar-right">
|
||||
<span class="chain-block mono" id="chain-block">—</span>
|
||||
<!-- Deliberately here and not in the navbar: the navbar is hidden until login,
|
||||
which would leave the landing page and the login form untranslatable for
|
||||
anyone who can't read the browser-detected default. -->
|
||||
<select id="lang-switcher" class="lang-switcher" onchange="setLanguage(this.value)" aria-label="Language">
|
||||
<option value="en">English</option>
|
||||
<option value="it">Italiano</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="maintenance-banner hidden" id="maintenance-banner">
|
||||
<span>⚠️</span>
|
||||
<span data-i18n="maintenance.banner">Manutenzione in programma: il round in corso viene completato regolarmente (vincitore incluso), ma il round successivo non si aprirà finché la manutenzione non sarà terminata.</span>
|
||||
</div>
|
||||
|
||||
<section id="landing-hero" class="hero">
|
||||
<h1>PLM Lottery</h1>
|
||||
<p class="lead" data-i18n="hero.lead">Deposita PLM, entra nel round con una quota fissa, e se viene estratto il tuo numero vinci il montepremi.</p>
|
||||
|
||||
<div class="hero-steps">
|
||||
<div class="hero-step">
|
||||
<div class="step-icon"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v16"/></svg></div>
|
||||
<div class="step-title" data-i18n="hero.step1.title">1. Deposita</div>
|
||||
<div class="step-hint" data-i18n="hero.step1.hint">Ricevi un indirizzo PLM personale, tuo per sempre</div>
|
||||
</div>
|
||||
<div class="hero-step">
|
||||
<div class="step-icon"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 12h.01M12 12h.01M18 12h.01"/></svg></div>
|
||||
<div class="step-title" data-i18n="hero.step2.title">2. Gioca</div>
|
||||
<div class="step-hint" data-i18n="hero.step2.hint">Una bet a quota fissa per entrare nel round corrente</div>
|
||||
</div>
|
||||
<div class="hero-step">
|
||||
<div class="step-icon"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 21h8M12 17v4M7 4h10v4a5 5 0 0 1-10 0V4Z"/><path d="M7 5H4a1 1 0 0 0-1 1v1a4 4 0 0 0 4 4M17 5h3a1 1 0 0 1 1 1v1a4 4 0 0 1-4 4"/></svg></div>
|
||||
<div class="step-title" data-i18n="hero.step3.title">3. Vinci</div>
|
||||
<div class="step-hint" data-i18n="hero.step3.hint">Estrazione dal blocco, montepremi accreditato subito</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trust-row">
|
||||
<span class="trust-pill"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span data-i18n="trust.fixedRate">Quota fissa dichiarata</span></span>
|
||||
<span class="trust-pill"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span data-i18n="trust.blockHash">Estrazione da hash di blocco</span></span>
|
||||
<span class="trust-pill"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span data-i18n="trust.freeWithdraw">Prelievo libero in ogni momento</span></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="auth-section" class="card">
|
||||
<div class="tabs">
|
||||
<div class="tab active" id="tab-login" onclick="switchTab('login')" data-i18n="auth.tabLogin">Login</div>
|
||||
<div class="tab" id="tab-register" onclick="switchTab('register')" data-i18n="auth.tabRegister">Registrati</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel active" id="panel-login">
|
||||
<label for="login-username" data-i18n="auth.username">Username</label>
|
||||
<input id="login-username" autocomplete="username">
|
||||
<label for="login-password" data-i18n="auth.password">Password</label>
|
||||
<input id="login-password" type="password" autocomplete="current-password">
|
||||
<button onclick="login()" id="login-btn" data-i18n="auth.loginBtn">Accedi</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="panel-register">
|
||||
<label for="reg-username" data-i18n="auth.username">Username</label>
|
||||
<input id="reg-username" autocomplete="username" minlength="3" maxlength="32" pattern="[A-Za-z0-9_.\-]+" required>
|
||||
<label for="reg-password" data-i18n="auth.password">Password</label>
|
||||
<input id="reg-password" type="password" autocomplete="new-password" minlength="8" required>
|
||||
<label for="reg-password-confirm" data-i18n="auth.passwordConfirm">Conferma password</label>
|
||||
<input id="reg-password-confirm" type="password" autocomplete="new-password" minlength="8" required>
|
||||
<button onclick="register()" id="register-btn" data-i18n="auth.registerBtn">Crea account</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="dashboard-section" class="hidden">
|
||||
|
||||
<div class="card round-card" id="round-card">
|
||||
<div class="row-between" id="round-normal-row">
|
||||
<h2 id="round-title">Round —</h2>
|
||||
<span class="mono" id="round-timer" style="font-size:1.1rem;font-weight:700">--:--</span>
|
||||
</div>
|
||||
<div class="row-between" style="margin-top:10px" id="round-stats-row">
|
||||
<div>
|
||||
<div class="hint" style="margin-bottom:2px" data-i18n="round.players">Giocatori</div>
|
||||
<span class="mono" id="round-players">—</span>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="hint" style="margin-bottom:2px" data-i18n="round.jackpot">Jackpot</div>
|
||||
<span class="mono" id="round-jackpot">—</span> <span class="balance-unit">PLM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="draw-state" id="draw-state">
|
||||
<div class="draw-spinner"></div>
|
||||
<div class="draw-label" id="draw-label">Drawing the winner…</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden" id="draw-result"></div>
|
||||
</div>
|
||||
|
||||
<div class="dash-panel active" id="panel-deposit">
|
||||
<div class="card">
|
||||
<h2 data-i18n="deposit.balanceTitle">Saldo interno</h2>
|
||||
<p class="hint" data-i18n="deposit.balanceHint">Aggiornato dopo 1 conferma sulla rete</p>
|
||||
<div class="row-between">
|
||||
<div><span class="balance-value mono" id="dash-balance">—</span> <span class="balance-unit">PLM</span></div>
|
||||
<button class="secondary" onclick="refreshMe()" id="refresh-btn" data-i18n-aria-label="deposit.refreshAria" aria-label="Aggiorna saldo">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg>
|
||||
<span data-i18n="deposit.refreshBtn">Aggiorna</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 data-i18n="deposit.addressTitle">Indirizzo di deposito</h2>
|
||||
<p class="hint" data-i18n="deposit.addressHint">È anche l'indirizzo su cui ricevi eventuali vincite</p>
|
||||
<div class="address-box">
|
||||
<span class="mono" id="dash-address"></span>
|
||||
<button class="secondary" onclick="copyAddress()" data-i18n-aria-label="deposit.copyAria" aria-label="Copia indirizzo">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="qr-box">
|
||||
<img id="dash-qr" data-i18n-alt="deposit.qrAlt" alt="QR code dell'indirizzo di deposito">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-panel" id="panel-bet">
|
||||
<div class="card">
|
||||
<h2 data-i18n="bet.title">Bet</h2>
|
||||
<p class="hint" data-i18n="bet.hint">Ingresso fisso al round corrente</p>
|
||||
<!-- No data-i18n here: the label carries the live bet amount, which is
|
||||
admin-configurable, so it's rendered by renderBetButton() in app.js. -->
|
||||
<button onclick="placeBet()" id="bet-btn">Place bet</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-panel" id="panel-withdraw">
|
||||
<div class="card">
|
||||
<h2 data-i18n="withdraw.title">Withdrawal</h2>
|
||||
<p class="hint" data-i18n="withdraw.hint">Invia fondi a un indirizzo PLM esterno</p>
|
||||
<label for="wd-address" data-i18n="withdraw.addressLabel">Indirizzo esterno</label>
|
||||
<input id="wd-address" class="mono" placeholder="plm1q...">
|
||||
<p class="hint" data-i18n-html="withdraw.addressHint">Solo indirizzi P2WPKH bech32 (quelli che iniziano con <code>plm1q...</code>). Indirizzi legacy (<code>P...</code>) o P2SH non sono supportati.</p>
|
||||
<label for="wd-amount" data-i18n="withdraw.amountLabel">Importo (PLM)</label>
|
||||
<input id="wd-amount" inputmode="decimal" data-i18n-placeholder="withdraw.amountPlaceholder" placeholder="es. 2">
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" id="wd-full-amount" onchange="toggleWithdrawFullAmount()">
|
||||
<span data-i18n="withdraw.fullAmountPrefix">Preleva l'intero importo (</span><span class="mono" id="wd-full-amount-value">—</span><span data-i18n="withdraw.fullAmountSuffix"> PLM)</span>
|
||||
</label>
|
||||
<button onclick="withdraw()" id="withdraw-btn" data-i18n="withdraw.button">Preleva</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-panel" id="panel-profile">
|
||||
<div class="card">
|
||||
<h2 data-i18n="profile.title">Profilo</h2>
|
||||
<p class="hint" data-i18n="profile.hint">Le tue informazioni account</p>
|
||||
<label data-i18n="profile.usernameLabel">Username</label>
|
||||
<div class="address-box"><span id="profile-username">—</span></div>
|
||||
<label data-i18n="profile.addressLabel">Indirizzo di deposito</label>
|
||||
<div class="address-box"><span class="mono" id="profile-address">—</span></div>
|
||||
<label data-i18n="profile.balanceLabel">Saldo interno</label>
|
||||
<div class="address-box"><span class="mono" id="profile-balance">—</span> <span class="balance-unit">PLM</span></div>
|
||||
<label data-i18n="profile.createdLabel">Utente dal</label>
|
||||
<div class="address-box"><span id="profile-created-at">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 data-i18n="settings.title">Impostazioni</h2>
|
||||
<p class="hint" data-i18n="settings.hint">Cambia la password del tuo account</p>
|
||||
<label for="settings-current-password" data-i18n="settings.currentPasswordLabel">Password attuale</label>
|
||||
<input id="settings-current-password" type="password" autocomplete="current-password">
|
||||
<label for="settings-new-password" data-i18n="settings.newPasswordLabel">Nuova password</label>
|
||||
<input id="settings-new-password" type="password" autocomplete="new-password">
|
||||
<label for="settings-new-password-confirm" data-i18n="settings.newPasswordConfirmLabel">Conferma nuova password</label>
|
||||
<input id="settings-new-password-confirm" type="password" autocomplete="new-password">
|
||||
<button onclick="changePassword()" id="change-password-btn" data-i18n="settings.updateBtn">Aggiorna password</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="toast-container" aria-live="polite"></div>
|
||||
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,169 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-i18n="bugReport.pageTitle">Report a bug</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell app-shell-bugreport">
|
||||
|
||||
<div class="bugreport-topbar">
|
||||
<a class="brand" href="/">
|
||||
<img class="brand-mark" src="/logo.svg" alt="">
|
||||
PLM Lottery
|
||||
</a>
|
||||
<div class="bugreport-topbar-right">
|
||||
<a class="back-home-btn" href="/">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
<span data-i18n="bugReport.backLink">Back to home</span>
|
||||
</a>
|
||||
<select id="lang-switcher" class="lang-switcher" onchange="setLanguage(this.value)" aria-label="Language">
|
||||
<option value="en">English</option>
|
||||
<option value="it">Italiano</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bugreport-hero">
|
||||
<div class="bugreport-hero-icon">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v5"/><path d="M12 16h.01"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 data-i18n="bugReport.heading">Report a bug</h1>
|
||||
<p data-i18n="bugReport.intro">Found a problem? Describe it below — your report goes straight to the admin panel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="report-form">
|
||||
<div class="field-note" id="english-notice">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
<span data-i18n="bugReport.englishNotice">Please write your bug report in English, regardless of the language you're browsing in — this helps us handle it faster.</span>
|
||||
</div>
|
||||
|
||||
<label for="bug-description" data-i18n="bugReport.descriptionLabel">What happened?</label>
|
||||
<textarea id="bug-description" rows="6" maxlength="2000" data-i18n-placeholder="bugReport.descriptionPlaceholder" oninput="updateCharCount()"></textarea>
|
||||
<div class="char-count" id="char-count">0 / 2000</div>
|
||||
|
||||
<label for="bug-contact" data-i18n="bugReport.contactLabel">Contact (optional)</label>
|
||||
<input id="bug-contact" type="text" maxlength="256" data-i18n-placeholder="bugReport.contactPlaceholder">
|
||||
|
||||
<button onclick="submitBugReport()" id="bug-submit-btn" data-i18n="bugReport.submitBtn">Send report</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden" id="my-reports-section">
|
||||
<p class="section-label" data-i18n="bugReport.myReportsTitle">Your reports</p>
|
||||
<div class="card">
|
||||
<p class="hint" data-i18n="bugReport.myReportsHint">Only reports sent from this account, with the status set by the admin team.</p>
|
||||
<div id="my-reports-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="toast-container" aria-live="polite"></div>
|
||||
|
||||
<script src="/i18n.js"></script>
|
||||
<script>
|
||||
function toast(message, type) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
el.textContent = message;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
function updateCharCount() {
|
||||
const field = document.getElementById('bug-description');
|
||||
document.getElementById('char-count').textContent = field.value.length + ' / ' + field.maxLength;
|
||||
}
|
||||
|
||||
async function submitBugReport() {
|
||||
const btn = document.getElementById('bug-submit-btn');
|
||||
const description = document.getElementById('bug-description').value.trim();
|
||||
const contact = document.getElementById('bug-contact').value.trim();
|
||||
if (!description) {
|
||||
toast(t('bugReport.blankError'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const token = localStorage.getItem('plm_token');
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
btn.disabled = true;
|
||||
const original = btn.textContent;
|
||||
btn.textContent = t('bugReport.submitting');
|
||||
try {
|
||||
const res = await fetch('/bug-reports', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ description, contact: contact || null }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText);
|
||||
document.getElementById('bug-description').value = '';
|
||||
document.getElementById('bug-contact').value = '';
|
||||
updateCharCount();
|
||||
toast(t('bugReport.successToast'), 'success');
|
||||
loadMyBugReports();
|
||||
} catch (e) {
|
||||
toast(t('bugReport.errorPrefix') + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
applyStaticTranslations(btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyBugReports() {
|
||||
const token = localStorage.getItem('plm_token');
|
||||
const section = document.getElementById('my-reports-section');
|
||||
if (!token) {
|
||||
section.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/bug-reports/mine', { headers: { Authorization: 'Bearer ' + token } });
|
||||
if (!res.ok) {
|
||||
section.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
const reports = await res.json();
|
||||
section.classList.remove('hidden');
|
||||
const list = document.getElementById('my-reports-list');
|
||||
list.innerHTML = reports.map((r) => `
|
||||
<div class="report-row">
|
||||
<span class="badge bug-status-${escapeHtml(r.status)}">${escapeHtml(t('bugReport.status' + r.status.charAt(0).toUpperCase() + r.status.slice(1)))}</span>
|
||||
<span class="report-row-desc" title="${escapeHtml(r.description)}">${escapeHtml(r.description)}</span>
|
||||
<span class="report-row-date">${new Date(r.created_at).toLocaleDateString(currentDateLocale(), { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
||||
</div>
|
||||
`).join('') || `<p class="hint report-empty">${escapeHtml(t('bugReport.myReportsEmpty'))}</p>`;
|
||||
} catch (e) {
|
||||
section.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-renders server-rendered content (my reports list) on a language switch,
|
||||
// the same split app.js uses between data-i18n (static markup) and t() (data).
|
||||
function onLanguageChange() {
|
||||
loadMyBugReports();
|
||||
}
|
||||
|
||||
updateCharCount();
|
||||
loadMyBugReports();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,478 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--color-background: #F8FAFC;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-surface-inset: #F1F5F9;
|
||||
--color-foreground: #0F172A;
|
||||
--color-muted-foreground: #64748B;
|
||||
--color-border: #E2E8F0;
|
||||
--color-primary: #F59E0B;
|
||||
--color-on-primary: #0F172A;
|
||||
--color-secondary: #FBBF24;
|
||||
--color-accent: #7C3AED;
|
||||
--color-destructive: #DC2626;
|
||||
--color-destructive-bg: #FEF2F2;
|
||||
--color-success: #16A34A;
|
||||
--color-success-bg: #F0FDF4;
|
||||
--color-ring: #F59E0B;
|
||||
--radius-sm: 8px;
|
||||
--radius: 14px;
|
||||
--radius-lg: 20px;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
--shadow-md: 0 8px 24px -8px rgba(15, 23, 42, 0.14);
|
||||
--content-width: 480px;
|
||||
--nav-bottom-height: 68px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-shell { max-width: var(--content-width); margin: 0 auto; padding: 20px 20px 32px; }
|
||||
|
||||
.mono { font-family: 'Fira Code', ui-monospace, monospace; }
|
||||
|
||||
h1, h2, h3 { font-family: inherit; letter-spacing: -0.01em; }
|
||||
|
||||
.section-label {
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--color-muted-foreground); margin: 0 2px 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card h2 { font-size: 1rem; font-weight: 600; margin: 0 0 4px; }
|
||||
.card .hint { color: var(--color-muted-foreground); font-size: 0.85rem; margin: 0 0 14px; line-height: 1.45; }
|
||||
.card .hint:last-child { margin-bottom: 0; }
|
||||
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--color-border); }
|
||||
.tab {
|
||||
flex: 1; text-align: center; padding: 10px 0; font-weight: 600; font-size: 0.9rem;
|
||||
color: var(--color-muted-foreground); cursor: pointer; border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px; transition: color 150ms, border-color 150ms;
|
||||
}
|
||||
.tab.active { color: var(--color-foreground); border-bottom-color: var(--color-primary); }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
label { display: block; font-size: 0.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 14px; margin-bottom: 6px; }
|
||||
label:first-child { margin-top: 0; }
|
||||
|
||||
input, textarea {
|
||||
width: 100%; min-height: 44px; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
|
||||
border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface);
|
||||
color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms;
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
input:focus, textarea:focus {
|
||||
outline: none; border-color: var(--color-ring);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent);
|
||||
}
|
||||
input:disabled, textarea:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
|
||||
|
||||
button {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
min-height: 44px; padding: 0 18px; margin-top: 16px; width: 100%;
|
||||
font-family: inherit; font-size: 0.95rem; font-weight: 600;
|
||||
background: var(--color-primary); color: var(--color-on-primary);
|
||||
border: none; border-radius: var(--radius-sm); cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: filter 150ms, transform 150ms, box-shadow 150ms;
|
||||
}
|
||||
button:hover { filter: brightness(0.96); box-shadow: var(--shadow-md); }
|
||||
button:active { transform: scale(0.98); }
|
||||
button:disabled { opacity: 0.6; cursor: default; box-shadow: none; }
|
||||
button:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
|
||||
|
||||
button.secondary {
|
||||
width: auto; margin-top: 0; padding: 0 12px; min-height: 40px;
|
||||
background: var(--color-surface-inset); color: var(--color-foreground);
|
||||
border: 1px solid var(--color-border); box-shadow: none;
|
||||
}
|
||||
button.secondary:hover { filter: none; background: var(--color-border); box-shadow: none; }
|
||||
|
||||
button.link, a.link {
|
||||
width: auto; min-height: 44px; margin-top: 0; padding: 0 2px;
|
||||
display: inline-flex; align-items: center;
|
||||
background: none; color: var(--color-muted-foreground); font-weight: 500;
|
||||
font-size: 0.82rem; box-shadow: none; text-decoration: none;
|
||||
}
|
||||
button.link:hover, a.link:hover { filter: none; color: var(--color-foreground); box-shadow: none; }
|
||||
|
||||
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
|
||||
.checkbox-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 0.85rem; font-weight: 500; color: var(--color-foreground);
|
||||
margin-top: 14px; cursor: pointer;
|
||||
}
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: 18px; height: 18px; min-height: auto; flex-shrink: 0; accent-color: var(--color-primary); cursor: pointer;
|
||||
}
|
||||
|
||||
/* --- app navbar: brand/balance row (sticky top) + section nav (bottom tab bar on
|
||||
mobile, promoted back to an inline tab strip once there's room — see the
|
||||
min-width breakpoint below), shown only when logged in --- */
|
||||
.app-navbar-top {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.app-navbar-top-inner {
|
||||
max-width: var(--content-width); margin: 0 auto; padding: 12px 20px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
}
|
||||
.app-navbar-top .brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1rem; letter-spacing: -0.01em; }
|
||||
.app-navbar-top .brand-mark { width: 26px; height: 26px; border-radius: 50%; flex-shrink: 0; display: block; }
|
||||
.app-navbar-account { display: flex; align-items: center; gap: 6px; }
|
||||
.app-navbar-account .navbar-username { display: none; font-weight: 600; font-size: 0.85rem; margin-right: 2px; }
|
||||
@media (min-width: 420px) { .app-navbar-account .navbar-username { display: inline; } }
|
||||
.app-navbar-account .navbar-balance {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-weight: 700; font-size: 0.85rem; white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-foreground));
|
||||
padding: 6px 10px; border-radius: 999px;
|
||||
}
|
||||
.app-navbar-account .icon-link {
|
||||
width: 36px; height: 36px; min-height: 36px; padding: 0; margin: 0; border-radius: 999px;
|
||||
justify-content: center;
|
||||
}
|
||||
.app-navbar-account .icon-link:hover { background: var(--color-surface-inset); }
|
||||
.app-navbar-account .icon-link .icon { width: 18px; height: 18px; }
|
||||
|
||||
/* Bottom tab bar on narrow (mobile) viewports — thumb-reachable, app-like. */
|
||||
.app-navbar-tabs {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||
max-width: var(--content-width); margin: 0 auto;
|
||||
background: color-mix(in srgb, var(--color-surface) 94%, transparent);
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||
border-top: 1px solid var(--color-border);
|
||||
box-shadow: 0 -8px 24px -12px rgba(15, 23, 42, 0.18);
|
||||
display: flex; padding: 4px 8px calc(4px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
.app-navbar-tabs button.navbar-tab {
|
||||
flex: 1; width: auto; min-height: 56px; margin-top: 0; padding: 8px 4px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
|
||||
font-size: 0.68rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
||||
background: none; color: var(--color-muted-foreground); box-shadow: none;
|
||||
border: none; border-radius: var(--radius-sm);
|
||||
transition: color 150ms, background 150ms;
|
||||
}
|
||||
.app-navbar-tabs button.navbar-tab .icon { width: 20px; height: 20px; }
|
||||
.app-navbar-tabs button.navbar-tab.active { color: var(--color-primary); }
|
||||
.app-navbar-tabs button.navbar-tab.active .icon { color: var(--color-primary); }
|
||||
.app-navbar-tabs button.navbar-tab:hover { filter: none; color: var(--color-foreground); background: var(--color-surface-inset); }
|
||||
.app-navbar-tabs button.navbar-tab.active:hover { color: var(--color-primary); background: none; }
|
||||
|
||||
/* Reserve room so fixed content never sits under the bottom bar or the
|
||||
iOS/Android home-indicator safe area. */
|
||||
.app-shell { padding-bottom: calc(var(--nav-bottom-height) + env(safe-area-inset-bottom, 0px) + 20px); }
|
||||
|
||||
@media (min-width: 720px) {
|
||||
:root { --content-width: 620px; }
|
||||
/* Promote the bottom tab bar back to an ordinary inline strip once there's
|
||||
enough width for it to sit comfortably under the top bar instead of
|
||||
floating over thumb-reach real estate. */
|
||||
.app-navbar-tabs {
|
||||
position: static; box-shadow: none; border-top: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 4px 12px; gap: 4px;
|
||||
}
|
||||
.app-navbar-tabs button.navbar-tab { flex-direction: row; min-height: 44px; font-size: 0.85rem; }
|
||||
.app-shell { padding-bottom: 40px; }
|
||||
}
|
||||
|
||||
.address-box {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
background: var(--color-surface-inset); border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm); padding: 10px 12px; font-size: 0.85rem; word-break: break-all;
|
||||
}
|
||||
|
||||
.balance-value { font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.balance-unit { color: var(--color-muted-foreground); font-size: 1rem; font-weight: 500; }
|
||||
/* Green once everything is confirmed; amber while a bet/withdrawal's change is
|
||||
still unconfirmed — the displayed number already includes that change (see
|
||||
compute_pending_balance), the color just flags that it isn't settled yet. */
|
||||
.balance-confirmed { color: var(--color-success); }
|
||||
.balance-pending { color: var(--color-primary); }
|
||||
|
||||
.icon { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
|
||||
.dash-panel { display: none; }
|
||||
.dash-panel.active { display: block; }
|
||||
|
||||
.qr-box { display: flex; justify-content: center; padding: 16px; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-sm); margin-top: 14px; }
|
||||
.qr-box img { width: 200px; height: 200px; image-rendering: pixelated; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.draw-state { display: none; text-align: center; padding: 8px 0 4px; }
|
||||
.draw-state.active { display: block; }
|
||||
.draw-spinner {
|
||||
width: 40px; height: 40px; margin: 0 auto 10px;
|
||||
border: 3px solid var(--color-border); border-top-color: var(--color-primary);
|
||||
border-radius: 50%; animation: spin 900ms linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.draw-state .draw-label { font-weight: 600; font-size: 0.95rem; }
|
||||
.draw-result { font-size: 1.05rem; font-weight: 700; padding: 6px 0; }
|
||||
.draw-result.win { color: var(--color-success); }
|
||||
.draw-result.lose { color: var(--color-muted-foreground); }
|
||||
|
||||
#toast-container {
|
||||
position: fixed; left: 50%; transform: translateX(-50%);
|
||||
bottom: calc(var(--nav-bottom-height) + env(safe-area-inset-bottom, 0px) + 12px);
|
||||
display: flex; flex-direction: column; gap: 8px; z-index: 100; width: calc(100% - 40px); max-width: 440px;
|
||||
}
|
||||
@media (min-width: 720px) { #toast-container { bottom: 20px; } }
|
||||
.toast {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
padding: 12px 14px; border-radius: var(--radius-sm); font-size: 0.85rem; font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
|
||||
animation: toast-in 200ms ease-out;
|
||||
}
|
||||
.toast.success { background: var(--color-success-bg); color: var(--color-success); }
|
||||
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* --- /report-bug: a standalone page (no logged-in navbar), so it gets its
|
||||
own slim top bar rather than the app's bottom tab bar / sticky header. --- */
|
||||
.app-shell-bugreport { padding-bottom: 32px; }
|
||||
|
||||
.bugreport-topbar {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 4px 0 20px; margin-bottom: 20px; border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.bugreport-topbar .brand {
|
||||
display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1rem;
|
||||
letter-spacing: -0.01em; color: var(--color-foreground); text-decoration: none;
|
||||
}
|
||||
.bugreport-topbar .brand-mark { width: 26px; height: 26px; border-radius: 50%; flex-shrink: 0; display: block; }
|
||||
.bugreport-topbar-right { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
/* Pill button, same idiom as .trust-pill / .chain-status-pill elsewhere on the
|
||||
site: a bordered chip rather than a bare text link, so "go back" reads as an
|
||||
actual control instead of fading into the surrounding copy. */
|
||||
.back-home-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 0.8rem; font-weight: 500; color: var(--color-muted-foreground);
|
||||
background: var(--color-surface); border: 1px solid var(--color-border);
|
||||
padding: 6px 12px 6px 10px; border-radius: 999px; text-decoration: none;
|
||||
transition: color 150ms, border-color 150ms, background 150ms;
|
||||
}
|
||||
.back-home-btn .icon { width: 15px; height: 15px; }
|
||||
.back-home-btn:hover {
|
||||
color: var(--color-foreground); background: var(--color-surface-inset);
|
||||
border-color: color-mix(in srgb, var(--color-ring) 40%, var(--color-border));
|
||||
}
|
||||
.back-home-btn:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
|
||||
|
||||
.bugreport-hero { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; }
|
||||
.bugreport-hero-icon {
|
||||
width: 44px; height: 44px; flex-shrink: 0; border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
color: var(--color-primary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.bugreport-hero-icon .icon { width: 22px; height: 22px; }
|
||||
.bugreport-hero h1 { font-size: 1.3rem; font-weight: 700; margin: 2px 0 4px; text-wrap: balance; }
|
||||
.bugreport-hero p { color: var(--color-muted-foreground); font-size: 0.9rem; margin: 0; max-width: 46ch; }
|
||||
|
||||
/* Info callout, anchored inside the form card right above the field it
|
||||
applies to — not a warning (that's what the amber status badges below are
|
||||
for), so it gets the accent hue instead, keeping the two meanings visually
|
||||
distinct. */
|
||||
.field-note {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 28%, transparent);
|
||||
color: color-mix(in srgb, var(--color-accent) 75%, var(--color-foreground));
|
||||
border-radius: var(--radius-sm); padding: 10px 12px; font-size: 0.82rem; line-height: 1.4;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.field-note .icon { width: 16px; height: 16px; margin-top: 1px; flex-shrink: 0; }
|
||||
|
||||
.char-count {
|
||||
font-variant-numeric: tabular-nums; text-align: right;
|
||||
font-size: 0.75rem; color: var(--color-muted-foreground); margin-top: 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block; font-size: 0.72rem; font-weight: 600; padding: 2px 8px;
|
||||
border-radius: 999px; background: var(--color-background); border: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge.bug-status-open { background: color-mix(in srgb, var(--color-primary) 16%, transparent); color: #92400E; border-color: color-mix(in srgb, var(--color-primary) 55%, transparent); }
|
||||
.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); }
|
||||
.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
|
||||
|
||||
.report-row {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
|
||||
padding: 12px 0; border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.report-row:first-child { padding-top: 0; }
|
||||
.report-row:last-child { padding-bottom: 0; border-bottom: none; }
|
||||
.report-row-desc {
|
||||
flex: 1 1 200px; font-size: 0.88rem;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.report-row-date {
|
||||
font-size: 0.78rem; color: var(--color-muted-foreground); white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.report-empty { margin: 0; }
|
||||
|
||||
/* --- landing hero (shown only when logged out) --- */
|
||||
body {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(600px circle at 20% -10%, color-mix(in srgb, var(--color-primary) 16%, transparent), transparent 60%),
|
||||
radial-gradient(500px circle at 90% 10%, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 60%);
|
||||
}
|
||||
|
||||
.hero { text-align: center; padding: 8px 0 28px; }
|
||||
.hero .eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 0.75rem; font-weight: 600; letter-spacing: 0.02em;
|
||||
color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
padding: 4px 10px; border-radius: 999px; margin-bottom: 14px;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 1.85rem; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 8px;
|
||||
background: linear-gradient(135deg, var(--color-foreground), var(--color-accent) 120%);
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.hero p.lead { color: var(--color-muted-foreground); font-size: 0.95rem; margin: 0 auto; max-width: 360px; }
|
||||
|
||||
.hero-steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 22px 0; }
|
||||
.hero-step {
|
||||
background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 14px 8px; transition: transform 150ms, border-color 150ms, box-shadow 150ms;
|
||||
}
|
||||
.hero-step:hover { transform: translateY(-2px); border-color: var(--color-ring); box-shadow: var(--shadow-md); }
|
||||
.hero-step .step-icon {
|
||||
width: 32px; height: 32px; margin: 0 auto 8px; border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.hero-step .step-icon .icon { width: 16px; height: 16px; }
|
||||
.hero-step .step-title { font-size: 0.8rem; font-weight: 600; margin-bottom: 2px; }
|
||||
.hero-step .step-hint { font-size: 0.72rem; color: var(--color-muted-foreground); line-height: 1.35; }
|
||||
|
||||
.trust-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-bottom: 24px; }
|
||||
.trust-pill {
|
||||
font-size: 0.72rem; font-weight: 500; color: var(--color-muted-foreground);
|
||||
background: var(--color-surface); border: 1px solid var(--color-border);
|
||||
padding: 5px 10px; border-radius: 999px; display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.trust-pill .icon { width: 13px; height: 13px; color: var(--color-success); flex-shrink: 0; }
|
||||
|
||||
/* --- round status: a "hero" ticket-style card, always visible above the panels --- */
|
||||
.card.round-card {
|
||||
background:
|
||||
radial-gradient(320px circle at 100% 0%, color-mix(in srgb, var(--color-accent) 10%, transparent), transparent 70%),
|
||||
var(--color-surface);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 25%, var(--color-border));
|
||||
}
|
||||
.round-card .hint { margin: 0; }
|
||||
|
||||
/* --- glowing card while a round is drawing --- */
|
||||
.card.drawing-glow {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 25%, transparent),
|
||||
0 0 24px color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
animation: glow-pulse 2200ms ease-in-out infinite;
|
||||
}
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 25%, transparent), 0 0 16px color-mix(in srgb, var(--color-primary) 16%, transparent); }
|
||||
50% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 45%, transparent), 0 0 28px color-mix(in srgb, var(--color-primary) 30%, transparent); }
|
||||
}
|
||||
|
||||
.jackpot-bump { animation: jackpot-bump 420ms ease-out; }
|
||||
@keyframes jackpot-bump {
|
||||
0% { transform: scale(1); }
|
||||
30% { transform: scale(1.12); color: var(--color-primary); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* --- network / lottery status strip, shown on every screen --- */
|
||||
.chain-bar {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
font-size: 0.78rem; padding: 12px 2px 16px; margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.chain-status-pill { display: inline-flex; align-items: center; gap: 7px; font-weight: 600; color: var(--color-foreground); }
|
||||
.chain-bar-right { display: inline-flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||
|
||||
/* Language switcher: a plain <select> styled down to look like the muted text
|
||||
around it, so it reads as part of the status strip rather than as a form
|
||||
control. Text labels, not flag emoji — flags don't render on every platform
|
||||
and don't map one-to-one onto languages anyway. */
|
||||
select.lang-switcher {
|
||||
font: inherit; font-size: 0.78rem; color: var(--color-muted-foreground);
|
||||
background: none; border: none; box-shadow: none; padding: 2px 4px;
|
||||
border-radius: 6px; cursor: pointer;
|
||||
-webkit-appearance: none; appearance: none;
|
||||
}
|
||||
select.lang-switcher:hover { color: var(--color-foreground); background: var(--color-surface-inset); }
|
||||
select.lang-switcher:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||
select.lang-switcher option { color: var(--color-foreground); background: var(--color-surface); }
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--color-muted-foreground);
|
||||
}
|
||||
.status-dot.status-open {
|
||||
background: var(--color-success);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 18%, transparent);
|
||||
}
|
||||
.status-dot.status-drawing {
|
||||
background: var(--color-primary);
|
||||
animation: status-dot-pulse 1400ms ease-in-out infinite;
|
||||
}
|
||||
.status-dot.status-waiting { background: var(--color-muted-foreground); }
|
||||
.status-dot.status-offline { background: var(--color-destructive); }
|
||||
@keyframes status-dot-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 45%, transparent); }
|
||||
50% { box-shadow: 0 0 0 5px transparent; }
|
||||
}
|
||||
.chain-block { color: var(--color-muted-foreground); white-space: nowrap; }
|
||||
|
||||
.maintenance-banner {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
color: var(--color-foreground); border-radius: var(--radius);
|
||||
padding: 12px 14px; font-size: 0.82rem; line-height: 1.4; margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation: none !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from embit import script
|
||||
from embit.psbt import PSBT
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
from embit.finalizer import finalize_psbt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.rounds.config import get_round_config
|
||||
from app.wallet.hd import derive_pool_key, derive_user_key
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POLL_INTERVAL_SECONDS = 30
|
||||
_FEE_RATE_INCREMENT = 1 # how much pending.fee_rate_sat_vb's *target* rises by per bump
|
||||
|
||||
# BIP125 rule 4: a replacement transaction must pay at least this much more, in
|
||||
# total, per vbyte of its own size, than the transaction it replaces — Bitcoin
|
||||
# Core's default incremental relay fee. bump_fee's delta must never fall below
|
||||
# this regardless of what the target-rate arithmetic comes out to (B-32).
|
||||
_INCREMENTAL_RELAY_FEE_RATE_SAT_VB = 1
|
||||
|
||||
|
||||
class RbfError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool:
|
||||
"""Pure decision: has this pending tx gone unconfirmed for longer than the
|
||||
configured timeout (RoundConfig.rbf_timeout_seconds) *since it was last
|
||||
broadcast*? Kept separate from the I/O-heavy bump_fee() so it's trivially
|
||||
unit-testable.
|
||||
|
||||
Deliberately measured from last_broadcast_at, not broadcast_at: this decides
|
||||
whether *another* bump is due, which should reset after every bump (a tx just
|
||||
rebroadcast at a higher fee deserves the same grace period again) — unlike
|
||||
reconcile.py's abandon check, which must measure from the *first* broadcast so
|
||||
repeated bumping can't indefinitely postpone ever giving up on a tx (B-27)."""
|
||||
if pending.status != "pending":
|
||||
return False
|
||||
return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds)
|
||||
|
||||
|
||||
async def _signing_context(session: AsyncSession, kind: str, user_id: int | None) -> tuple:
|
||||
"""Returns (signing_key, own_script, own_address) for the single sender that
|
||||
controls every input of this tx — a user for bet/withdrawal, the pool for
|
||||
payout. All our builders only ever spend one address's UTXOs per tx."""
|
||||
if kind == "payout":
|
||||
key = derive_pool_key()
|
||||
else:
|
||||
user = await session.get(User, user_id)
|
||||
key = derive_user_key(user.derivation_index)
|
||||
own_script = script.p2wpkh(key.to_public())
|
||||
own_address = own_script.address(network=PLM_MAINNET)
|
||||
return key, own_script, own_address
|
||||
|
||||
|
||||
async def _prevout_amount(client: ElectrumClient, vin: TransactionInput) -> int:
|
||||
"""The exact integer satoshi value of the output this input spends.
|
||||
|
||||
Parsed directly from the raw transaction via embit rather than asking the
|
||||
server for its own float, whole-coin-denominated "value" field (verbose=True)
|
||||
and converting with `* 100_000_000` — embit's TransactionOutput.value is
|
||||
already an integer number of satoshis straight from the tx's binary
|
||||
encoding, so this never touches floating point in a codebase that is
|
||||
otherwise strictly integer-satoshi (B-40).
|
||||
"""
|
||||
txid_hex = vin.txid.hex()
|
||||
raw_hex = await client.get_transaction(txid_hex, verbose=False)
|
||||
prevout_tx = Transaction.parse(bytes.fromhex(raw_hex))
|
||||
return prevout_tx.vout[vin.vout].value
|
||||
|
||||
|
||||
def _find_change_output(tx: Transaction, change_address: str) -> int | None:
|
||||
for i, out in enumerate(tx.vout):
|
||||
if out.script_pubkey.address(network=PLM_MAINNET) == change_address:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
async def bump_fee(
|
||||
session_factory: async_sessionmaker, client: ElectrumClient, pending_id: int
|
||||
) -> str | None:
|
||||
"""Rebuild pending_transaction `pending_id`'s transaction with a higher fee
|
||||
(same inputs, same recipient outputs, the extra fee taken from the change
|
||||
output) and rebroadcast. Returns the new txid, or None if there was nothing
|
||||
to do (the row is gone or already left "pending" — a normal race with
|
||||
confirmation, not an error).
|
||||
|
||||
Three phases, so no DB session is held across the network calls this needs
|
||||
(one get_transaction per input, then a broadcast) — the same shape used
|
||||
elsewhere for exactly this reason (B-18, rounds/scheduler.py:_trigger_payout;
|
||||
B-31, electrum/listener.py:refresh_user) and now here too (B-40): read what's
|
||||
needed and close the session, do the chain work, then reopen to persist.
|
||||
|
||||
Only handles the common case: exactly one change output paying back to the
|
||||
tx's own sender address, large enough to absorb the increase. If there's no
|
||||
such output (e.g. an exact-amount bet with no change), this raises RbfError —
|
||||
bumping such a tx would require selecting additional inputs, which isn't
|
||||
implemented for the MVP; it needs manual operator intervention. Also raises
|
||||
RbfError, rather than bumping, once the row is already at MAX_FEE_RATE_SAT_VB
|
||||
(B-32) — the reconciler abandons it if it never confirms (B-27), instead of
|
||||
this retrying an ever-higher fee forever.
|
||||
"""
|
||||
# --- Phase 1: read what's needed, close the session before any network call ---
|
||||
async with session_factory() as session:
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
if pending is None or pending.status != "pending":
|
||||
logger.info("pending_transaction %s no longer pending; skipping bump", pending_id)
|
||||
return None
|
||||
if pending.fee_rate_sat_vb >= MAX_FEE_RATE_SAT_VB:
|
||||
raise RbfError(
|
||||
f"pending_transaction {pending_id}: already at the maximum fee rate "
|
||||
f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further"
|
||||
)
|
||||
|
||||
kind = pending.kind
|
||||
current_fee_rate = pending.fee_rate_sat_vb
|
||||
raw_tx_hex = pending.raw_tx_hex
|
||||
signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id)
|
||||
|
||||
# --- Phase 2: chain reads, signing, and the broadcast — no DB session open ----
|
||||
old_tx = Transaction.parse(bytes.fromhex(raw_tx_hex))
|
||||
input_amounts = [await _prevout_amount(client, vin) for vin in old_tx.vin]
|
||||
total_in = sum(input_amounts)
|
||||
old_fee = total_in - sum(o.value for o in old_tx.vout)
|
||||
vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout))
|
||||
|
||||
target_fee_rate = min(current_fee_rate + _FEE_RATE_INCREMENT, MAX_FEE_RATE_SAT_VB)
|
||||
target_fee = vsize * target_fee_rate
|
||||
# BIP125 rule 4's minimum, in absolute sats for this tx's size — the floor
|
||||
# `fee_delta` must never go below, no matter what `target_fee - old_fee` comes
|
||||
# out to. That naive difference used to go to zero or negative whenever
|
||||
# old_fee already exceeded target_fee (e.g. a dust change amount folded into
|
||||
# the original fee — wallet/psbt_builder.py's DUST_LIMIT_SATS handling), and
|
||||
# the previous fallback — a flat 1-satoshi total bump — was nowhere near this
|
||||
# relay-mandated minimum, so the node rejected it every time. Because bump_fee
|
||||
# raised before touching `pending`, the next tick retried with identical
|
||||
# parameters every 30 seconds, forever (B-32).
|
||||
min_valid_delta = vsize * _INCREMENTAL_RELAY_FEE_RATE_SAT_VB
|
||||
fee_delta = max(target_fee - old_fee, min_valid_delta)
|
||||
|
||||
change_index = _find_change_output(old_tx, own_address)
|
||||
if change_index is None or old_tx.vout[change_index].value <= fee_delta:
|
||||
raise RbfError(f"pending_transaction {pending_id}: no change output large enough to absorb a fee bump")
|
||||
|
||||
new_vout = list(old_tx.vout)
|
||||
bumped_change = new_vout[change_index].value - fee_delta
|
||||
new_vout[change_index] = TransactionOutput(bumped_change, new_vout[change_index].script_pubkey)
|
||||
|
||||
new_vin = [TransactionInput(v.txid, v.vout, sequence=RBF_SEQUENCE) for v in old_tx.vin]
|
||||
new_tx = Transaction(vin=new_vin, vout=new_vout)
|
||||
psbt = PSBT(new_tx)
|
||||
for i, amount in enumerate(input_amounts):
|
||||
psbt.inputs[i].witness_utxo = TransactionOutput(amount, own_script)
|
||||
|
||||
signed = psbt.sign_with(signing_key)
|
||||
if signed != len(new_vin):
|
||||
raise RuntimeError(f"expected {len(new_vin)} signatures, got {signed}")
|
||||
|
||||
final_tx = finalize_psbt(psbt)
|
||||
if final_tx is None:
|
||||
raise RuntimeError("failed to finalize bumped PSBT")
|
||||
|
||||
raw_hex = final_tx.serialize().hex()
|
||||
new_txid = final_tx.txid().hex()
|
||||
await client.broadcast(raw_hex)
|
||||
|
||||
# --- Phase 3: persist the outcome ----------------------------------------------
|
||||
async with session_factory() as session:
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
old_txid = pending.current_txid
|
||||
pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced
|
||||
pending.current_txid = new_txid
|
||||
pending.raw_tx_hex = raw_hex
|
||||
# The *actual* resulting rate, not target_fee_rate: when the BIP125-minimum
|
||||
# floor above raised fee_delta past the naive target, the tx now pays more
|
||||
# than target_fee_rate implied. Recording the true rate keeps the next bump's
|
||||
# arithmetic honest instead of drifting from what's really being paid.
|
||||
pending.fee_rate_sat_vb = (old_fee + fee_delta) // vsize
|
||||
pending.attempt_count += 1
|
||||
# last_broadcast_at, not broadcast_at (B-27): broadcast_at must stay the *first*
|
||||
# broadcast, since reconcile.py's abandon-after-N-hours grace period is measured
|
||||
# from it — overwriting it here used to reset that clock on every bump, so a
|
||||
# repeatedly-bumped-but-never-mined tx was never abandoned.
|
||||
pending.last_broadcast_at = datetime.now(timezone.utc)
|
||||
await _retarget_txid_references(session, pending, old_txid, new_txid)
|
||||
await session.commit()
|
||||
|
||||
logger.info("bumped %s pending_transaction %s: %s -> %s", kind, pending_id, old_txid, new_txid)
|
||||
return new_txid
|
||||
|
||||
|
||||
async def _retarget_txid_references(
|
||||
session: AsyncSession, pending: PendingTransaction, old_txid: str, new_txid: str
|
||||
) -> None:
|
||||
"""A bump changes the txid, and everything that recorded the old one has to
|
||||
follow — otherwise the bumped tx confirms and nothing recognizes it (B-02).
|
||||
|
||||
The worst case was the bet path: _on_bet_confirmed used to look the participant
|
||||
up by bet_txid, so after a bump it found nothing, the participant stayed
|
||||
"broadcast" forever, and the scheduler waited on it forever — the round could
|
||||
never close and the lottery stopped. The handlers now key off immutable ids
|
||||
(round_id/user_id, withdrawal_id) as well, so this update is about keeping the
|
||||
stored txids *true* — for the admin UI, for the audit trail, and for
|
||||
reconcile.py, which matches UtxoEvent.spent_txid against current_txid.
|
||||
"""
|
||||
if pending.kind == "bet":
|
||||
participant = await session.scalar(
|
||||
select(RoundParticipant).where(
|
||||
RoundParticipant.round_id == pending.round_id,
|
||||
RoundParticipant.user_id == pending.user_id,
|
||||
)
|
||||
)
|
||||
if participant is not None:
|
||||
participant.bet_txid = new_txid
|
||||
elif pending.kind == "withdrawal" and pending.withdrawal_id is not None:
|
||||
withdrawal = await session.get(Withdrawal, pending.withdrawal_id)
|
||||
if withdrawal is not None:
|
||||
withdrawal.txid = new_txid
|
||||
elif pending.kind == "payout" and pending.round_id is not None:
|
||||
round_ = await session.get(Round, pending.round_id)
|
||||
if round_ is not None and round_.payout_txid == old_txid:
|
||||
round_.payout_txid = new_txid
|
||||
|
||||
# The UTXOs this tx spends are still the same UTXOs — only the id of the tx
|
||||
# spending them changed. Keeping this in step is what lets reconcile.py tell
|
||||
# "reserved by this pending tx" from "spent by something else".
|
||||
spent = (await session.scalars(select(UtxoEvent).where(UtxoEvent.spent_txid == old_txid))).all()
|
||||
for utxo in spent:
|
||||
utxo.spent_txid = new_txid
|
||||
|
||||
|
||||
class RbfBumper:
|
||||
def __init__(self, session_factory: async_sessionmaker, get_client):
|
||||
self._session_factory = session_factory
|
||||
self._get_client = get_client
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
client = self._get_client()
|
||||
if client is not None:
|
||||
try:
|
||||
await self._tick(client)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("RBF bump tick failed")
|
||||
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
|
||||
|
||||
async def _tick(self, client: ElectrumClient) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
async with self._session_factory() as session:
|
||||
timeout_seconds = (await get_round_config(session)).rbf_timeout_seconds
|
||||
candidates = (
|
||||
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
|
||||
).all()
|
||||
due_ids = [p.id for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)]
|
||||
|
||||
for pending_id in due_ids:
|
||||
try:
|
||||
await bump_fee(self._session_factory, client, pending_id)
|
||||
except RbfError:
|
||||
logger.exception("could not bump pending_transaction %s", pending_id)
|
||||
except Exception:
|
||||
logger.exception("unexpected error bumping pending_transaction %s", pending_id)
|
||||
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.models import PendingTransaction
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.events import broadcaster
|
||||
from app.tx.pending_address import own_address_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POLL_INTERVAL_SECONDS = 10
|
||||
|
||||
ConfirmationHandler = Callable[[AsyncSession, PendingTransaction], Awaitable[None]]
|
||||
_handlers: dict[str, ConfirmationHandler] = {}
|
||||
|
||||
|
||||
def register_handler(kind: str, handler: ConfirmationHandler) -> None:
|
||||
"""Domain modules (bets, rounds, withdrawals) register here so this generic
|
||||
poller can notify them when one of their outgoing txs gets its 1st
|
||||
confirmation, without this module importing them directly."""
|
||||
_handlers[kind] = handler
|
||||
|
||||
|
||||
async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
|
||||
async with session_factory() as session:
|
||||
# Plain columns, not entities: nothing then outlives the session, so this
|
||||
# can't break if expire_on_commit is ever turned on (B-21).
|
||||
candidates = (
|
||||
await session.execute(
|
||||
select(
|
||||
PendingTransaction.id,
|
||||
PendingTransaction.current_txid,
|
||||
PendingTransaction.kind,
|
||||
PendingTransaction.user_id,
|
||||
).where(PendingTransaction.status == "pending")
|
||||
)
|
||||
).all()
|
||||
|
||||
# Resolved once per candidate while the session is still open, and cached
|
||||
# by scripthash below — every "payout" row shares the same pool address,
|
||||
# so this also avoids asking the server the same history twice per tick.
|
||||
scripthash_by_id: dict[int, str] = {}
|
||||
for pending_id, _txid, kind, user_id in candidates:
|
||||
try:
|
||||
address = await own_address_for(session, kind, user_id)
|
||||
scripthash_by_id[pending_id] = address_to_scripthash(address)
|
||||
except Exception:
|
||||
logger.exception("could not derive the address for pending_transaction %s", pending_id)
|
||||
|
||||
confirmed = 0
|
||||
history_cache: dict[str, list[dict]] = {}
|
||||
for pending_id, txid, kind, _user_id in candidates:
|
||||
scripthash = scripthash_by_id.get(pending_id)
|
||||
if scripthash is None:
|
||||
continue # address derivation failed above; already logged
|
||||
|
||||
try:
|
||||
if scripthash not in history_cache:
|
||||
history_cache[scripthash] = await client.get_history(scripthash)
|
||||
except Exception:
|
||||
# One unresolvable scripthash must not stop the others: a tx the server
|
||||
# no longer knows about (dropped from the mempool, replaced) used to
|
||||
# abort the whole pass via a verbose blockchain.transaction.get call
|
||||
# that some servers reject outright (B-41), so nothing confirmed again
|
||||
# until an operator intervened (B-03). Abandoning such a row is
|
||||
# app/tx/reconcile.py's job, not ours.
|
||||
logger.warning("could not fetch history for pending_transaction %s (txid %s)", pending_id, txid, exc_info=True)
|
||||
continue
|
||||
|
||||
entry = next((e for e in history_cache[scripthash] if e.get("tx_hash") == txid), None)
|
||||
# height > 0 means confirmed at that height; 0 or absent means still in
|
||||
# the mempool (or the server doesn't know this txid at all yet) — either
|
||||
# way, not confirmed, so keep waiting.
|
||||
if entry is None or entry.get("height", 0) <= 0:
|
||||
continue
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
if row is None or row.status != "pending":
|
||||
continue
|
||||
row.status = "confirmed"
|
||||
handler = _handlers.get(kind)
|
||||
if handler is not None:
|
||||
await handler(session, row)
|
||||
await session.commit()
|
||||
broadcaster.publish() # a bet/withdrawal/payout just confirmed — balance and/or round state changed
|
||||
confirmed += 1
|
||||
|
||||
return confirmed
|
||||
|
||||
|
||||
class ConfirmationPoller:
|
||||
def __init__(self, session_factory: async_sessionmaker, get_client: Callable[[], ElectrumClient | None]):
|
||||
self._session_factory = session_factory
|
||||
self._get_client = get_client
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
client = self._get_client()
|
||||
if client is not None:
|
||||
try:
|
||||
await poll_once(self._session_factory, client)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("confirmation poll failed")
|
||||
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
|
||||
@@ -0,0 +1,22 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
class UserLocks:
|
||||
"""Per-user asyncio.Lock registry, shared by PLAY and WITHDRAW so a user can
|
||||
never have a bet-build and a withdrawal-build in flight at once (both would
|
||||
otherwise spend from the same UTXO set on the user's dedicated address).
|
||||
|
||||
Single-process-only by design (an in-memory dict of asyncio.Lock) — this is an
|
||||
accepted MVP constraint; a multi-process deployment would need a DB or Redis
|
||||
lock instead (e.g. a Postgres advisory lock).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._locks: dict[int, asyncio.Lock] = {}
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self, user_id: int):
|
||||
lock = self._locks.setdefault(user_id, asyncio.Lock())
|
||||
async with lock:
|
||||
yield
|
||||
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import User
|
||||
from app.wallet.hd import derive_pool_address, derive_user_address
|
||||
|
||||
|
||||
async def own_address_for(session: AsyncSession, kind: str, user_id: int | None) -> str:
|
||||
"""The address that owns every input of a PendingTransaction of this kind —
|
||||
a user's own address for a bet/withdrawal, the pool address for a payout.
|
||||
All our builders only ever spend one address's UTXOs per tx (see
|
||||
tx/broadcast.py:_signing_context, which derives the same address alongside
|
||||
the signing key it also needs).
|
||||
|
||||
Shared by tx/confirmation.py and tx/reconcile.py (B-41): both now check
|
||||
blockchain.scripthash.get_history for this address instead of asking
|
||||
blockchain.transaction.get for a verbose reply, so the two can't derive
|
||||
different addresses for the same row.
|
||||
"""
|
||||
if kind == "payout":
|
||||
return derive_pool_address()
|
||||
user = await session.get(User, user_id)
|
||||
return derive_user_address(user.derivation_index)
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Resolves in-flight transactions against the chain.
|
||||
|
||||
Everything else in this codebase assumes a broadcast either confirms or gets
|
||||
fee-bumped until it does. Neither is guaranteed: an RBF bump raises RbfError
|
||||
whenever there's no change output big enough to absorb it (see tx/broadcast.py),
|
||||
a node can drop a low-fee tx from its mempool, and the process can die between
|
||||
building a transaction and broadcasting it. Without this module those cases were
|
||||
permanent: `spent_txid` was set at build time and never cleared, so the coins
|
||||
stayed spendable on-chain while the database considered them gone — the user's
|
||||
balance simply lost them, with no path back short of editing the DB by hand
|
||||
(B-04, and the "building" half of B-08).
|
||||
|
||||
What it does, per PendingTransaction that isn't already terminal:
|
||||
|
||||
* status "building" — we crashed (or were killed) between writing the row and
|
||||
broadcasting. Ask the chain: if the tx is there after all, promote everything
|
||||
to its live state; if it isn't, release the UTXOs and undo the intent.
|
||||
* status "pending" — broadcast, still unconfirmed. Left alone until it has been
|
||||
unconfirmed for `_ABANDON_AFTER_SECONDS`, since absence from one server's
|
||||
mempool is not proof of death; only then is it abandoned like the above.
|
||||
|
||||
Deliberately conservative: it never touches a tx the chain knows about, and the
|
||||
grace period is long (multiples of the RBF timeout) so a slow-but-alive tx is
|
||||
bumped by RbfBumper rather than abandoned here.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant, UtxoEvent, Withdrawal
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.events import broadcaster
|
||||
from app.tx.pending_address import own_address_for
|
||||
from app.wallet.balance import recompute_balance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POLL_INTERVAL_SECONDS = 120
|
||||
|
||||
# A "building" row means we never got confirmation that the broadcast happened, so
|
||||
# it only needs long enough to rule out a request still in flight.
|
||||
_BUILDING_GRACE_SECONDS = 120
|
||||
|
||||
# A "pending" row was accepted by a node once. Give it a wide margin — the RBF
|
||||
# bumper gets several attempts inside this window — before concluding it's gone.
|
||||
_ABANDON_AFTER_SECONDS = 6 * 60 * 60
|
||||
|
||||
|
||||
class PendingTransactionReconciler:
|
||||
def __init__(self, session_factory: async_sessionmaker, get_client: Callable[[], ElectrumClient | None]):
|
||||
self._session_factory = session_factory
|
||||
self._get_client = get_client
|
||||
|
||||
async def run(self) -> None:
|
||||
# Runs once promptly at startup: a crash mid-broadcast is exactly the case
|
||||
# that leaves a "building" row, and the restart is when we can clear it.
|
||||
while True:
|
||||
client = self._get_client()
|
||||
if client is not None:
|
||||
try:
|
||||
await reconcile_once(self._session_factory, client)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("pending-transaction reconciliation failed")
|
||||
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
|
||||
"""Returns how many rows were resolved (promoted or abandoned).
|
||||
|
||||
Existence is decided by checking whether a row's own address's history
|
||||
(blockchain.scripthash.get_history) includes its txid at all — mempool or
|
||||
mined — rather than asking blockchain.transaction.get for a verbose reply
|
||||
(B-41): several Electrum server implementations and versions reject the
|
||||
verbose flag outright, and the previous substring-matching on the error
|
||||
text (looking for "missing", "not found", ...) was fragile as the basis for
|
||||
a decision that releases funds. A transport failure fetching history still
|
||||
raises and leaves the row alone until next time — get_history not
|
||||
returning our txid is the only thing that means "gone". History is cached
|
||||
per scripthash within one pass, since every "payout" row shares the same
|
||||
pool address.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(PendingTransaction).where(PendingTransaction.status.in_(("building", "pending")))
|
||||
)
|
||||
).all()
|
||||
candidates = []
|
||||
for row in rows:
|
||||
if not _is_due(row, now):
|
||||
continue
|
||||
try:
|
||||
address = await own_address_for(session, row.kind, row.user_id)
|
||||
scripthash = address_to_scripthash(address)
|
||||
except Exception:
|
||||
logger.exception("could not derive the address for pending_transaction %s", row.id)
|
||||
continue
|
||||
candidates.append((row.id, row.status, row.current_txid, scripthash))
|
||||
|
||||
resolved = 0
|
||||
history_cache: dict[str, list[dict]] = {}
|
||||
for row_id, status, txid, scripthash in candidates:
|
||||
try:
|
||||
if scripthash not in history_cache:
|
||||
history_cache[scripthash] = await client.get_history(scripthash)
|
||||
exists = any(entry.get("tx_hash") == txid for entry in history_cache[scripthash])
|
||||
except Exception:
|
||||
# Transport/server problem — say nothing about this tx and try again on
|
||||
# the next pass rather than abandoning a tx that may be perfectly alive.
|
||||
logger.warning("could not check pending_transaction %s (txid %s) against the chain", row_id, txid)
|
||||
continue
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, row_id)
|
||||
if row is None or row.status != status:
|
||||
continue # something else moved it while we were asking
|
||||
if exists:
|
||||
if row.status == "building":
|
||||
await _promote(session, row)
|
||||
resolved += 1
|
||||
else:
|
||||
await _abandon(session, row, "not found on chain")
|
||||
resolved += 1
|
||||
await session.commit()
|
||||
broadcaster.publish()
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def _is_due(row: PendingTransaction, now: datetime) -> bool:
|
||||
# Deliberately broadcast_at (the *first* broadcast), not last_broadcast_at: an
|
||||
# RBF bump used to overwrite this same field, which reset this grace period on
|
||||
# every bump and meant a repeatedly-bumped-but-never-mined tx was never
|
||||
# abandoned (B-27). tx/broadcast.py:bump_fee now only ever touches
|
||||
# last_broadcast_at, so this keeps measuring from when the tx first appeared,
|
||||
# no matter how many times it's since been bumped.
|
||||
grace = _BUILDING_GRACE_SECONDS if row.status == "building" else _ABANDON_AFTER_SECONDS
|
||||
return now >= row.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=grace)
|
||||
|
||||
|
||||
async def _promote(session: AsyncSession, row: PendingTransaction) -> None:
|
||||
"""The tx did make it onto the chain before we died — finish what phase 2 of
|
||||
place_bet/request_withdrawal would have done."""
|
||||
row.status = "pending"
|
||||
if row.kind == "bet":
|
||||
participant = await session.scalar(
|
||||
select(RoundParticipant).where(
|
||||
RoundParticipant.round_id == row.round_id, RoundParticipant.user_id == row.user_id
|
||||
)
|
||||
)
|
||||
if participant is not None and participant.status == "building":
|
||||
participant.status = "broadcast"
|
||||
elif row.kind == "withdrawal" and row.withdrawal_id is not None:
|
||||
withdrawal = await session.get(Withdrawal, row.withdrawal_id)
|
||||
if withdrawal is not None and withdrawal.status == "building":
|
||||
withdrawal.status = "broadcast"
|
||||
await write_audit_log(
|
||||
session,
|
||||
"pending_tx_recovered",
|
||||
{"pending_transaction_id": row.id, "kind": row.kind, "txid": row.current_txid},
|
||||
user_id=row.user_id,
|
||||
round_id=row.round_id,
|
||||
)
|
||||
logger.info("recovered %s pending_transaction %s: tx %s is on-chain", row.kind, row.id, row.current_txid)
|
||||
|
||||
|
||||
async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str) -> None:
|
||||
"""The tx is gone for good. Release whatever it reserved so the funds come back,
|
||||
and roll the domain row back to something truthful."""
|
||||
row.status = "failed"
|
||||
row.failure_reason = reason[:128]
|
||||
|
||||
released = await _release_inputs(session, row)
|
||||
|
||||
if row.kind == "bet":
|
||||
participant = await session.scalar(
|
||||
select(RoundParticipant).where(
|
||||
RoundParticipant.round_id == row.round_id, RoundParticipant.user_id == row.user_id
|
||||
)
|
||||
)
|
||||
if participant is not None and participant.status in ("building", "broadcast"):
|
||||
# The bet never happened, so the user is not in this round. Removing the
|
||||
# row also unblocks the scheduler, which waits for every non-confirmed
|
||||
# participant before closing the round.
|
||||
await session.delete(participant)
|
||||
elif row.kind == "withdrawal" and row.withdrawal_id is not None:
|
||||
withdrawal = await session.get(Withdrawal, row.withdrawal_id)
|
||||
if withdrawal is not None and withdrawal.status in ("building", "broadcast"):
|
||||
withdrawal.status = "failed"
|
||||
withdrawal.txid = None
|
||||
elif row.kind == "payout":
|
||||
# Payout funds come from the pool address, which isn't tracked in
|
||||
# utxo_events, so there's nothing to release. Clearing payout_txid leaves the
|
||||
# round in "paying_out" with no tx attached, which is the state an operator
|
||||
# (or a future payout-retry routine — still an open gap) can act on.
|
||||
round_ = await session.get(Round, row.round_id) if row.round_id else None
|
||||
if round_ is not None and round_.status == "paying_out":
|
||||
round_.payout_txid = None
|
||||
logger.error(
|
||||
"round %s payout tx %s vanished — round needs operator attention", round_.id, row.current_txid
|
||||
)
|
||||
|
||||
if row.user_id is not None:
|
||||
await recompute_balance(session, row.user_id)
|
||||
|
||||
await write_audit_log(
|
||||
session,
|
||||
"pending_tx_abandoned",
|
||||
{
|
||||
"pending_transaction_id": row.id,
|
||||
"kind": row.kind,
|
||||
"txid": row.current_txid,
|
||||
"reason": reason,
|
||||
"utxos_released": released,
|
||||
},
|
||||
user_id=row.user_id,
|
||||
round_id=row.round_id,
|
||||
)
|
||||
logger.warning(
|
||||
"abandoned %s pending_transaction %s (txid %s): %s — released %s UTXO(s)",
|
||||
row.kind,
|
||||
row.id,
|
||||
row.current_txid,
|
||||
reason,
|
||||
released,
|
||||
)
|
||||
|
||||
|
||||
async def _release_inputs(session: AsyncSession, row: PendingTransaction) -> int:
|
||||
"""Clear spent_txid on every UTXO this transaction consumed, so the balance
|
||||
counts them again. The inputs come from the stored raw tx, which is kept current
|
||||
across RBF bumps, so this works for a bumped tx too."""
|
||||
try:
|
||||
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
|
||||
except Exception:
|
||||
logger.exception("could not parse raw tx of pending_transaction %s; inputs not released", row.id)
|
||||
return 0
|
||||
|
||||
released = 0
|
||||
for vin in tx.vin:
|
||||
utxo = await session.scalar(
|
||||
select(UtxoEvent).where(UtxoEvent.txid == vin.txid.hex(), UtxoEvent.vout == vin.vout)
|
||||
)
|
||||
# Only release what this tx actually reserved: if another tx has since spent
|
||||
# the same UTXO, its claim is the live one and must not be cleared.
|
||||
if utxo is not None and utxo.spent_txid == row.current_txid:
|
||||
utxo.spent_txid = None
|
||||
released += 1
|
||||
return released
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Validation for PLM addresses supplied by the user (withdrawal destinations).
|
||||
|
||||
embit's `Script.from_address` accepts a well-formed bech32 address from *any*
|
||||
chain — a Bitcoin `bc1...` parses fine and yields a perfectly valid witness
|
||||
program — so parse-success alone is not a sufficient check here: a withdrawal
|
||||
to a `bc1...` address would build, sign and broadcast normally on PLM and land
|
||||
on a script nobody holds the key for. The HRP check below is what makes the
|
||||
destination actually PLM, and it matches what the withdrawal form already
|
||||
tells the user (bech32 `plm1q...` only).
|
||||
"""
|
||||
|
||||
from embit import script
|
||||
from embit.base import EmbitError
|
||||
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
|
||||
_BECH32_PREFIX = PLM_MAINNET["bech32"] + "1"
|
||||
|
||||
|
||||
def is_valid_plm_address(address: str) -> bool:
|
||||
if not address.startswith(_BECH32_PREFIX):
|
||||
return False
|
||||
try:
|
||||
script.Script.from_address(address)
|
||||
except EmbitError:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,82 @@
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import PendingTransaction, User, UtxoEvent
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
|
||||
|
||||
async def recompute_balance(session: AsyncSession, user_id: int) -> int:
|
||||
"""Source of truth: sum of this user's confirmed, unspent UTXOs. Updates and
|
||||
returns the read-cache column (User.cached_balance_sats). Must be called
|
||||
within the same transaction as whatever inserted/updated utxo_events rows."""
|
||||
balance = await session.scalar(
|
||||
select(func.sum(UtxoEvent.amount_sats)).where(UtxoEvent.user_id == user_id, UtxoEvent.spent_txid.is_(None))
|
||||
)
|
||||
user = await session.get(User, user_id)
|
||||
user.cached_balance_sats = balance or 0
|
||||
return user.cached_balance_sats
|
||||
|
||||
|
||||
async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[int, bool]:
|
||||
"""Balance including the user's own change still in flight.
|
||||
|
||||
Placing a bet or a withdrawal spends whatever UTXOs cover the amount — often
|
||||
much larger than the amount actually moving, since select_utxos() picks
|
||||
whole UTXOs — and recompute_balance() drops that entire input total from
|
||||
cached_balance_sats the moment the tx is broadcast (spent_txid is set right
|
||||
away, well before the tx has any confirmations). The change output that
|
||||
returns the difference only becomes a UtxoEvent (and so counts toward the
|
||||
confirmed balance again) once it gets its own 1st confirmation. In between,
|
||||
User.cached_balance_sats alone understates the user's real balance by the
|
||||
full unconfirmed change amount, which can look like a much bigger loss than
|
||||
the tx actually represents.
|
||||
|
||||
This walks every in-flight (status="pending") bet/withdrawal PendingTransaction
|
||||
of this user, decodes its current raw tx (kept up to date across RBF bumps —
|
||||
see tx/broadcast.py:bump_fee), and sums whichever outputs pay back to the
|
||||
user's own address. Adding that to cached_balance_sats gives the balance the
|
||||
user will end up with once everything currently in flight confirms.
|
||||
|
||||
The change output's own confirmation is credited by two independent, unordered
|
||||
paths: the Electrum listener (event-driven, near-instant — app/deposits/service.py
|
||||
turns it into a UtxoEvent and folds it into cached_balance_sats via
|
||||
recompute_balance) and this module's PendingTransaction.status flip
|
||||
(app/tx/confirmation.py, polled every 10s). The listener usually wins that race,
|
||||
so for the gap until the poller catches up the row is still "pending" here while
|
||||
the same sats are already inside cached_balance_sats — double-counting the
|
||||
change unless excluded below.
|
||||
|
||||
Returns (pending_inclusive_balance_sats, has_pending) — has_pending tells the
|
||||
caller whether this differs from the confirmed-only balance at all.
|
||||
"""
|
||||
pending = (
|
||||
await session.scalars(
|
||||
select(PendingTransaction).where(
|
||||
PendingTransaction.user_id == user.id,
|
||||
PendingTransaction.kind.in_(("bet", "withdrawal")),
|
||||
# "building" as well as "pending": a building row's UTXOs are already
|
||||
# marked spent (see place_bet's two phases), so leaving it out would
|
||||
# make the displayed balance dip for the duration of the broadcast.
|
||||
PendingTransaction.status.in_(("building", "pending")),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
already_credited = {
|
||||
(txid, vout)
|
||||
for txid, vout in (
|
||||
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user.id))
|
||||
).all()
|
||||
}
|
||||
|
||||
pending_change_sats = 0
|
||||
for row in pending:
|
||||
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
|
||||
for vout, out in enumerate(tx.vout):
|
||||
if (row.current_txid, vout) in already_credited:
|
||||
continue
|
||||
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
|
||||
pending_change_sats += out.value
|
||||
|
||||
return user.cached_balance_sats + pending_change_sats, bool(pending)
|
||||
@@ -0,0 +1,63 @@
|
||||
import os
|
||||
|
||||
from embit import script
|
||||
from embit.bip32 import HDKey
|
||||
from embit.ec import PrivateKey
|
||||
|
||||
from app.config import settings
|
||||
from app.wallet.keystore import decrypt_xprv, encrypt_xprv
|
||||
from app.wallet.plm_network import ACCOUNT_PATH, PLM_MAINNET
|
||||
|
||||
_account_key: HDKey | None = None
|
||||
|
||||
|
||||
def generate_master_key(overwrite: bool = False) -> None:
|
||||
"""One-time ops bootstrap: create a random master seed, encrypt it, write it to
|
||||
disk. Not exposed via any API endpoint — run manually before first launch."""
|
||||
if os.path.exists(settings.master_key_path) and not overwrite:
|
||||
raise FileExistsError(f"{settings.master_key_path} already exists")
|
||||
root = HDKey.from_seed(os.urandom(32), version=PLM_MAINNET["xprv"])
|
||||
with open(settings.master_key_path, "wb") as f:
|
||||
f.write(encrypt_xprv(root.to_base58(version=PLM_MAINNET["xprv"])))
|
||||
|
||||
|
||||
def _load_account_key() -> HDKey:
|
||||
global _account_key
|
||||
if _account_key is None:
|
||||
with open(settings.master_key_path, "rb") as f:
|
||||
token = f.read()
|
||||
root = HDKey.from_base58(decrypt_xprv(token))
|
||||
_account_key = root.derive(ACCOUNT_PATH)
|
||||
return _account_key
|
||||
|
||||
|
||||
def derive_user_key(derivation_index: int) -> HDKey:
|
||||
return _load_account_key().derive(f"0/{derivation_index}")
|
||||
|
||||
|
||||
def derive_user_address(derivation_index: int) -> str:
|
||||
pub = derive_user_key(derivation_index).to_public()
|
||||
return script.p2wpkh(pub).address(network=PLM_MAINNET)
|
||||
|
||||
|
||||
def derive_user_wif(derivation_index: int) -> str:
|
||||
"""Exports a user's raw private key (WIF) for manual server-side intervention
|
||||
(e.g. sweeping funds back to a user, or out, if something gets stuck). This is
|
||||
a custodial system — the server already holds the master key this is derived
|
||||
from — but callers must still treat the result as a live secret: log access,
|
||||
never persist it, never return it over an unauthenticated channel."""
|
||||
key = derive_user_key(derivation_index)
|
||||
return PrivateKey(key.secret, compressed=True, network=PLM_MAINNET).wif(network=PLM_MAINNET)
|
||||
|
||||
|
||||
def derive_pool_key() -> HDKey:
|
||||
"""The "indirizzo padre" from the flowchart: all bets are sent here, and
|
||||
payouts are signed with this key. Reserved on branch 1 of the account (branch 0
|
||||
is user addresses), index 0 — not a spec requirement, an implementation choice
|
||||
to keep it in the same encrypted master key rather than a separate secret."""
|
||||
return _load_account_key().derive("1/0")
|
||||
|
||||
|
||||
def derive_pool_address() -> str:
|
||||
pub = derive_pool_key().to_public()
|
||||
return script.p2wpkh(pub).address(network=PLM_MAINNET)
|
||||
@@ -0,0 +1,11 @@
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def encrypt_xprv(xprv_base58: str) -> bytes:
|
||||
return Fernet(settings.xprv_encryption_key).encrypt(xprv_base58.encode())
|
||||
|
||||
|
||||
def decrypt_xprv(token: bytes) -> str:
|
||||
return Fernet(settings.xprv_encryption_key).decrypt(token).decode()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""PLM mainnet params for embit, verified against PalladiumWallet/src/Core/Chain/ChainProfiles.cs.
|
||||
|
||||
Threaded explicitly through every embit call via `network=PLM_MAINNET` rather than
|
||||
registered globally, since this is a long-lived async server (embit has no
|
||||
concept of "current network" beyond what you pass in).
|
||||
"""
|
||||
|
||||
PLM_MAINNET = {
|
||||
"name": "PLM Mainnet",
|
||||
"wif": bytes([0x80]),
|
||||
"p2pkh": bytes([55]),
|
||||
"p2sh": bytes([5]),
|
||||
"bech32": "plm",
|
||||
"xprv": bytes.fromhex("0488ade4"),
|
||||
"xpub": bytes.fromhex("0488b21e"),
|
||||
"yprv": bytes.fromhex("049d7878"),
|
||||
"ypub": bytes.fromhex("049d7cb2"),
|
||||
"zprv": bytes.fromhex("04b2430c"),
|
||||
"zpub": bytes.fromhex("04b24746"),
|
||||
"Yprv": bytes.fromhex("0295b005"),
|
||||
"Ypub": bytes.fromhex("0295b43f"),
|
||||
"Zprv": bytes.fromhex("02aa7a99"),
|
||||
"Zpub": bytes.fromhex("02aa7ed3"),
|
||||
"bip32": 0,
|
||||
}
|
||||
|
||||
BIP44_COIN_TYPE = 746
|
||||
ACCOUNT_PATH = f"m/84h/{BIP44_COIN_TYPE}h/0h"
|
||||
@@ -0,0 +1,254 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from embit import script
|
||||
from embit.bip32 import HDKey
|
||||
from embit.finalizer import finalize_psbt
|
||||
from embit.psbt import PSBT
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
|
||||
# Standard P2WPKH size estimates (vbytes): 10.5-byte overhead (version+counts+locktime+
|
||||
# segwit marker/flag), ~68 vbytes per input, ~31 vbytes per output. Used to size the fee
|
||||
# before signing (fee only needs to be "minimized ~1 sat/vB", not maximally precise).
|
||||
_TX_OVERHEAD_VBYTES = 11
|
||||
_P2WPKH_INPUT_VBYTES = 68
|
||||
_P2WPKH_OUTPUT_VBYTES = 31
|
||||
|
||||
# BIP125 opt-in RBF: any sequence < 0xfffffffe signals replaceability. Set on every
|
||||
# input we create so a stuck tx can later be fee-bumped (tx/broadcast.py, stage 9).
|
||||
RBF_SEQUENCE = 0xFFFFFFFD
|
||||
|
||||
# Below this, an output costs more to spend than it's worth and relay policy rejects
|
||||
# the whole transaction as "dust" — so a small change amount must be left to the fee
|
||||
# instead of being paid back to ourselves. 294 sat is the standard P2WPKH threshold
|
||||
# (the output's own 31 vbytes plus the 67-vbyte input needed to spend it, at the
|
||||
# 3000 sat/kvB dust relay fee). Creating such an output used to make the bet or
|
||||
# withdrawal fail at broadcast with an opaque error (B-06).
|
||||
DUST_LIMIT_SATS = 294
|
||||
|
||||
# Sanity ceiling on any transaction's fee rate — shared by RoundConfig.fee_rate_sat_vb's
|
||||
# admin-facing bound (app/api/routes/admin.py, so the two can't drift apart, the same
|
||||
# reason MIN_PASSWORD_LENGTH is shared in auth/security.py) and tx/broadcast.py's RBF
|
||||
# bump escalation, which refuses to bump a pending_transaction past this rate (B-32) —
|
||||
# without a ceiling, a stuck transaction's fee climbed by 1 sat/vB every bump forever,
|
||||
# eating further and further into the sender's change with no limit.
|
||||
MAX_FEE_RATE_SAT_VB = 10_000
|
||||
|
||||
# Ceiling on how many UTXOs one transaction may spend (B-48). Every extra input costs
|
||||
# ~68 vbytes of fee, and that fee comes out of the amount being moved — so an address
|
||||
# fragmented into hundreds of small deposits would silently erode its own bet (shrinking
|
||||
# the user's share of the pool) or withdrawal, and past a few hundred inputs the tx also
|
||||
# stops being standard and gets refused at broadcast. Failing the build with a
|
||||
# translatable error is the honest outcome; consolidating the address is the way out.
|
||||
MAX_TX_INPUTS = 50
|
||||
|
||||
|
||||
class InsufficientFundsError(Exception):
|
||||
"""`code` is the machine-readable identifier the API layer forwards to the
|
||||
client so it can translate the failure (see app/api/errors.py); the message
|
||||
itself stays English, and `params` carries the values it interpolates so the
|
||||
translation can place them wherever its own grammar needs them."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: str = "insufficient_balance",
|
||||
**params: int | str,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.params = params
|
||||
|
||||
|
||||
@dataclass
|
||||
class Utxo:
|
||||
txid: str
|
||||
vout: int
|
||||
amount_sats: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuiltTransaction:
|
||||
raw_hex: str
|
||||
txid: str
|
||||
fee_sats: int
|
||||
recipient_sats: int
|
||||
change_sats: int
|
||||
spent_utxos: list[Utxo]
|
||||
|
||||
|
||||
def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
|
||||
return _TX_OVERHEAD_VBYTES + n_inputs * _P2WPKH_INPUT_VBYTES + n_outputs * _P2WPKH_OUTPUT_VBYTES
|
||||
|
||||
|
||||
def select_utxos(utxos: list[Utxo], target_sats: int) -> tuple[list[Utxo], int]:
|
||||
"""Greedily select UTXOs (largest first, to minimize input count) covering
|
||||
target_sats — the amount deducted from the sender's balance. The fee is paid
|
||||
out of target_sats (see build_signed_transaction), not added on top of it.
|
||||
|
||||
At most MAX_TX_INPUTS are ever selected (B-48): if the largest MAX_TX_INPUTS
|
||||
UTXOs don't cover the target, the balance is there but too fragmented to spend
|
||||
in one transaction, which is a different failure from having no funds at all
|
||||
and gets its own code."""
|
||||
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
|
||||
selected: list[Utxo] = []
|
||||
total = 0
|
||||
for utxo in ordered:
|
||||
if len(selected) == MAX_TX_INPUTS:
|
||||
raise InsufficientFundsError(
|
||||
f"balance too fragmented: more than {MAX_TX_INPUTS} inputs would be needed",
|
||||
code="too_many_inputs",
|
||||
max_inputs=MAX_TX_INPUTS,
|
||||
)
|
||||
selected.append(utxo)
|
||||
total += utxo.amount_sats
|
||||
if total >= target_sats:
|
||||
return selected, total
|
||||
raise InsufficientFundsError("not enough confirmed balance to cover amount")
|
||||
|
||||
|
||||
def build_signed_transaction(
|
||||
*,
|
||||
signing_key: HDKey,
|
||||
from_script: script.Script,
|
||||
utxos: list[Utxo],
|
||||
to_address: str,
|
||||
amount_sats: int,
|
||||
change_address: str,
|
||||
fee_rate_sat_vb: int,
|
||||
) -> BuiltTransaction:
|
||||
"""Build, sign and finalize a single-recipient P2WPKH transaction with change
|
||||
back to change_address.
|
||||
|
||||
`amount_sats` is deducted from the sender's balance in full: the recipient
|
||||
receives `amount_sats - fee`, change = total_in - amount_sats. This matches the
|
||||
spec everywhere a single-recipient tx is used (bet, withdrawal): "fee deducted
|
||||
from the amount being moved", not paid on top by the sender.
|
||||
|
||||
A change amount below DUST_LIMIT_SATS is dropped and left to the fee — paying it
|
||||
back to ourselves would produce an unrelayable transaction. The fee estimate
|
||||
already assumes two outputs, so dropping one never underpays.
|
||||
"""
|
||||
selected, total_in = select_utxos(utxos, amount_sats)
|
||||
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
|
||||
recipient_amount = amount_sats - fee
|
||||
if recipient_amount <= 0:
|
||||
raise InsufficientFundsError(
|
||||
"amount too small to cover the network fee", code="amount_below_network_fee"
|
||||
)
|
||||
change = total_in - amount_sats
|
||||
if change < DUST_LIMIT_SATS:
|
||||
fee += change # dust change is unspendable and unrelayable — miners get it
|
||||
change = 0
|
||||
if recipient_amount < DUST_LIMIT_SATS:
|
||||
raise InsufficientFundsError(
|
||||
"amount too small to be sent (dust)", code="amount_below_dust_limit"
|
||||
)
|
||||
|
||||
# TransactionInput.txid is natural/display byte order (as in tx_hash from Electrum);
|
||||
# embit reverses it internally when serializing to wire format.
|
||||
vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
|
||||
vout = [TransactionOutput(recipient_amount, script.Script.from_address(to_address))]
|
||||
if change > 0:
|
||||
vout.append(TransactionOutput(change, script.Script.from_address(change_address)))
|
||||
|
||||
tx = Transaction(vin=vin, vout=vout)
|
||||
psbt = PSBT(tx)
|
||||
for i, utxo in enumerate(selected):
|
||||
psbt.inputs[i].witness_utxo = TransactionOutput(utxo.amount_sats, from_script)
|
||||
|
||||
signed_count = psbt.sign_with(signing_key)
|
||||
if signed_count != len(selected):
|
||||
raise RuntimeError(f"expected {len(selected)} signatures, got {signed_count}")
|
||||
|
||||
final_tx = finalize_psbt(psbt)
|
||||
if final_tx is None:
|
||||
raise RuntimeError("failed to finalize PSBT")
|
||||
|
||||
raw = final_tx.serialize()
|
||||
return BuiltTransaction(
|
||||
raw_hex=raw.hex(),
|
||||
txid=final_tx.txid().hex(),
|
||||
fee_sats=fee,
|
||||
recipient_sats=recipient_amount,
|
||||
change_sats=change,
|
||||
spent_utxos=selected,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PayoutTransaction:
|
||||
raw_hex: str
|
||||
txid: str
|
||||
fee_sats: int
|
||||
winner_sats: int
|
||||
commission_sats: int
|
||||
change_sats: int
|
||||
spent_utxos: list[Utxo]
|
||||
|
||||
|
||||
def build_payout_transaction(
|
||||
*,
|
||||
signing_key: HDKey,
|
||||
from_script: script.Script,
|
||||
utxos: list[Utxo],
|
||||
winner_address: str,
|
||||
winner_share_sats: int,
|
||||
fee_address: str,
|
||||
commission_sats: int,
|
||||
change_address: str,
|
||||
fee_rate_sat_vb: int,
|
||||
) -> PayoutTransaction:
|
||||
"""Build, sign and finalize the round payout: pool -> winner + fee address,
|
||||
with change back to the pool itself. Per spec, only the winner's share
|
||||
absorbs the tx fee — the commission (fee_address) output is untouched.
|
||||
|
||||
As in build_signed_transaction, dust-sized pool change is left to the fee
|
||||
rather than creating an unrelayable output (B-06)."""
|
||||
target = winner_share_sats + commission_sats
|
||||
selected, total_in = select_utxos(utxos, target)
|
||||
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
|
||||
winner_amount = winner_share_sats - fee
|
||||
if winner_amount < DUST_LIMIT_SATS:
|
||||
raise InsufficientFundsError(
|
||||
"winner share too small to cover the network fee", code="winner_share_below_network_fee"
|
||||
)
|
||||
if commission_sats < DUST_LIMIT_SATS:
|
||||
raise InsufficientFundsError(
|
||||
"commission share too small to be paid out (dust)", code="commission_below_dust_limit"
|
||||
)
|
||||
change = total_in - target
|
||||
if change < DUST_LIMIT_SATS:
|
||||
fee += change
|
||||
change = 0
|
||||
|
||||
vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
|
||||
vout = [
|
||||
TransactionOutput(winner_amount, script.Script.from_address(winner_address)),
|
||||
TransactionOutput(commission_sats, script.Script.from_address(fee_address)),
|
||||
]
|
||||
if change > 0:
|
||||
vout.append(TransactionOutput(change, script.Script.from_address(change_address)))
|
||||
|
||||
tx = Transaction(vin=vin, vout=vout)
|
||||
psbt = PSBT(tx)
|
||||
for i, utxo in enumerate(selected):
|
||||
psbt.inputs[i].witness_utxo = TransactionOutput(utxo.amount_sats, from_script)
|
||||
|
||||
signed_count = psbt.sign_with(signing_key)
|
||||
if signed_count != len(selected):
|
||||
raise RuntimeError(f"expected {len(selected)} signatures, got {signed_count}")
|
||||
|
||||
final_tx = finalize_psbt(psbt)
|
||||
if final_tx is None:
|
||||
raise RuntimeError("failed to finalize PSBT")
|
||||
|
||||
raw = final_tx.serialize()
|
||||
return PayoutTransaction(
|
||||
raw_hex=raw.hex(),
|
||||
txid=final_tx.txid().hex(),
|
||||
fee_sats=fee,
|
||||
winner_sats=winner_amount,
|
||||
commission_sats=commission_sats,
|
||||
change_sats=change,
|
||||
spent_utxos=selected,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import PendingTransaction, Withdrawal
|
||||
from app.tx.confirmation import register_handler
|
||||
|
||||
|
||||
async def _on_withdrawal_confirmed(session: AsyncSession, pending: PendingTransaction) -> None:
|
||||
if pending.withdrawal_id is None:
|
||||
return
|
||||
withdrawal = await session.get(Withdrawal, pending.withdrawal_id)
|
||||
# "building" is reachable if we confirmed before the reconciler promoted the row
|
||||
# (a crash between broadcast and commit — see app/tx/reconcile.py).
|
||||
if withdrawal is not None and withdrawal.status in ("building", "broadcast"):
|
||||
withdrawal.status = "confirmed"
|
||||
withdrawal.confirmed_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
register_handler("withdrawal", _on_withdrawal_confirmed)
|
||||
@@ -0,0 +1,176 @@
|
||||
from embit import script
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
from app.wallet.address import is_valid_plm_address
|
||||
from app.wallet.balance import compute_pending_balance, recompute_balance
|
||||
from app.wallet.hd import derive_user_key
|
||||
from app.wallet.psbt_builder import (
|
||||
BuiltTransaction,
|
||||
InsufficientFundsError,
|
||||
Utxo,
|
||||
build_signed_transaction,
|
||||
)
|
||||
|
||||
|
||||
class WithdrawalError(ApiError):
|
||||
pass
|
||||
|
||||
|
||||
async def request_withdrawal(
|
||||
session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int
|
||||
) -> Withdrawal:
|
||||
# Checked before anything else: an address from another chain parses fine as a
|
||||
# witness program (see wallet/address.py), so without this the tx would build,
|
||||
# broadcast and be irrecoverable rather than fail.
|
||||
if not is_valid_plm_address(external_address):
|
||||
raise WithdrawalError("invalid_address", "not a valid PLM bech32 address")
|
||||
|
||||
# Withdrawing to your own deposit address is a no-op that costs a network fee,
|
||||
# and it breaks two things that assume the recipient and the change are
|
||||
# distinguishable by address: the RBF bump would shrink the recipient output
|
||||
# instead of the change (tx/broadcast.py:_find_change_output), and
|
||||
# compute_pending_balance would count the amount twice (B-17).
|
||||
if external_address == user.address:
|
||||
raise WithdrawalError(
|
||||
"withdrawal_to_own_address",
|
||||
"that is your own deposit address — withdraw to an external wallet instead",
|
||||
)
|
||||
|
||||
config = await get_round_config(session)
|
||||
if amount_sats < config.bet_amount_sats:
|
||||
raise WithdrawalError(
|
||||
"amount_below_minimum",
|
||||
f"amount below the minimum of {config.bet_amount_sats} sats",
|
||||
minimum_sats=config.bet_amount_sats,
|
||||
)
|
||||
|
||||
unspent = (
|
||||
await session.scalars(
|
||||
select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None))
|
||||
)
|
||||
).all()
|
||||
confirmed_sats = sum(u.amount_sats for u in unspent)
|
||||
if confirmed_sats < amount_sats:
|
||||
# B-37: cached_balance_sats (== confirmed_sats here) can understate the real
|
||||
# balance by a whole unconfirmed change output right after a bet/withdrawal —
|
||||
# the UI shows pending_balance_sats instead (compute_pending_balance), which
|
||||
# can cover an amount this check would otherwise reject as flatly
|
||||
# "insufficient". Distinguish "you don't have the money" from "your money
|
||||
# hasn't confirmed yet" so the error doesn't contradict what the user is
|
||||
# looking at on screen.
|
||||
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user)
|
||||
if has_pending and pending_inclusive_sats >= amount_sats:
|
||||
raise WithdrawalError(
|
||||
"balance_pending_confirmation",
|
||||
"the requested amount is covered by your pending balance, which has not confirmed yet",
|
||||
pending_sats=pending_inclusive_sats - confirmed_sats,
|
||||
)
|
||||
raise WithdrawalError("insufficient_balance", "insufficient balance", required_sats=amount_sats)
|
||||
|
||||
user_key = derive_user_key(user.derivation_index)
|
||||
from_script = script.p2wpkh(user_key.to_public())
|
||||
utxos = [Utxo(u.txid, u.vout, u.amount_sats) for u in unspent]
|
||||
|
||||
try:
|
||||
built = build_signed_transaction(
|
||||
signing_key=user_key,
|
||||
from_script=from_script,
|
||||
utxos=utxos,
|
||||
to_address=external_address,
|
||||
amount_sats=amount_sats,
|
||||
change_address=user.address,
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
)
|
||||
except InsufficientFundsError as exc:
|
||||
raise WithdrawalError(exc.code, str(exc), **exc.params) from exc
|
||||
|
||||
# Persist the intent before broadcasting, and only promote the rows once the
|
||||
# network has accepted the tx — same two-phase shape as place_bet (B-08).
|
||||
spent_by_key = {(u.txid, u.vout): u for u in unspent}
|
||||
for spent in built.spent_utxos:
|
||||
spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid
|
||||
await recompute_balance(session, user.id)
|
||||
|
||||
withdrawal = Withdrawal(
|
||||
user_id=user.id,
|
||||
external_address=external_address,
|
||||
amount_requested_sats=amount_sats,
|
||||
amount_sent_sats=built.recipient_sats,
|
||||
txid=built.txid,
|
||||
status="building",
|
||||
)
|
||||
session.add(withdrawal)
|
||||
await session.flush()
|
||||
pending = PendingTransaction(
|
||||
kind="withdrawal",
|
||||
withdrawal_id=withdrawal.id,
|
||||
user_id=user.id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
status="building",
|
||||
)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
await client.broadcast(built.raw_hex)
|
||||
except Exception as exc:
|
||||
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, str(exc))
|
||||
raise WithdrawalError(
|
||||
"broadcast_failed", f"the network refused the transaction: {exc}"
|
||||
) from exc
|
||||
|
||||
withdrawal.status = "broadcast"
|
||||
pending.status = "pending"
|
||||
await write_audit_log(
|
||||
session,
|
||||
"withdrawal_sent",
|
||||
{"txid": built.txid, "amount_sent_sats": built.recipient_sats, "external_address": external_address},
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(withdrawal)
|
||||
broadcaster.publish() # balance just went "pending" — nudge the dashboard to refetch
|
||||
return withdrawal
|
||||
|
||||
|
||||
async def _release_failed_withdrawal(
|
||||
session: AsyncSession,
|
||||
withdrawal: Withdrawal,
|
||||
pending: PendingTransaction,
|
||||
built: BuiltTransaction,
|
||||
user_id: int,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Nothing reached the chain, so free the reserved UTXOs and restore the balance.
|
||||
The Withdrawal row is kept (marked "failed") rather than deleted: unlike a bet, a
|
||||
withdrawal is an instruction the user gave, and they should be able to see that it
|
||||
didn't go through."""
|
||||
for spent in built.spent_utxos:
|
||||
row = await session.scalar(
|
||||
select(UtxoEvent).where(UtxoEvent.txid == spent.txid, UtxoEvent.vout == spent.vout)
|
||||
)
|
||||
if row is not None:
|
||||
row.spent_txid = None
|
||||
withdrawal.status = "failed"
|
||||
withdrawal.txid = None
|
||||
pending.status = "failed"
|
||||
pending.failure_reason = reason[:128]
|
||||
await recompute_balance(session, user_id)
|
||||
await write_audit_log(
|
||||
session,
|
||||
"withdrawal_broadcast_failed",
|
||||
{"txid": built.txid, "reason": reason[:200]},
|
||||
user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
broadcaster.publish() # the reserved UTXOs are spendable again — refetch the balance (B-49)
|
||||
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=sqlite+aiosqlite:////app/db_data/plm_lottery.db
|
||||
- MASTER_KEY_PATH=/app/key_data/master.xprv.enc
|
||||
volumes:
|
||||
- ./data/db:/app/db_data
|
||||
- ./data/keys:/app/key_data
|
||||
- ./data/logs:/app/logs
|
||||
expose:
|
||||
- "8123"
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
- SITE_ADDRESS=${SITE_ADDRESS:-localhost}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -0,0 +1,183 @@
|
||||
# Guida admin
|
||||
|
||||
Come gestire la configurazione operativa di PLM Lottery. Presuppone che il
|
||||
server sia già avviato — vedi [running-the-server.md](running-the-server.md).
|
||||
|
||||
## Accesso
|
||||
|
||||
Il pannello admin è su **`https://<host>/admin`** — **non è collegato** da
|
||||
nessun link nell'interfaccia utente (né in entrata né in uscita): ci si
|
||||
arriva solo conoscendo l'URL. La pagina mostra solo un campo token finché non
|
||||
accedi: non è protetta da login personale, ma da un **token condiviso**
|
||||
(`ADMIN_TOKEN`, definito in `.env`).
|
||||
|
||||
Incolla il valore di `ADMIN_TOKEN` e premi "Accedi" (o Invio): se il token è
|
||||
valido, si apre la dashboard con una navbar in alto e carica automaticamente
|
||||
tutte le sezioni — nessun bottone "Carica" separato. Il token resta in
|
||||
`sessionStorage` (si perde chiudendo la tab/il browser); "Esci" torna alla
|
||||
sola schermata di login.
|
||||
|
||||
## Sezioni della dashboard
|
||||
|
||||
- **Parametri** — configurazione operativa (vedi tabella sotto)
|
||||
- **Utenti** — elenco utenti, saldo, accesso alla chiave privata, reset password
|
||||
- **Round** — storico round: stato, vincitore, importi, txid di payout
|
||||
- **Transazioni pendenti** — bet/payout/prelievi non ancora confermati, candidati al fee-bump RBF
|
||||
- **Audit log** — eventi registrati dal sistema (config cambiata, bet, payout, accessi a chiavi private, ecc.)
|
||||
|
||||
## Parametri
|
||||
|
||||
Tutti i parametri operativi/di business sono nella sezione "Parametri",
|
||||
salvati nel database — modificabili in qualsiasi momento, effetto immediato,
|
||||
**nessun riavvio del server necessario**. Non esiste alcuna variabile
|
||||
d'ambiente equivalente: `.env` contiene solo segreti e configurazione di
|
||||
infrastruttura (chiave master, JWT, Electrum, token admin), non parametri di
|
||||
business — quelli si toccano solo da qui.
|
||||
|
||||
| Campo | Significato |
|
||||
|---|---|
|
||||
| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: i payout **non partono** se questo campo è vuoto. |
|
||||
| **Bet amount (PLM)** | Il costo fisso d'ingresso per round. È anche l'importo minimo prelevabile: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). |
|
||||
| **Durata round (secondi)** | Quanto resta aperto un round prima di chiudersi ed estrarre il vincitore. Il taglio per le nuove giocate scatta esattamente allo scadere di questo tempo (verificato ad ogni bet, non dipende dal ciclo dello scheduler) — è un "semaforo giallo": nessuna nuova entrata, ma le bet già trasmesse prima dello scadere hanno comunque tempo di confermarsi prima che il round chiuda ed estragga. |
|
||||
| **Pausa tra un round e il successivo (secondi)** | Cooldown dopo la chiusura di un round, prima che il successivo si apra — dà tempo ai giocatori di vedere l'esito. |
|
||||
| **Durata animazione estrazione (secondi)** | Tempo minimo per cui la dashboard di ogni utente mostra l'animazione "Estrazione in corso" dopo la chiusura del round, prima di rivelare il vincitore. È solo un minimo: il processo reale aspetta fino a 3 blocchi confermati in sequenza (ultima bet in sospeso, estrazione, payout — ~2 minuti l'uno), quindi l'animazione può durare più a lungo di questo valore, mai meno. |
|
||||
| **Fee rate di rete (sat/vB)** | Fee per byte usata per costruire bet, payout e prelievi. |
|
||||
| **Timeout prima del fee-bump RBF (secondi)** | Dopo quanto tempo senza conferma una transazione viene ritrasmessa con fee più alta. |
|
||||
|
||||
Tutti gli importi in PLM vengono convertiti in sats (1 PLM = 100.000.000 sats)
|
||||
solo nella chiamata API — il backend lavora sempre in sats.
|
||||
|
||||
Su un'istanza nuova (mai avviata), questi campi partono con dei default
|
||||
hardcoded nel codice (`RoundConfig` in `app/db/models.py`: bet 10 PLM, round
|
||||
10 minuti, cooldown 30s, animazione estrazione 20s, fee 1
|
||||
sat/vB, RBF timeout 900s) — vanno
|
||||
comunque rivisti e confermati dal pannello prima del primo utilizzo reale.
|
||||
|
||||
## Manutenzione (pausa/ripresa lotteria)
|
||||
|
||||
In cima alla sezione "Parametri" c'è una card "Manutenzione" con un pulsante
|
||||
per fermare l'apertura di nuovi round — utile per intervenire sul server
|
||||
(aggiornamenti, riavvii) senza lasciare gli utenti a metà di un round o
|
||||
sorprenderli con un'interruzione improvvisa.
|
||||
|
||||
- **"Interrompi dopo questo round"**: il round eventualmente in corso viene
|
||||
**completato normalmente** — chiude, estrae il vincitore da un blocco
|
||||
confermato, e paga il 70/30 come sempre. Solo l'apertura del **round
|
||||
successivo** viene sospesa. Gli utenti vedono un avviso di manutenzione
|
||||
sulla loro dashboard (e sulla home, anche da sloggati) finché la lotteria
|
||||
resta in pausa.
|
||||
- **"Riprendi lotteria"**: annulla la pausa — al prossimo giro dello
|
||||
scheduler (ogni 5 secondi) un nuovo round si apre normalmente (rispettando
|
||||
comunque il cooldown se il precedente si è appena chiuso).
|
||||
|
||||
Ogni pausa/ripresa viene registrata nell'audit log (`lottery_paused` /
|
||||
`lottery_resumed`), ma — come per il resto del pannello — non registra
|
||||
*quale* operatore l'ha premuta (token condiviso, vedi limiti noti in
|
||||
[CLAUDE.md](../CLAUDE.md)).
|
||||
|
||||
**Chi paga il fee-bump RBF?** Quando una bet, un payout o un prelievo resta
|
||||
troppo a lungo senza conferma (oltre il "Timeout prima del fee-bump RBF"), il
|
||||
sistema lo ritrasmette con una fee più alta. Il costo aggiuntivo lo assorbe
|
||||
sempre **chi ha originato la transazione**, non il destinatario: per bet e
|
||||
prelievi è l'utente stesso (gli torna un resto più piccolo), per i payout è
|
||||
il pool (il resto che torna all'indirizzo pool si riduce) — la quota del
|
||||
vincitore e quella delle fee, già fissate, non vengono mai toccate. Se non
|
||||
c'è un resto abbastanza grande da assorbire l'aumento, il bump fallisce e
|
||||
resta un intervento manuale (vedi "Limiti noti").
|
||||
|
||||
## Round
|
||||
|
||||
La sezione "Round" mostra lo storico (`GET /admin/rounds`, ultimi 50 per
|
||||
default): id, stato, orario di apertura, vincitore (username), importo del
|
||||
pool, importo vinto, importo di fee, txid del payout — tutti in PLM salvo il
|
||||
txid.
|
||||
|
||||
## Transazioni pendenti
|
||||
|
||||
`GET /admin/pending-transactions` elenca bet, payout e prelievi ancora senza
|
||||
conferma: tipo, stato, txid corrente, fee rate usata, numero di tentativi
|
||||
(si incrementa a ogni bump RBF) e orario dell'ultima trasmissione. Una riga
|
||||
che resta qui a lungo, con `attempt_count` che sale, indica una transazione
|
||||
in difficoltà — vedi "Limiti noti" sul fallback RBF.
|
||||
|
||||
## Audit log
|
||||
|
||||
`GET /admin/audit-log` elenca gli ultimi 200 eventi registrati dal sistema
|
||||
(tipo evento, payload JSON, utente/round coinvolti, timestamp): bet
|
||||
piazzate, payout inviati, round chiusi, configurazione modificata, accessi
|
||||
alle chiavi private, ecc. È il primo posto da controllare per ricostruire
|
||||
cosa è successo dopo un problema.
|
||||
|
||||
Eventi a cui vale la pena prestare attenzione:
|
||||
|
||||
| Evento | Significato |
|
||||
|---|---|
|
||||
| `config_updated` | Un parametro è stato modificato; il payload contiene valore precedente e nuovo per ogni campo cambiato. |
|
||||
| `bet_broadcast_failed` / `withdrawal_broadcast_failed` | La rete ha rifiutato la transazione. Non è stato speso nulla: gli UTXO sono stati liberati e il saldo dell'utente è tornato come prima. |
|
||||
| `pending_tx_abandoned` | Una transazione trasmessa è scomparsa dalla catena e il sistema l'ha dichiarata persa: UTXO liberati, bet rimossa o prelievo segnato `failed`. Se capita spesso, la fee rate configurata è probabilmente troppo bassa. |
|
||||
| `pending_tx_recovered` | Una transazione che si credeva incompleta è invece finita in catena (tipicamente dopo un riavvio a metà invio) e il sistema l'ha ripresa da sé. |
|
||||
| `payout_failed` | Il payout di un round non è partito. Il round resta in `paying_out` e **richiede intervento manuale**: non esiste un retry automatico. Controlla `fee_address`, il saldo dell'indirizzo pool e la connessione Electrum. |
|
||||
|
||||
## Alternative all'interfaccia grafica
|
||||
|
||||
Le stesse operazioni si possono fare da terminale o da Swagger UI
|
||||
(`https://<host>/docs`, sezione `admin` — disponibile solo se `ENABLE_API_DOCS=true`
|
||||
è impostato in `.env`, disattivata di default perché espone l'intera API),
|
||||
sempre passando `ADMIN_TOKEN` nell'header `X-Admin-Token`:
|
||||
|
||||
```bash
|
||||
# leggere la configurazione
|
||||
curl https://<host>/admin/config -H "X-Admin-Token: <ADMIN_TOKEN>"
|
||||
|
||||
# aggiornarla (importi in sats: 10 PLM = 1000000000; solo i campi passati vengono cambiati)
|
||||
curl -X PUT https://<host>/admin/config \
|
||||
-H "X-Admin-Token: <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"fee_address": "plm1q...", "bet_amount_sats": 1000000000, "round_duration_seconds": 600}'
|
||||
```
|
||||
|
||||
I valori vengono validati: `fee_address` deve essere un indirizzo bech32 PLM
|
||||
valido (`plm1...`) e i parametri numerici hanno limiti di buon senso
|
||||
(`fee_rate_sat_vb` almeno 1, durata round almeno 30s, ecc.). Un valore fuori
|
||||
range viene rifiutato con un errore 422 e la configurazione resta invariata.
|
||||
Il controllo su `fee_address` è deliberatamente severo: un indirizzo di
|
||||
un'altra catena (per esempio `bc1...`) sarebbe formalmente valido come witness
|
||||
program, e il 30% di commissione di ogni round finirebbe su uno script di cui
|
||||
nessuno ha la chiave.
|
||||
|
||||
```bash
|
||||
```
|
||||
|
||||
## Utenti, chiave privata e reset password
|
||||
|
||||
La card "Utenti" elenca id, username, indirizzo e saldo di ogni utente
|
||||
registrato. Il bottone "Mostra" su ogni riga rivela la chiave privata (WIF)
|
||||
di quell'utente, dietro conferma esplicita — serve per interventi manuali
|
||||
(es. restituire fondi bloccati). **Ogni visualizzazione viene registrata
|
||||
nell'audit log** (`admin_privkey_accessed`). Questo non introduce una nuova
|
||||
falla: il server è già custodial, la chiave master da cui derivano tutte le
|
||||
chiavi utente vive sul server — questo pannello espone solo qualcosa che
|
||||
l'operatore può già fare via script.
|
||||
|
||||
Il bottone "Reset" nella colonna "Password" genera una **nuova password
|
||||
casuale** per l'utente e sovrascrive quella esistente — mostrata una sola
|
||||
volta nel pannello, così puoi comunicarla a chi ti ha chiesto aiuto perché
|
||||
l'ha dimenticata. Non è un "recupero": le password sono salvate solo come
|
||||
hash Argon2 (`app/auth/security.py`), quindi quella vecchia **non è mai
|
||||
recuperabile** né per l'admin né per il codice stesso — l'unica opzione è
|
||||
sempre sostituirla con una nuova. Anche questa azione è audit-loggata
|
||||
(`admin_password_reset`) e non esiste alcun flusso self-service equivalente
|
||||
per l'utente: solo un admin col token può farlo.
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Il token è unico e condiviso: non c'è identità per singolo admin né audit
|
||||
di chi ha cambiato cosa (oltre alla tabella `audit_log` generica).
|
||||
- Nessun rate limiting sugli endpoint admin (né su registrazione/bet/
|
||||
prelievo utente).
|
||||
- Se un payout fallisce (es. Electrum disconnesso, UTXO insufficienti), il
|
||||
round resta bloccato in `paying_out` senza retry automatico — richiede
|
||||
intervento manuale.
|
||||
|
||||
Per l'elenco completo dei gap noti vedi la sezione "Known gaps / TODO" in
|
||||
`CLAUDE.md`.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Guida utente
|
||||
|
||||
Come usare PLM Lottery dall'interfaccia web (`https://<host>/` — vedi
|
||||
[running-the-server.md](running-the-server.md) per come avviare il server).
|
||||
|
||||
## Registrazione e accesso
|
||||
|
||||
Nella schermata iniziale trovi due tab: **Registrati** e **Login**.
|
||||
|
||||
- **Registrati**: scegli username e password. Al termine ti viene assegnato
|
||||
automaticamente un **indirizzo di deposito personale** (derivato
|
||||
server-side) — è per sempre tuo, e riceverai anche eventuali vincite su
|
||||
quello stesso indirizzo.
|
||||
- **Login**: se hai già un account, accedi con username e password.
|
||||
|
||||
La sessione resta salvata nel browser (fino al logout): non serve rifare
|
||||
login ogni volta che riapri la pagina.
|
||||
|
||||
## La dashboard
|
||||
|
||||
Dopo l'accesso vedi, in ordine:
|
||||
|
||||
1. **Barra di navigazione** (fissa in alto) — il tuo username e il bottone
|
||||
"Esci" (logout) nella riga superiore, e i tab delle sezioni subito sotto
|
||||
2. **Card del round corrente** — sempre visibile, indipendentemente dalla
|
||||
sezione che stai guardando:
|
||||
- numero del round e stato (*aperto*, *in chiusura*, *estrazione in
|
||||
corso*, *pagamento in corso*)
|
||||
- **timer** che conta alla rovescia il tempo rimanente prima della
|
||||
chiusura del round
|
||||
- **giocatori**: quanti hanno già piazzato una bet in questo round
|
||||
- **jackpot**: quanto riceverà chi vince questo round
|
||||
3. **Tab di navigazione** con quattro sezioni:
|
||||
|
||||
### Estrazione del vincitore
|
||||
|
||||
Appena il timer arriva a zero, **nessun nuovo giocatore può più entrare nel
|
||||
round** — è un "semaforo giallo": il conteggio raggiunto lo zero blocca da
|
||||
subito le nuove giocate, ma il round non chiude immediatamente. Se qualcuno
|
||||
aveva già piazzato una bet negli ultimi istanti (transazione trasmessa ma
|
||||
non ancora confermata), il round aspetta che anche quella si confermi prima
|
||||
di procedere, così nessuna giocata già fatta viene persa al confine del
|
||||
round. Solo a quel punto la card mostra un messaggio di stato ("Round
|
||||
chiuso — attesa conferma puntate…", poi "Estrazione in corso…", poi
|
||||
"Pagamento al vincitore in corso…") al posto del timer — la stessa cosa
|
||||
compare nella dashboard di **ogni** utente, anche di chi non ha giocato in
|
||||
questo round. Questo messaggio resta visibile per l'intera durata della fase
|
||||
(chiusura → estrazione → pagamento), perché sotto la copertina servono
|
||||
**fino a tre conferme sulla rete PLM in sequenza**, una diversa dall'altra:
|
||||
|
||||
1. conferma dell'ultima giocata rimasta in sospeso (se ce n'era una proprio
|
||||
allo scadere del timer — altrimenti questo passo è già superato);
|
||||
2. un nuovo blocco dopo la chiusura, il cui hash serve a scegliere il
|
||||
vincitore;
|
||||
3. la conferma della transazione che paga effettivamente la vincita.
|
||||
|
||||
Con un blocco PLM ogni ~2 minuti, il tempo reale dall'azzeramento del
|
||||
timer all'accredito della vincita è quindi in media **4-6 minuti** (se
|
||||
c'era una giocata da confermare all'ultimo istante) o **2-4 minuti** (se
|
||||
tutte le giocate erano già confermate prima dello zero) — non pochi
|
||||
secondi, ed è normale.
|
||||
|
||||
Se **hai giocato in questo round**, appena il vincitore è determinato compare
|
||||
**in aggiunta** (non al posto del messaggio di stato sopra, che resta
|
||||
visibile finché il pagamento non è confermato) un secondo riquadro solo per
|
||||
te:
|
||||
- **"🎉 Hai vinto! +N PLM"** se sei tu il vincitore — l'importo ti verrà
|
||||
accreditato non appena la transazione di payout viene confermata (il
|
||||
round successivo non si apre finché questo non accade)
|
||||
- **"Non hai vinto questa volta."** altrimenti
|
||||
|
||||
Chi non ha giocato in questo round non vede mai questo secondo riquadro,
|
||||
solo il messaggio di stato generico. Il riquadro personale resta visibile
|
||||
anche **dopo un refresh della pagina** (persiste nel browser), fino
|
||||
all'apertura del round successivo — non serve restare sulla pagina per non
|
||||
perderlo, e se hai perso completamente la finestra in tempo reale (es. tab in
|
||||
background per diversi minuti), lo vedrai comunque comparire non appena
|
||||
riapri la dashboard.
|
||||
|
||||
La dashboard si aggiorna anche **in tempo reale**, non solo a intervalli
|
||||
fissi: appena qualcosa cambia sul server (una giocata, un cambio di fase del
|
||||
round, un nuovo blocco confermato...) la pagina lo recepisce quasi subito,
|
||||
senza bisogno di premere "Aggiorna" o ricaricare.
|
||||
|
||||
### Avviso di manutenzione
|
||||
|
||||
Se l'operatore ha messo in pausa la lotteria per manutenzione, in cima alla
|
||||
pagina (visibile anche prima del login) compare un avviso: il round
|
||||
eventualmente in corso viene comunque **completato normalmente**, vincitore
|
||||
incluso, ma **non ne parte uno nuovo** finché la manutenzione non termina.
|
||||
L'avviso sparisce da solo appena l'operatore riprende la lotteria.
|
||||
|
||||
### Deposito
|
||||
|
||||
- Il tuo **saldo interno**, con bottone "Aggiorna" per ricontrollarlo. Il
|
||||
numero mostrato include anche il resto di una bet o un prelievo appena
|
||||
inviati (non ancora confermato sulla rete) — non solo la parte già
|
||||
confermata — così non sembra che il saldo sia crollato più del dovuto
|
||||
subito dopo un'operazione. Il colore indica lo stato:
|
||||
- **verde**: tutto confermato, il saldo mostrato è quello definitivo
|
||||
- **arancione**: c'è una bet o un prelievo ancora in attesa di conferma —
|
||||
il numero è corretto, ma non ancora "finale"
|
||||
- Il tuo **indirizzo di deposito**, con bottone per copiarlo negli appunti
|
||||
- Il **QR code** dello stesso indirizzo, comodo per inviare PLM da un altro
|
||||
wallet scansionandolo invece di copiare l'indirizzo a mano
|
||||
|
||||
Per depositare, invia PLM (mainnet reale) a quell'indirizzo da un wallet
|
||||
esterno. Il saldo si aggiorna da solo dopo la prima conferma (e quasi subito,
|
||||
grazie all'aggiornamento in tempo reale); premi "Aggiorna" se vuoi comunque
|
||||
ricontrollarlo a mano.
|
||||
|
||||
### Bet
|
||||
|
||||
Un bottone unico: piazza l'ingresso a costo fisso (mostrato in PLM) nel round
|
||||
corrente. Puoi avere **al massimo una bet attiva alla volta**. Il costo viene
|
||||
scalato dal tuo saldo interno.
|
||||
|
||||
### Prelievo
|
||||
|
||||
Form con due campi:
|
||||
- **Indirizzo esterno**: dove vuoi ricevere i PLM
|
||||
- **Importo (PLM)**: quanto prelevare, oppure spunta **"Preleva l'intero
|
||||
importo"** per prelevare tutto il saldo confermato senza doverlo
|
||||
ricopiare a mano (il campo importo si disabilita e si aggiorna da solo)
|
||||
|
||||
Il prelievo viene costruito e trasmesso sulla rete; la fee di rete viene
|
||||
scalata dall'importo richiesto (non si aggiunge separatamente). L'importo
|
||||
minimo prelevabile è pari alla quota fissa di ingresso al round (mostrata
|
||||
nella sezione Bet).
|
||||
|
||||
> **Nota**: attualmente è supportato solo l'indirizzo esterno in formato
|
||||
> **P2WPKH bech32** (quelli che iniziano con `plm1q...`). Non inserire
|
||||
> indirizzi legacy (quelli che iniziano con `P...`) o P2SH: al momento
|
||||
> non sono gestiti correttamente dal server.
|
||||
|
||||
### Profilo
|
||||
|
||||
Due card:
|
||||
|
||||
- **Profilo**: le tue informazioni account — username, indirizzo di
|
||||
deposito, saldo interno e data di iscrizione. Sola lettura, nessuna
|
||||
modifica possibile qui.
|
||||
- **Impostazioni**: form per **cambiare la password**. Serve la password
|
||||
attuale (per conferma) più la nuova password (minimo 8 caratteri, digitata
|
||||
due volte). Non richiede un nuovo login: la sessione attiva resta valida
|
||||
anche dopo il cambio.
|
||||
|
||||
Se hai dimenticato la password e non riesci più ad accedere, questa sezione
|
||||
non ti aiuta (serve la password attuale) — contatta l'operatore della
|
||||
piattaforma, che può reimpostartene una nuova dal pannello admin.
|
||||
|
||||
## Notifiche
|
||||
|
||||
Ogni azione (registrazione, login, bet, prelievo, ecc.) mostra un breve
|
||||
messaggio (toast) verde in caso di successo o rosso in caso di errore, in
|
||||
basso nella pagina. Se qualcosa non va e il messaggio non basta a capire il
|
||||
motivo, il dettaglio tecnico è nei log del server (`logs/app.log` o
|
||||
`data/logs/app.log` con Docker) — non nell'interfaccia.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Avviare il server
|
||||
|
||||
Presuppone che [setup.md](setup.md) sia già stato completato (`.env` pronto,
|
||||
master key generata, migrazioni applicate).
|
||||
|
||||
Il server gira sempre via Docker, in sviluppo e in produzione allo stesso
|
||||
modo — non esiste un modo supportato per lanciare `uvicorn` direttamente.
|
||||
Il venv locale (`.venv/`) serve solo per i test, per scrivere le migrazioni
|
||||
Alembic e per gli script una tantum di generazione chiavi (vedi
|
||||
[setup.md](setup.md) e la sezione "Commands" di
|
||||
[CLAUDE.md](../CLAUDE.md#commands)).
|
||||
|
||||
## Docker + Caddy (unico workflow supportato)
|
||||
|
||||
```bash
|
||||
mkdir -p data/db data/keys data/logs # una tantum, se non già presenti
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
- Caddy fa da reverse proxy davanti all'app e gestisce il TLS automaticamente
|
||||
- App su `https://localhost/` (o sul dominio configurato, vedi sotto)
|
||||
- Pannello admin su `https://localhost/admin`
|
||||
- DB, master key cifrata e log persistono in `./data/` sulla root del repo
|
||||
(bind mount, non volumi Docker opachi) — sopravvivono a stop/rebuild del
|
||||
container e sono ispezionabili/backup-abili direttamente
|
||||
|
||||
### Modalità dev, senza dominio (certificato self-signed)
|
||||
|
||||
Non serve fare nulla: lasciando `SITE_ADDRESS` non impostata, Caddy usa
|
||||
`localhost` di default. Rilevando che non è un hostname pubblico, genera da
|
||||
solo un certificato dalla sua CA interna — il browser mostrerà un avviso di
|
||||
sicurezza al primo accesso (normale, accettalo o usa `curl -k`).
|
||||
|
||||
### Modalità produzione, con dominio reale
|
||||
|
||||
```bash
|
||||
SITE_ADDRESS=lottery.tuodominio.it docker compose up -d
|
||||
```
|
||||
|
||||
Il DNS del dominio deve già puntare all'IP del server, con le porte 80 e 443
|
||||
raggiungibili da internet. Caddy richiede e rinnova automaticamente un
|
||||
certificato Let's Encrypt reale — nessuna configurazione aggiuntiva.
|
||||
|
||||
### Comandi utili
|
||||
|
||||
```bash
|
||||
docker compose logs -f app # segui i log dell'app (anche in ./data/logs/app.log)
|
||||
docker compose ps # stato dei container
|
||||
docker compose stop # ferma senza rimuovere i container
|
||||
docker compose down # ferma e rimuove i container (i dati in ./data/ restano)
|
||||
```
|
||||
|
||||
> **Nota sul `Caddyfile`**: è montato in sola lettura nel container `caddy`
|
||||
> (bind mount). Modificarlo non basta a farlo ripartire con la nuova
|
||||
> configurazione — `docker compose up -d --build` non ricrea `caddy` solo
|
||||
> perché il *contenuto* di un file montato è cambiato. Dopo una modifica al
|
||||
> `Caddyfile` serve un passaggio in più:
|
||||
> ```bash
|
||||
> docker compose restart caddy
|
||||
> ```
|
||||
> (oppure, senza interrompere le connessioni esistenti: `docker compose exec
|
||||
> caddy caddy reload --config /etc/caddy/Caddyfile`).
|
||||
|
||||
### ⚠️ Attenzione: riavvii automatici a metà round
|
||||
|
||||
`docker-compose.yml` imposta `restart: unless-stopped` sul container dell'app:
|
||||
se crasha, riparte da solo. Questo però non è ancora sicuro in ogni caso — se
|
||||
il crash avviene mentre un round è in stato `closing`/`drawing`/`paying_out`,
|
||||
lo scheduler non lo riprende al riavvio e il round resta bloccato (gap noto,
|
||||
vedi "Known gaps" in `CLAUDE.md`). Non trattare questo setup come
|
||||
"non supervisionato" finché quel gap non è risolto.
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Setup
|
||||
|
||||
Passaggi da eseguire una tantum per preparare un'istanza di PLM Lottery, prima di
|
||||
poterla avviare (in locale o via Docker). Per come avviarla poi ogni volta, vedi
|
||||
[running-the-server.md](running-the-server.md).
|
||||
|
||||
## 1. Prerequisiti
|
||||
|
||||
- Python 3.12+ (serve solo per il workflow locale/venv — puoi saltarlo se usi solo Docker)
|
||||
- Docker + Docker Compose (serve solo per il workflow a container)
|
||||
- Un server Electrum raggiungibile per la rete PLM. Il server di bootstrap per lo
|
||||
sviluppo è `santantonio.sytes.net:50002` (SSL) — va bene per i test, ma in
|
||||
produzione conviene usarne uno di cui ci si fida o gestirne uno proprio.
|
||||
- **Consigliato in produzione: più di un server.** Tutto passa da questa singola
|
||||
connessione (accredito depositi, invio transazioni, conferme, altezza della
|
||||
catena su cui si basa l'estrazione), quindi un solo server è il principale
|
||||
punto di rottura della piattaforma. Elencane altri in
|
||||
`ELECTRUM_FALLBACK_SERVERS` (vedi sotto): l'app li prova a rotazione, così un
|
||||
server irraggiungibile costa un solo tentativo di riconnessione invece di un
|
||||
disservizio.
|
||||
|
||||
## 2. Creare il file `.env`
|
||||
|
||||
Copia `.env.example` in `.env` e compila i segreti. Ogni valore sotto viene
|
||||
generato una volta e non cambia più (ruotarlo invalida sessioni/dati cifrati
|
||||
esistenti):
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
| Variabile | Scopo | Come generarla |
|
||||
|---|---|---|
|
||||
| `XPRV_ENCRYPTION_KEY` | Chiave simmetrica che cifra a riposo la master xprv del server. **Perdere questa chiave significa perdere per sempre l'accesso ai fondi di tutti gli utenti.** | `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` |
|
||||
| `JWT_SECRET` | Firma i token di sessione degli utenti. | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
||||
| `ADMIN_TOKEN` | Token bearer richiesto sugli endpoint admin (header `X-Admin-Token`). | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
||||
|
||||
Le altre chiavi di `.env` (`DATABASE_URL`, `ELECTRUM_HOST`/`PORT`/`USE_SSL`,
|
||||
`MASTER_KEY_PATH`) hanno default sensati in `.env.example`.
|
||||
|
||||
`ENABLE_API_DOCS` (default `false`) controlla Swagger/ReDoc/l'OpenAPI JSON grezzo
|
||||
su `/docs`, `/redoc` e `/openapi.json`: espongono l'intera superficie dell'API,
|
||||
endpoint admin inclusi, quindi restano disattivati a meno di non impostarlo
|
||||
esplicitamente a `true` — utile in locale, da evitare in produzione.
|
||||
|
||||
`ELECTRUM_FALLBACK_SERVERS` elenca i server di riserva, separati da virgola, nel
|
||||
formato `host:porta` (TLS, il caso normale) oppure `host:porta:notls`. Esempio:
|
||||
|
||||
```
|
||||
ELECTRUM_FALLBACK_SERVERS=nodo2.example.net:50002,nodo3.example.net:50001:notls
|
||||
```
|
||||
|
||||
Vengono provati a rotazione dopo il primario. Attenzione: un valore scritto male
|
||||
**blocca l'avvio** dell'app — è voluto, meglio accorgersene subito che durante il
|
||||
disservizio in cui il fallback serve davvero.
|
||||
|
||||
`JWT_SECRET` e `XPRV_ENCRYPTION_KEY` vengono verificati all'avvio: se sono vuoti
|
||||
(o `JWT_SECRET` è più corto di 32 caratteri) il container si rifiuta di partire con
|
||||
un errore esplicito, invece di avviarsi e rompersi al primo login. Nota: `.env`
|
||||
contiene solo segreti e configurazione di infrastruttura — i parametri di
|
||||
business (bet amount, durata round, fee, ecc.) si configurano dal pannello
|
||||
admin dopo l'avvio, non qui — vedi [guida-admin.md](guida-admin.md).
|
||||
|
||||
**Non committare mai `.env`.** È già escluso da `.gitignore`.
|
||||
|
||||
## 3. Generare la master key
|
||||
|
||||
Il server deriva l'indirizzo di deposito di ogni utente (e l'indirizzo pool) da
|
||||
un'unica master xprv, generata una volta e cifrata a riposo con
|
||||
`XPRV_ENCRYPTION_KEY`. Questo passaggio va eseguito esattamente una volta per
|
||||
ogni deployment, dopo aver impostato `XPRV_ENCRYPTION_KEY` in `.env`:
|
||||
|
||||
- **Locale/venv**: `PYTHONPATH=. python scripts/generate_master_key.py`
|
||||
- **Docker**: `docker compose run --rm app python scripts/generate_master_key.py`
|
||||
|
||||
Questo scrive un file cifrato (`MASTER_KEY_PATH`, default `./master.xprv.enc` in
|
||||
locale o `./data/keys/master.xprv.enc` con Docker). **Fai il backup di questo
|
||||
file insieme a `XPRV_ENCRYPTION_KEY`** — uno dei due da solo è inutile, ma
|
||||
perderli entrambi insieme significa perdere i fondi di tutti gli utenti senza
|
||||
possibilità di recupero.
|
||||
|
||||
### Recuperare o portare una xprv esistente
|
||||
|
||||
Due script, entrambi manuali/una tantum, per lo scenario di disaster recovery
|
||||
o per usare una xprv generata altrove (es. offline/air-gapped) invece di
|
||||
farla generare al server:
|
||||
|
||||
- **`scripts/decrypt_master_key.py`**: decifra e stampa a schermo la xprv
|
||||
già presente in `MASTER_KEY_PATH` (con fallback automatico su
|
||||
`./data/keys/master.xprv.enc` se il path di `.env` non esiste in locale).
|
||||
Chiede conferma esplicita prima di stampare.
|
||||
- **`scripts/encrypt_master_key.py`**: cifra una xprv esterna e la scrive in
|
||||
`MASTER_KEY_PATH` con lo stesso identico schema (Fernet +
|
||||
`XPRV_ENCRYPTION_KEY`) usato da `generate_master_key.py`. La xprv va
|
||||
incollata con input nascosto (non appare a schermo). Si rifiuta di
|
||||
sovrascrivere un file esistente a meno di passare `--overwrite`.
|
||||
|
||||
Entrambi vanno eseguiti localmente (o dentro il container via
|
||||
`docker compose run --rm app ...`), mai esposti da un endpoint API o dal
|
||||
pannello admin: chi ottiene questa xprv ottiene il controllo dei fondi di
|
||||
tutti gli utenti e del pool.
|
||||
|
||||
## 4. Installare le dipendenze (solo workflow locale/venv)
|
||||
|
||||
Salta questo passaggio se usi solo Docker — l'immagine installa le proprie
|
||||
dipendenze durante la build.
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## 5. Applicare le migrazioni del database
|
||||
|
||||
- **Locale/venv**: `alembic upgrade head`
|
||||
- **Docker**: le migrazioni vengono eseguite automaticamente all'avvio del
|
||||
container (vedi il `CMD` del `Dockerfile`) — nessun passaggio manuale.
|
||||
|
||||
## 6. Impostare l'indirizzo delle fee
|
||||
|
||||
Prima che il primo round possa pagare, un admin deve impostare `fee_address`
|
||||
tramite il pannello admin o l'API — vedi [guida-admin.md](guida-admin.md). I
|
||||
payout si rifiutano di partire finché non è impostato.
|
||||
|
||||
A questo punto l'istanza è pronta per essere avviata — continua con
|
||||
[running-the-server.md](running-the-server.md).
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+163
@@ -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} · 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."
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
||||
@@ -0,0 +1,85 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
from app.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db import models # noqa: F401 (registers models on Base.metadata)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add draw_animation_seconds to round_config
|
||||
|
||||
Revision ID: 1db52f3a7c67
|
||||
Revises: 53cc70d16e63
|
||||
Create Date: 2026-07-21 15:45:16.434942
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1db52f3a7c67'
|
||||
down_revision: Union[str, Sequence[str], None] = '53cc70d16e63'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# server_default backfills the existing singleton row (if any); dropped right
|
||||
# after so new rows go through the ORM default instead of a stale constant.
|
||||
op.add_column(
|
||||
'round_config', sa.Column('draw_animation_seconds', sa.Integer(), nullable=False, server_default='20')
|
||||
)
|
||||
with op.batch_alter_table('round_config') as batch_op:
|
||||
batch_op.alter_column('draw_animation_seconds', server_default=None)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('round_config', 'draw_animation_seconds')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,153 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 274efdcbfbcc
|
||||
Revises:
|
||||
Create Date: 2026-07-20 22:11:47.766165
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '274efdcbfbcc'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('round_config',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('fee_address', sa.String(length=128), nullable=False),
|
||||
sa.Column('bet_amount_sats', sa.BigInteger(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=64), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=256), nullable=False),
|
||||
sa.Column('derivation_index', sa.Integer(), nullable=False),
|
||||
sa.Column('address', sa.String(length=128), nullable=False),
|
||||
sa.Column('cached_balance_sats', sa.BigInteger(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('address'),
|
||||
sa.UniqueConstraint('derivation_index')
|
||||
)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
op.create_table('rounds',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('opened_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('closed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('draw_block_height', sa.Integer(), nullable=True),
|
||||
sa.Column('draw_block_hash', sa.String(length=64), nullable=True),
|
||||
sa.Column('seed_int', sa.String(length=128), nullable=True),
|
||||
sa.Column('winner_user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('pool_amount_sats', sa.BigInteger(), nullable=True),
|
||||
sa.Column('winner_amount_sats', sa.BigInteger(), nullable=True),
|
||||
sa.Column('fee_amount_sats', sa.BigInteger(), nullable=True),
|
||||
sa.Column('payout_txid', sa.String(length=64), nullable=True),
|
||||
sa.ForeignKeyConstraint(['winner_user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('utxo_events',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('txid', sa.String(length=64), nullable=False),
|
||||
sa.Column('vout', sa.Integer(), nullable=False),
|
||||
sa.Column('amount_sats', sa.BigInteger(), nullable=False),
|
||||
sa.Column('confirmed_height', sa.Integer(), nullable=False),
|
||||
sa.Column('confirmed_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('spent_txid', sa.String(length=64), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('txid', 'vout')
|
||||
)
|
||||
op.create_index(op.f('ix_utxo_events_user_id'), 'utxo_events', ['user_id'], unique=False)
|
||||
op.create_table('withdrawals',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('external_address', sa.String(length=128), nullable=False),
|
||||
sa.Column('amount_requested_sats', sa.BigInteger(), nullable=False),
|
||||
sa.Column('amount_sent_sats', sa.BigInteger(), nullable=True),
|
||||
sa.Column('txid', sa.String(length=64), nullable=True),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_withdrawals_user_id'), 'withdrawals', ['user_id'], unique=False)
|
||||
op.create_table('audit_log',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('event_type', sa.String(length=32), nullable=False),
|
||||
sa.Column('payload_json', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('round_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['round_id'], ['rounds.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('pending_transactions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('kind', sa.String(length=16), nullable=False),
|
||||
sa.Column('round_id', sa.Integer(), nullable=True),
|
||||
sa.Column('withdrawal_id', sa.Integer(), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('current_txid', sa.String(length=64), nullable=False),
|
||||
sa.Column('fee_rate_sat_vb', sa.Integer(), nullable=False),
|
||||
sa.Column('raw_tx_hex', sa.String(), nullable=False),
|
||||
sa.Column('broadcast_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('replaced_by_txid', sa.String(length=64), nullable=True),
|
||||
sa.Column('attempt_count', sa.Integer(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['round_id'], ['rounds.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['withdrawal_id'], ['withdrawals.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('round_participants',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('round_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('bet_amount_sats', sa.BigInteger(), nullable=False),
|
||||
sa.Column('bet_txid', sa.String(length=64), nullable=False),
|
||||
sa.Column('broadcast_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.ForeignKeyConstraint(['round_id'], ['rounds.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('round_id', 'user_id')
|
||||
)
|
||||
op.create_index(op.f('ix_round_participants_round_id'), 'round_participants', ['round_id'], unique=False)
|
||||
op.create_index(op.f('ix_round_participants_user_id'), 'round_participants', ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_round_participants_user_id'), table_name='round_participants')
|
||||
op.drop_index(op.f('ix_round_participants_round_id'), table_name='round_participants')
|
||||
op.drop_table('round_participants')
|
||||
op.drop_table('pending_transactions')
|
||||
op.drop_table('audit_log')
|
||||
op.drop_index(op.f('ix_withdrawals_user_id'), table_name='withdrawals')
|
||||
op.drop_table('withdrawals')
|
||||
op.drop_index(op.f('ix_utxo_events_user_id'), table_name='utxo_events')
|
||||
op.drop_table('utxo_events')
|
||||
op.drop_table('rounds')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_table('round_config')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,59 @@
|
||||
"""add operational params to round_config
|
||||
|
||||
Revision ID: 53cc70d16e63
|
||||
Revises: 274efdcbfbcc
|
||||
Create Date: 2026-07-21 14:44:09.866407
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '53cc70d16e63'
|
||||
down_revision: Union[str, Sequence[str], None] = '274efdcbfbcc'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# server_default backfills the existing singleton row (if any) with the same
|
||||
# defaults app/config.py used before these became DB-editable; dropped right
|
||||
# after so new rows go through the ORM defaults instead of a stale constant.
|
||||
op.add_column(
|
||||
'round_config', sa.Column('round_duration_seconds', sa.Integer(), nullable=False, server_default='600')
|
||||
)
|
||||
op.add_column(
|
||||
'round_config', sa.Column('round_cooldown_seconds', sa.Integer(), nullable=False, server_default='30')
|
||||
)
|
||||
op.add_column(
|
||||
'round_config',
|
||||
sa.Column('min_amount_sats', sa.BigInteger(), nullable=False, server_default='100000000'),
|
||||
)
|
||||
op.add_column(
|
||||
'round_config', sa.Column('fee_rate_sat_vb', sa.Integer(), nullable=False, server_default='1')
|
||||
)
|
||||
op.add_column(
|
||||
'round_config', sa.Column('rbf_timeout_seconds', sa.Integer(), nullable=False, server_default='900')
|
||||
)
|
||||
with op.batch_alter_table('round_config') as batch_op:
|
||||
batch_op.alter_column('round_duration_seconds', server_default=None)
|
||||
batch_op.alter_column('round_cooldown_seconds', server_default=None)
|
||||
batch_op.alter_column('min_amount_sats', server_default=None)
|
||||
batch_op.alter_column('fee_rate_sat_vb', server_default=None)
|
||||
batch_op.alter_column('rbf_timeout_seconds', server_default=None)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('round_config', 'rbf_timeout_seconds')
|
||||
op.drop_column('round_config', 'fee_rate_sat_vb')
|
||||
op.drop_column('round_config', 'min_amount_sats')
|
||||
op.drop_column('round_config', 'round_cooldown_seconds')
|
||||
op.drop_column('round_config', 'round_duration_seconds')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add paused to round_config
|
||||
|
||||
Revision ID: 5f2079b95b33
|
||||
Revises: 1db52f3a7c67
|
||||
Create Date: 2026-07-22 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5f2079b95b33'
|
||||
down_revision: Union[str, Sequence[str], None] = '1db52f3a7c67'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# server_default backfills the existing singleton row (if any); dropped right
|
||||
# after so new rows go through the ORM default instead of a stale constant.
|
||||
op.add_column(
|
||||
'round_config', sa.Column('paused', sa.Boolean(), nullable=False, server_default=sa.false())
|
||||
)
|
||||
with op.batch_alter_table('round_config') as batch_op:
|
||||
batch_op.alter_column('paused', server_default=None)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('round_config', 'paused')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,31 @@
|
||||
"""drop min_amount_sats, withdrawal minimum now equals bet amount
|
||||
|
||||
Revision ID: 6cb50b29f64c
|
||||
Revises: 5f2079b95b33
|
||||
Create Date: 2026-07-22 16:50:38.765233
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6cb50b29f64c'
|
||||
down_revision: Union[str, Sequence[str], None] = '5f2079b95b33'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
with op.batch_alter_table('round_config') as batch_op:
|
||||
batch_op.drop_column('min_amount_sats')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
with op.batch_alter_table('round_config') as batch_op:
|
||||
batch_op.add_column(sa.Column('min_amount_sats', sa.BigInteger(), nullable=False, server_default='100000000'))
|
||||
batch_op.alter_column('min_amount_sats', server_default=None)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add last_broadcast_at to pending_transactions
|
||||
|
||||
Fixes B-27: bump_fee used to overwrite broadcast_at on every RBF bump, but
|
||||
tx/reconcile.py's abandon-after-N-hours grace period is measured from that same
|
||||
column — so a transaction bumped repeatedly but never mined reset that clock on
|
||||
every bump and was never abandoned. broadcast_at now stays the *first* broadcast
|
||||
(what the reconciler measures from); last_broadcast_at is the new column bump_fee
|
||||
updates and should_bump reads to decide whether another bump is due.
|
||||
|
||||
Backfilled from the existing broadcast_at (the best available approximation for
|
||||
rows written before this column existed — for a row never bumped it's exact)
|
||||
before the NOT NULL constraint is applied, so this is safe against any existing
|
||||
data.
|
||||
|
||||
Revision ID: 861e76aaf34c
|
||||
Revises: 8a1c4e7b2d90
|
||||
Create Date: 2026-07-27
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '861e76aaf34c'
|
||||
down_revision: Union[str, Sequence[str], None] = '8a1c4e7b2d90'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('pending_transactions', sa.Column('last_broadcast_at', sa.DateTime(), nullable=True))
|
||||
op.execute('UPDATE pending_transactions SET last_broadcast_at = broadcast_at')
|
||||
with op.batch_alter_table('pending_transactions') as batch_op:
|
||||
batch_op.alter_column('last_broadcast_at', nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('pending_transactions', 'last_broadcast_at')
|
||||
@@ -0,0 +1,38 @@
|
||||
"""widen raw_tx_hex and payload_json to Text
|
||||
|
||||
Fixes B-47: both columns held arbitrary-length data (a raw signed transaction
|
||||
hex, and a JSON audit payload) in an unbounded `String`, which SQLAlchemy
|
||||
compiles to `VARCHAR` with no length. That's accepted by SQLite and
|
||||
PostgreSQL but rejected by other backends (e.g. MySQL requires a length on
|
||||
VARCHAR) — `Text` is the portable type for both.
|
||||
|
||||
Revision ID: 87a0c640355c
|
||||
Revises: 9ef6a51509f7
|
||||
Create Date: 2026-07-27
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '87a0c640355c'
|
||||
down_revision: Union[str, Sequence[str], None] = '9ef6a51509f7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('audit_log') as batch_op:
|
||||
batch_op.alter_column('payload_json', existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=False)
|
||||
with op.batch_alter_table('pending_transactions') as batch_op:
|
||||
batch_op.alter_column('raw_tx_hex', existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('pending_transactions') as batch_op:
|
||||
batch_op.alter_column('raw_tx_hex', existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=False)
|
||||
with op.batch_alter_table('audit_log') as batch_op:
|
||||
batch_op.alter_column('payload_json', existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=False)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Add pending tx failure_reason and the single-active-round index
|
||||
|
||||
Supports two fixes from BUGS.md:
|
||||
|
||||
* B-04 — the reconciler (app/tx/reconcile.py) records *why* it abandoned a
|
||||
transaction, so an operator can tell a dropped tx from a rejected one.
|
||||
* B-09 — "at most one active round" becomes a database guarantee instead of a
|
||||
read-then-insert that two concurrent callers could both pass. A unique index
|
||||
over the constant expression (1), restricted to the active statuses: any number
|
||||
of closed rounds, only ever one live one.
|
||||
|
||||
The index creation is not blind: if an instance already has more than one active
|
||||
round (the very bug this prevents), creating it would fail with an opaque
|
||||
IntegrityError mid-migration. It closes the stale duplicates first, keeping the
|
||||
newest — which is exactly what get_active_round was already doing silently.
|
||||
|
||||
Revision ID: 8a1c4e7b2d90
|
||||
Revises: 6cb50b29f64c
|
||||
Create Date: 2026-07-26
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "8a1c4e7b2d90"
|
||||
down_revision: Union[str, Sequence[str], None] = "6cb50b29f64c"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_ACTIVE = "'open', 'closing', 'drawing', 'paying_out'"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"pending_transactions", sa.Column("failure_reason", sa.String(length=128), nullable=True)
|
||||
)
|
||||
|
||||
connection = op.get_bind()
|
||||
active_ids = [
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
sa.text(f"SELECT id FROM rounds WHERE status IN ({_ACTIVE}) ORDER BY id DESC")
|
||||
)
|
||||
]
|
||||
for stale_id in active_ids[1:]:
|
||||
connection.execute(
|
||||
sa.text("UPDATE rounds SET status = 'closed' WHERE id = :id"), {"id": stale_id}
|
||||
)
|
||||
|
||||
op.execute(f"CREATE UNIQUE INDEX ix_rounds_single_active ON rounds ((1)) WHERE status IN ({_ACTIVE})")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_rounds_single_active")
|
||||
op.drop_column("pending_transactions", "failure_reason")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""add token_version to users
|
||||
|
||||
Revision ID: 943dbd74d983
|
||||
Revises: 861e76aaf34c
|
||||
Create Date: 2026-07-27
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '943dbd74d983'
|
||||
down_revision: Union[str, Sequence[str], None] = '861e76aaf34c'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# server_default backfills every existing user to 0 (their current sessions
|
||||
# stay valid, since 0 also matches what already-issued tokens carry
|
||||
# implicitly — see the "sub"-only tokens issued before this migration);
|
||||
# dropped right after so new rows go through the ORM default instead of a
|
||||
# stale constant.
|
||||
op.add_column(
|
||||
'users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0')
|
||||
)
|
||||
with op.batch_alter_table('users') as batch_op:
|
||||
batch_op.alter_column('token_version', server_default=None)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'token_version')
|
||||
# ### end Alembic commands ###
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user