Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
526a649c8b | ||
|
|
666cb1a0c9 | ||
|
|
162ceed40f | ||
|
|
5f6abe5b32 | ||
|
|
23d58796b6 | ||
|
|
c4b2dc3ea2 | ||
|
|
c0314e2bf0 | ||
|
|
8a0ebecfcc | ||
|
|
8dd913ec59 | ||
|
|
37cc5eeeb5 | ||
|
|
77e07e87dc | ||
|
|
6246b13247 | ||
|
|
0aac73e557 | ||
|
|
57721355f0 | ||
|
|
ab65728bdc | ||
|
|
9c7befe595 | ||
|
|
421fe72a8b | ||
|
|
907e32e9e0 | ||
|
|
8f3cdcb2f8 | ||
|
|
64f62291d2 | ||
|
|
025754c860 | ||
|
|
99d7a1ee00 | ||
|
|
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 |
@@ -2,6 +2,19 @@ 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=
|
||||
@@ -17,3 +30,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# BUGS.md — audit of 2026-08-03
|
||||
|
||||
Third full-codebase audit, opened after the 2026-07-26 (B-01 … B-24) and
|
||||
2026-07-27 (B-25 … B-49) lists were emptied. Numbering continues from the last
|
||||
fixed finding, B-51.
|
||||
|
||||
The list opened at B-52 … B-72 and holds only what is still **open**: a finding is
|
||||
removed from this file once it is fixed, and is not listed here afterwards. Per
|
||||
CLAUDE.md's convention each entry gets its own commit with its own regression test,
|
||||
and the `B-nn` marker goes in a comment next to the fix, so
|
||||
`git log --all --grep 'B-nn'` is the record of how any closed finding was closed.
|
||||
|
||||
State of the tree at audit time: 264 unit tests, all passing; `tests/integration/`
|
||||
still empty; withdrawal and the RBF bump path still never live-broadcast.
|
||||
|
||||
Verified as *not* broken while looking for these: i18n key parity (146 identical
|
||||
keys across all 7 languages), HTML escaping of every user-controlled value in
|
||||
`admin.js`, the Alembic chain (linear, single head, matching `models.py`),
|
||||
strictly-integer satoshi arithmetic everywhere, and `.gitignore` coverage of
|
||||
secrets/DB/logs (nothing sensitive is tracked in git).
|
||||
|
||||
Severity is about consequence, not likelihood:
|
||||
**critical** = money stuck or lost, or the lottery stops;
|
||||
**high** = a security control that does not hold;
|
||||
**medium** = wrong behaviour with a bounded blast radius;
|
||||
**low** = drift between documentation and code.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### (not new) `drawing` does not resume after a restart
|
||||
|
||||
Already tracked as an accepted gap in CLAUDE.md's "Known gaps", not re-numbered
|
||||
here. Worth restating in context: with `restart: unless-stopped` on the app
|
||||
container, this is the one state that gets stuck with money in play, and it
|
||||
remains the last prerequisite for running unattended.
|
||||
|
||||
---
|
||||
|
||||
## Low — documentation and consistency drift
|
||||
|
||||
### B-71 — `.env` points `MASTER_KEY_PATH` at a second copy of the master key
|
||||
|
||||
CLAUDE.md's deployment section prescribes pointing `MASTER_KEY_PATH` at the
|
||||
host-side `./data/keys/master.xprv.enc` so the venv scripts and the container read
|
||||
one file. `.env` instead sets `./master.xprv.enc`, and both files now exist in the
|
||||
working tree (both gitignored). They were verified during this audit to decrypt to
|
||||
the *same* xprv, so nothing has diverged yet — but `scripts/decrypt_master_key.py`
|
||||
reads a different file from the one the container uses, and a future
|
||||
`generate_master_key.py --overwrite` would split them silently, with an ops
|
||||
recovery path that then reports the wrong key.
|
||||
|
||||
The same duplication exists for the database (`./plm_lottery.db` next to
|
||||
`data/db/plm_lottery.db`), which is less dangerous but equally confusing.
|
||||
|
||||
Fix: set `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, delete the stray
|
||||
root copy once confirmed redundant, and state the same for `DATABASE_URL`.
|
||||
|
||||
### B-72 — `docs/setup.md` still frames setup as "locally or via Docker"
|
||||
|
||||
`docs/setup.md:1-10` lists Python as a prerequisite "for the local/venv workflow"
|
||||
and describes the master-key step as local *or* Docker, while B-44 made Docker the
|
||||
only supported way to run the server (`docs/running-the-server.md` and README were
|
||||
updated, this file was not). The venv genuinely is needed for tests, migrations and
|
||||
the key scripts — the wording just needs to say that instead of implying a second
|
||||
way to run the server.
|
||||
@@ -8,158 +8,247 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
||||
|
||||
## Project status
|
||||
|
||||
All 10 build-order stages from `/home/davide/.claude/plans/scalable-mixing-sloth.md` are code-complete and unit-tested (76 tests green): project skeleton, DB schema + Alembic migrations, auth, HD wallet derivation, Electrum client, deposit detection, bet flow, round/draw engine, payout, withdrawal, RBF fee-bump, admin config + audit log. Beyond the original 10 stages: a Docker + Caddy deployment (see below), a full admin dashboard (`/admin`), a static test UI for the user-facing flow (`/`), a pending-inclusive balance display (see "Balance display" below), and a Server-Sent Events push channel layered on top of the original polling (see "Real-time updates" below).
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 351 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.
|
||||
|
||||
Real-money verification on mainnet, done so far: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast, confirmed, change credited back), and a full round cycle — close → draw (real block hash) → payout (70/30 split, exact sat math verified against the broadcast tx) → confirmation → round closed → next round auto-opened. Withdrawal and the RBF bump path are unit-tested but have never been exercised against a live broadcast. See "Known gaps" below before treating this as production-ready.
|
||||
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.
|
||||
|
||||
Before writing code, always read the "Architecture" section below in full, plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) for the whole 5-phase flow, and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) for the round/draw lifecycle in detail. Every node in these diagrams corresponds to a behavior that must be implemented exactly as described, including the labels on the edges (conditions, retries, loops). Regenerate their companion PDFs with `flowchart/render-pdf.sh <file>.mmd` after editing either one.
|
||||
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.
|
||||
|
||||
Human-facing guides live in [docs/](docs/) (Italian, per explicit request — an exception to this file's English-only rule below): [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md).
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
## Commands
|
||||
|
||||
The server itself — in development and in production alike — always runs via Docker (see "Deployment" below); there is no supported way to run `uvicorn` directly against this codebase. The venv (`.venv/`) is only for local tooling: running tests, authoring Alembic migrations, and running the one-time scripts that generate the secrets/key material that end up referenced from `.env`.
|
||||
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.
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate # venv already created at .venv/
|
||||
pip install -e ".[dev]" # install/update deps
|
||||
pip install -e ".[dev]"
|
||||
|
||||
alembic revision --autogenerate -m "message" # generate a new migration after editing app/db/models.py (applied automatically by the container's startup command — see Deployment — never run `alembic upgrade head` manually)
|
||||
alembic revision --autogenerate -m "message" # after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand
|
||||
|
||||
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the server's master xprv (requires XPRV_ENCRYPTION_KEY in .env; see Deployment for where MASTER_KEY_PATH should point)
|
||||
PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print the existing master xprv (asks for confirmation first)
|
||||
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: bring your own externally-generated xprv instead of generating one (getpass prompt, --overwrite to replace)
|
||||
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
|
||||
|
||||
python -m pytest # run all tests
|
||||
python -m pytest tests/unit/test_hd.py # run one test file
|
||||
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # run a single test
|
||||
python -m pytest # all 351 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
|
||||
```
|
||||
|
||||
`.env` (gitignored) holds real secrets for local dev; `.env.example` documents the required keys and how to generate them.
|
||||
`asyncio_mode = "auto"` (`pyproject.toml`), so async tests need no `@pytest.mark.asyncio`.
|
||||
|
||||
`.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.
|
||||
|
||||
## Deployment (Docker + Caddy)
|
||||
|
||||
The app is always run via Docker — dev and prod alike use the same `docker-compose.yml`, just with a different `SITE_ADDRESS` (see below); there's no separate dev-mode compose file or bare-`uvicorn` workflow. `docker-compose.yml` runs two containers: `app` (this codebase, built by `Dockerfile`, runs `alembic upgrade head` then `uvicorn`) and `caddy` (reverse proxy + automatic TLS). `.env` holds the app secrets; `docker-compose.yml` overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted master key, logs — all gitignored, persist across container restarts). Set `MASTER_KEY_PATH` in `.env` itself to the host-side equivalent, `./data/keys/master.xprv.enc`, so the venv-run key-generation scripts above (see "Commands") write to the exact same file the container reads — one source of truth for the key, whichever way it was generated.
|
||||
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: host dirs bind-mounted into the app container
|
||||
|
||||
# one-time: generate the master key via the venv script above (scripts/generate_master_key.py),
|
||||
# not via `docker compose run` — MASTER_KEY_PATH in .env already points at ./data/keys/
|
||||
|
||||
docker compose up -d --build # build + start app and caddy — same command for dev and prod
|
||||
docker compose logs -f app # tail app logs (also written to ./data/logs/app.log)
|
||||
docker compose down # stop
|
||||
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
|
||||
```
|
||||
|
||||
Caddy's site address comes from `SITE_ADDRESS` (env var on the host, read by `docker-compose.yml`):
|
||||
- **Dev, no domain**: leave it unset (defaults to `localhost`). Caddy detects it isn't a public hostname and issues a self-signed cert from its own internal CA — browsers will warn on first visit, expected for local testing (`curl -k` or click through).
|
||||
- **Production, with a domain**: `SITE_ADDRESS=lottery.example.com docker compose up -d` (DNS must already point at the server, ports 80+443 reachable). Caddy automatically requests and renews a real Let's Encrypt certificate — no other config needed.
|
||||
`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).
|
||||
|
||||
Known risk: `docker-compose.yml` sets `restart: unless-stopped` on `app`, so a crash mid-round auto-restarts the container — which hits the scheduler-resume gap below (a round stuck in `closing`/`drawing`/`paying_out` at restart stays stuck). Don't treat this as unattended-safe until that gap is closed.
|
||||
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`. It also overwrites `X-Forwarded-For` with the real peer (`header_up X-Forwarded-For {remote_host}`, B-54) — Caddy otherwise *appends* to whatever the client sent, which made every IP-keyed control (the B-33 throttles, B-38's SSE cap) bypassable; `app/api/client_ip.py` independently reads the *last* hop, so either half closes it. `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 (MVP)
|
||||
## Tech stack
|
||||
|
||||
- **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**: every business/round parameter (fee address, bet amount, round duration, round cooldown, draw animation duration, minimum amount, network fee rate, RBF timeout) lives in the `round_config` DB table (single row, `app/rounds/config.py`) and is only editable live via the admin dashboard (`/admin`) or its API — no env var involved at all, no redeploy or restart needed. Defaults for a brand-new instance are hardcoded column defaults on the `RoundConfig` model (`app/db/models.py`), not `app/config.py`. Secrets and infra wiring (master key, JWT secret, Electrum host, admin token, database URL) stay env-var-driven in `.env` since those genuinely need a restart.
|
||||
- **Round cooldown**: `round_cooldown_seconds` — gap after a round closes before the next one opens, so players have time to see the outcome (default 30s). Not in the original flowchart; added afterwards as an explicit design decision.
|
||||
- **Maintenance pause**: `RoundConfig.paused` (default `false`), toggled via `POST /admin/pause` / `POST /admin/resume` (a dedicated "Manutenzione" card in `/admin`'s Parametri section, not a plain config field — it's a deliberate operator action, audit-logged as `lottery_paused`/`lottery_resumed`). When set, `rounds/service.py:open_new_round_if_needed` stops opening a *next* round once the current one closes — it never interrupts a round already in progress (that one still closes, draws, and pays out its winner normally). `GET /rounds/current` exposes it as `lottery_paused` so the user-facing page (`/`) shows a maintenance banner.
|
||||
- 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). Tokens **are** revocable (B-34): the token carries a `tv` claim, `User.token_version` is bumped by a self-service password change and by an admin reset, and `get_current_user`/`get_optional_user` reject any token whose `tv` no longer matches — so changing the password invalidates every session issued before it, instead of leaving them valid for up to `jwt_expire_minutes`. A token predating the claim decodes as `tv = 0`, which is what a migrated user starts at, so the deploy didn't log everyone out. Argon2 costs tens of ms of CPU per call by design, so every async caller goes through `hash_password_async`/`verify_password_async` (`run_in_threadpool`, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts.
|
||||
- Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or `XPRV_ENCRYPTION_KEY` is empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
|
||||
- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway.
|
||||
|
||||
## PLM network parameters
|
||||
## PLM network parameters (mainnet)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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`
|
||||
| | |
|
||||
|---|---|
|
||||
| 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`, but **snapshotted onto `Round.duration_seconds`/`cooldown_seconds` when a round opens** (B-61) — an edit applies from the next round, never to the one in progress |
|
||||
| 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 *user* tx (bet, withdrawal) | 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` |
|
||||
| Max inputs per *payout* | 500 (`MAX_PAYOUT_TX_INPUTS`, B-52) — the pool holds one UTXO per bet, so reusing the user cap made any round past ~50 players unpayable | hardcoded in `wallet/psbt_builder.py` |
|
||||
| Max participants per round | 400 (`MAX_PARTICIPANTS_PER_ROUND`, B-52) — the 401st bet is refused with `round_full` *before* any money moves, so "a round can always be paid out" is an invariant rather than something discovered at payout time | hardcoded in `wallet/psbt_builder.py`, enforced in `bets/service.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). It counts **confirmed participants only** (B-65), matching what the draw picks from and what the payout can spend, with `pending_participant_count`/`pending_jackpot_sats`/`has_pending_bets` reporting the in-flight bets alongside — inclusive figures, not deltas, exactly like `pending_balance_sats` (see "Balance display"). `/`'s round card shows the confirmed numbers big and the difference as an amber "+N in attesa" suffix, so a player who just bet sees their own bet immediately without the advertised jackpot ever exceeding what will be paid.
|
||||
|
||||
**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.
|
||||
|
||||
**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time.
|
||||
|
||||
## 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`, `bug_reports`; `app/api/errors.py` holds the error contract and `app/api/client_ip.py` the trusted-peer extraction every IP-keyed control uses |
|
||||
| `app/auth/` | routes (register/login), Argon2 + JWT (`security.py`), `get_current_user`/`get_optional_user`, login/registration throttling (`rate_limit.py`) |
|
||||
| `app/db/` | `models.py` (all tables + the active-round index), engine/session factories |
|
||||
| `app/wallet/` | HD derivation + WIF export (`hd.py`), PLM network constants, address/scripthash, balance math, `psbt_builder.py` (build/sign bet, withdrawal, payout; `select_utxos`) |
|
||||
| `app/electrum/` | `client.py` (JSON-RPC, endpoint parsing, timeouts), `listener.py` (the one connection: rotation, keepalive, header validation, corroboration, deposit crediting) |
|
||||
| `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. **A non-null `client` means the tip is already known**: `_run_once` publishes it only after the first header has been applied (B-63), so `client is not None` can be read as "the chain is reachable *and* we know where it is" — `tip_height` is never the initial 0 behind a live client, which is what the draw depends on (see DRAW below). `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. Two more headers are refused *without* ending the session, since neither implies a hostile server (B-64): one at a height we already hold a header for (a reorg at the tip, or one server disagreeing — the hash committed to for a height is never swapped under us, and `corroborate_header` is what catches us holding an orphan), and one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63.
|
||||
- **A quorum corroborates every money-moving decision** (`_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), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum. No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing. A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep.
|
||||
|
||||
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** and only once the other servers corroborate the outpoint and its amount (B-59), 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**, and **at most `MAX_PARTICIPANTS_PER_ROUND` (400) players per round** — past that the bet is refused with `round_full` and the player waits for the next round (B-52: the payout must spend one pool UTXO per bet, so a round is only ever allowed to grow to what a single payout tx can drain). PSBT user-address → pool-address, always with a **change output back to the same user address** of at least `DUST_LIMIT_SATS` — a user's balance must never exactly equal the bet, and since B-62 that's enforced (`balance_leaves_no_change`) rather than assumed. 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* (`round_deadline` = `opened_at + Round.duration_seconds`, the value snapshotted at open time — B-61), **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`. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a **compare-and-set on the round row** (`UPDATE rounds ... WHERE status = 'open'`, B-53): the scheduler flips `open` → `closing` in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only `confirmed` participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is `_close_and_draw` re-counting in-flight bets in the same session it snapshots the participants from.
|
||||
- *"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. The baseline the draw compares against (`tip_at_close`) must be a height we actually knew at closing time: a `0` there means *unknown*, not "the chain is at zero", so `_wait_for_next_block` adopts the first height it then learns as the baseline and waits for a block strictly after it (`draw_baseline_tip_unknown`, B-63) — seeding from a block that already existed while bets were open would make the winner predictable to whoever was watching the chain.
|
||||
- *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. A full-balance withdrawal moves `balance - DUST_LIMIT_SATS` so the change output (and with it the ability to fee-bump) always exists — `Withdrawal.amount_requested_sats` vs `amount_sent_sats` is what records the difference (B-62).
|
||||
|
||||
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. That is the *only* mutual exclusion between them (B-70): a withdrawal is accepted while a bet is still unconfirmed, as long as confirmed, unspent UTXOs cover it — the lock plus `select_utxos` skipping anything already marked `spent_txid` is what prevents the two from picking the same input, so freezing the rest of the balance for a block on top of that would restrict the user without protecting anything.
|
||||
|
||||
**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` (`app/bets/service.py`, `app/withdrawals/service.py`) select whole UTXOs to cover the amount (`select_utxos`, largest-first) and mark every selected UTXO `spent_txid` immediately at broadcast time — well before the tx has any confirmations. `User.cached_balance_sats` (`recompute_balance`, `app/wallet/balance.py`) only sums confirmed, unspent UTXOs, so right after a bet/withdrawal it understates the user's real balance by the entire unconfirmed change amount, which is often far larger than the amount actually moving.
|
||||
`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 touching what's actually spendable: it decodes the raw tx of every in-flight (`status="pending"`) bet/withdrawal `PendingTransaction` belonging to the user and sums whichever outputs pay back to the user's own address, adding that to `cached_balance_sats`. `GET /users/me` returns both `balance_sats` (confirmed-only — still what withdrawal-max and internal spend logic use, since only confirmed UTXOs are actually spendable) and `pending_balance_sats` + `has_pending` (what the frontend displays, colored green when settled and amber while `has_pending` is true).
|
||||
`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` (`app/api/routes/rounds.py`) is a Server-Sent Events channel layered *on top of* the original polling loops in `app/static/index.html`/`admin.html` — polling is the fallback, not replaced, so a blocked/dropped SSE connection just degrades to the pre-existing behavior. The channel carries no payload and needs no auth: it's purely a "something changed, go refetch" ping; personalization (e.g. `user_played` below) still lives entirely in the normal per-user REST endpoints.
|
||||
`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.
|
||||
|
||||
`app/rounds/events.py`'s `RoundEventBroadcaster` (module-level singleton `broadcaster`) is a simple in-process pub/sub — one `asyncio.Queue` (maxsize 1, so redundant notifications coalesce) per connected SSE client. `broadcaster.publish()` is called from every point that changes something a dashboard would want to know about: a new round opening (`rounds/service.py`), every round status transition (`rounds/scheduler.py`: closing/drawing/paying_out/closed), a bet or withdrawal broadcast (`bets/service.py`, `withdrawals/service.py`), any pending tx confirming — bet/withdrawal/payout (`tx/confirmation.py`), a deposit credited (`deposits/service.py`), and a new block tip arriving (`electrum/listener.py` — the exact moment the "drawing" phase is waiting on).
|
||||
`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 decisions, not oversights:
|
||||
- **Single-process only, no cross-worker fan-out.** Fine for the current deployment (one uvicorn process, see `docker-compose.yml`). A multi-worker/multi-container deployment would need a shared channel (e.g. Redis pub/sub) instead — don't add that speculatively before it's actually needed.
|
||||
- **Generic broadcast, not a per-user channel.** Every connected client refetches on every event, even ones irrelevant to them. Acceptable at the expected scale (~100 concurrent users); a targeted per-user channel would need auth on the SSE endpoint and server-side knowledge of who's affected by each event — real engineering work, only worth it well past current expected concurrency.
|
||||
- `MAX_SUBSCRIBERS` (default 500, `app/rounds/events.py`) is a defensive cap only — past it, `GET /rounds/stream` returns 503 instead of opening a stream, and the client's `EventSource` just falls back to polling. Not a substitute for the app-wide "no rate limiting anywhere" gap (see Known gaps).
|
||||
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).
|
||||
|
||||
Frontend: both `index.html` and `admin.html` open an `EventSource('/rounds/stream')` and, on an `update` message *or* on `open` (which fires on the initial connection and every automatic reconnect), immediately re-run the same refresh calls polling would eventually do — this matters most right after a dropped connection reconnects, closing most of the "missed while disconnected" gap.
|
||||
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.
|
||||
|
||||
## MVP business parameters
|
||||
## Transaction lifecycle and reconciliation
|
||||
|
||||
- Bet cost per round: **10 PLM** by default, admin-configurable (`RoundConfig.bet_amount_sats`) — not a fixed constant.
|
||||
- Prize split: **70% winner / 30% fees**, hardcoded in `rounds/scheduler.py` (`winner_share = pool_amount_sats * 70 // 100`) — unlike bet amount, this ratio is not in `RoundConfig` and would need a code change, not an admin-panel edit.
|
||||
- Minimum withdrawal amount: equal to the current bet amount (`RoundConfig.bet_amount_sats`), enforced in `app/withdrawals/service.py` — not a separate admin-configurable field. Deposits have no server-side minimum check.
|
||||
- Confirmations required for all tx types (deposit, bet, payout, withdrawal): **1**, hardcoded in `tx/confirmation.py` — not configurable, per the design decision below.
|
||||
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`.
|
||||
|
||||
## What is PLM Lottery
|
||||
- `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).
|
||||
|
||||
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).
|
||||
`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.
|
||||
|
||||
## Architecture (from the flowchart subgraphs)
|
||||
**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.
|
||||
|
||||
The flow is organized into 5 phases (see [flowchart/platform-overview.mmd](flowchart/platform-overview.mmd) for the full-platform diagram, and [flowchart/round-lifecycle.mmd](flowchart/round-lifecycle.mmd) for the round/draw phase in detail):
|
||||
**"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).
|
||||
|
||||
- **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). The round's own deadline (`opened_at + round_duration_seconds`) is the authoritative "yellow light" cutoff for new bets — **not** the DB status transition. `place_bet` (`app/bets/service.py`) calls `rounds/service.round_accepts_bets(round_, round_duration_seconds)`, which rejects the bet once the deadline has passed even if `status` is still `"open"` in the DB (the `RoundScheduler` tick that flips it to `"closing"` runs every `_TICK_INTERVAL_SECONDS` = 5s and can lag a few seconds behind the deadline). This closes the race where a bet placed in that lag window would otherwise still be accepted. Once a round leaves `open` (closing/drawing/paying_out), **no new bets are accepted** for it either, and a new round can't open until the current one is fully `closed` (see round cooldown below). Round closing **waits for all already-broadcast bets to confirm** before proceeding (avoids losing bets at the round boundary) — this is the "yellow light" behavior: no new entries once the timer hits zero, but bets already in flight are still given time to confirm before the round actually closes and draws. 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. The frontend shows a generic "drawing" status box (phase label, e.g. "Pagamento al vincitore in corso…") to **every** viewer on every dashboard for the whole closing/drawing/paying_out phase — this one is purely cosmetic status text, driven directly by `status`, no gating. Independently and *additively* (not instead of it), a personalized "Hai vinto!/Non hai vinto" box appears only for users where `GET /rounds/current`'s `user_played` field is true (computed via `app/auth/dependencies.py:get_optional_user`, since this endpoint is reachable logged-out too) — everyone else has nothing to reveal and never sees it. That reveal is additionally delayed by at least `draw_animation_seconds` (admin-configurable, default 20s) for cosmetic suspense, anchored to the round's server-provided `closes_at` timestamp rather than a client-side "first seen" time (so reloading the page can't reset the countdown), and decoupled from the real (and much longer, ~block-time) wait for `winner_user_id` to actually be set. Once revealed, the result is persisted in the browser's `localStorage` (`plm_persisted_result`) so it survives a page refresh even after the round moves past `paying_out` into `closed` — at which point `get_active_round` stops returning that round at all and `winner_user_id` disappears from `GET /rounds/current` entirely. `GET /users/me/last-round-result` (`app/api/routes/users.py`) is a durable, DB-backed backstop for a user who reloads on a browser/device that missed the live reveal window completely: it looks up the most recent *closed* round the user has a `RoundParticipant` row in. See `app/static/index.html`'s `refreshRound`/`checkLastRoundResult` for the full reveal logic.
|
||||
- **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.
|
||||
## Frontends
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
**Three separate on-chain confirmations, not one, between the timer hitting zero and the payout landing** — a common point of confusion, worth spelling out explicitly:
|
||||
1. **Last bet's confirmation** (`scheduler.py`'s `_tick`, the `pending_count` check before `_close_and_draw`) — the round doesn't even flip to `"closing"` until every already-broadcast bet has its 1st confirmation. This can already have happened before the timer expired; it's the earliest of the three and not necessarily tied to the deadline at all.
|
||||
2. **The draw block** (`_wait_for_next_block`, waits for `tip_height > tip_at_close`, where `tip_at_close` is recorded only once step 1 is done) — by construction this must be a **later, different block** than whichever one confirmed the last bet in step 1.
|
||||
3. **Payout confirmation** — `_trigger_payout` broadcasts only after step 2's block is known, then registers a `PendingTransaction(kind="payout")` that the same generic `ConfirmationPoller` (`app/tx/confirmation.py`) waits on independently — this needs **yet another, later block** than step 2's, since the payout can't be built before the winner is known.
|
||||
- **`/`** — 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 six sections each backed by its own `/admin/*` endpoint: Parametri (`RoundConfig` + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log, Bug report (triage `open` → `read` → `resolved`, audit-logged `bug_report_status_changed`); plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction.
|
||||
- **`/report-bug`** — standalone page (no navbar, own language switcher), reachable logged-in or logged-out: `POST /bug-reports` stores the report with the submitter attached when there is one, `GET /bug-reports/mine` is the reporter-side status view for the logged-in case, and `/admin`'s Bug report section is the triage end.
|
||||
|
||||
So worst case (last bet confirms right at the deadline) is ~3 block times end-to-end; best case (all bets already confirmed before the timer hit zero) is ~2 (draw block + payout block). At PLM's 120s block time that's roughly 4–6 minutes worst case, 2–4 minutes best case — independent of `draw_animation_seconds`, which only sets a cosmetic minimum for the frontend animation.
|
||||
Both talk to the same JSON API; there's no admin/user API split beyond `require_admin`.
|
||||
|
||||
## Admin dashboard and test UI
|
||||
## Internationalization (`/` and `/report-bug`)
|
||||
|
||||
Two static single-page apps, served directly by FastAPI (`app/main.py` mounts `app/static/` and adds a dedicated `GET /admin` route) — no build step, no framework. Each page's HTML/CSS/JS are separate files (`index.html`/`style.css`/`app.js`, `admin.html`/`admin.css`/`admin.js`), served as plain static files (no bundler):
|
||||
`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.
|
||||
|
||||
- **`/` (`app/static/index.html`)**: the end-user test UI. Register/login, then a menu-driven dashboard (Deposito with a QR code of the address via `GET /qr/{address}`, Bet, Prelievo) with a persistent round-status card (`GET /rounds/current`: id/status/timer/participant count/jackpot) above the menu.
|
||||
- **`/admin` (`app/static/admin.html`)**: gated by a token screen (not a real login — just checks `X-Admin-Token` against `ADMIN_TOKEN` from `.env`), then a navbar-driven dashboard with five sections, each backed by its own `/admin/*` endpoint (`app/api/routes/admin.py`): Parametri (`RoundConfig` CRUD), Utenti (list + per-user WIF privkey export, audit-logged), Round (history), Transazioni pendenti (in-flight RBF candidates), Audit log. **`/admin` is deliberately not linked from `/`** in either direction — reachable only by knowing the URL.
|
||||
- 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`.
|
||||
|
||||
Both pages talk to the same JSON API everything else uses; there's no separate "admin API" vs "user API" boundary beyond the `require_admin` dependency.
|
||||
**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.
|
||||
- The admin panel can export any user's raw WIF private key (`GET /admin/users/{id}/privkey`, `app/wallet/hd.py:derive_user_wif`). This is intentional, not a vulnerability to fix: the server already holds the master key everything derives from (custodial by design, see above), so this only exposes through the API something an operator could already do via a script. Every access is written to `audit_log` (`admin_privkey_accessed`) — don't remove that logging when touching this endpoint.
|
||||
- RBF fee bumps are paid by whoever's change output the tx pays back to — the user for bets/withdrawals, the pool for payouts — never by the fixed counterparty amount (recipient/winner/fee-address outputs are untouched; only the sender's own change shrinks). See `bump_fee` in `app/tx/broadcast.py`.
|
||||
- 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.
|
||||
- **Usernames are case-insensitive** (B-57): one namespace, enforced by a unique index on `lower(username)` (`app/db/models.py`) and matched with `func.lower(...)` on both register and login. The name is still *stored* as typed — that's what `/admin` and the audit log show. The migration refuses to run if two existing accounts differ only by case, rather than guessing which one to rename: both may hold funds.
|
||||
- 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
|
||||
|
||||
Not blockers for reading the code, but must be addressed before this is production-ready:
|
||||
Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here.
|
||||
|
||||
- **Scheduler doesn't resume mid-flight rounds after a restart.** `rounds/scheduler.py`'s `_tick()` only acts on rounds with `status == "open"`. If the process restarts while a round is `closing`/`drawing`/`paying_out`, it's permanently stuck — nothing re-enters `_wait_for_next_block` or retries `_trigger_payout`. Needs a startup routine that inspects in-progress rounds and resumes (or a periodic "unstick" check) before this can run unattended.
|
||||
- **RBF bump only handles one case**: a single change output, paying back to the tx's own sender address, large enough to absorb the fee increase. No additional-input selection fallback — an exact-amount tx (no change) or a change output too small to absorb the bump raises `RbfError` and needs manual operator intervention. Documented in `tx/broadcast.py`.
|
||||
- **Payout retry**: if `_trigger_payout` fails (e.g. insufficient pool UTXOs, Electrum disconnected), it just logs and returns — the round stays stuck in `paying_out` with no automatic retry.
|
||||
- **Withdrawal and RBF bump have never been exercised against a live broadcast** — only deposit and bet flow are verified end-to-end with real PLM as of this commit.
|
||||
- **No general user-facing history endpoints** (list my own bets / withdrawals / past rounds) — `GET /users/me/last-round-result` covers exactly one case (the outcome of the most recent *closed* round the user played in, as a reveal-persistence backstop; see DRAW above), not a real history. The admin side has more (`/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`), but there's still no "my own full history" equivalent for a logged-in user.
|
||||
- **Admin auth is a single shared bearer token** (`ADMIN_TOKEN`, `X-Admin-Token` header) — no per-admin identity or audit trail of *who* changed config (the `audit_log` table records *what* changed, not which operator did it). This token now gates a lot more than config (user list, private key export, round/audit history), so its blast radius if leaked is correspondingly larger.
|
||||
- **No rate limiting / abuse protection** on any endpoint (register, bet, withdrawal, admin).
|
||||
- No automated integration tests against a live Electrum connection — all live-network verification so far has been manual (ad hoc scripts + real mainnet transactions), not part of the `pytest` suite.
|
||||
- **`docker-compose.yml`'s `restart: unless-stopped`** on the app container means a crash mid-round auto-restarts straight into the scheduler-resume gap above — see the Deployment section.
|
||||
- **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_wait_for_next_block` after a crash. That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36). Restart-resumption itself remains the last prerequisite for running unattended.
|
||||
- **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why `build_signed_transaction` now guarantees a change output of at least `DUST_LIMIT_SATS` instead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused with `balance_leaves_no_change`. What's left is a bump whose *delta* exceeds an otherwise-fine change output, which still raises `RbfError`; that tx is eventually abandoned and its UTXOs released.
|
||||
- **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network).
|
||||
- **No user-facing history of rounds or transactions.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above) and `GET /bug-reports/mine` one more (the reporter's own reports). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
|
||||
- **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
|
||||
- **No rate limiting on bet, withdrawal, admin or SSE.** Login has a per-username + per-IP failure throttle and registration a per-IP quota of 5 accounts/hour (`app/auth/rate_limit.py`: `RateLimiter` for failed guesses at a secret, `RollingQuota` for "how many of these may one source create" — B-33, B-58); everything else is unlimited.
|
||||
- **`/guida` is a placeholder** (`app/static/guida.html`) — the link works, the content is "coming soon". `/report-bug` is *not*: it is fully implemented and translated, with admin triage (see Frontends).
|
||||
- **No integration tests against a live Electrum connection.** `tests/integration/` is empty; live verification has all been manual (`scripts/electrum_smoke_test.py`, ad hoc scripts, real mainnet txs).
|
||||
- **Single-process assumptions**: the SSE broadcaster and the per-user locks are in-process only. A multi-worker deployment needs a shared channel and a DB/Redis lock. The round-uniqueness invariant is *not* in this category — it's a DB index.
|
||||
|
||||
@@ -13,5 +13,31 @@
|
||||
not path /rounds/stream
|
||||
}
|
||||
encode @not_sse gzip
|
||||
reverse_proxy app:8123
|
||||
|
||||
# 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'"
|
||||
}
|
||||
|
||||
# B-54: Caddy *appends* the real peer address to whatever X-Forwarded-For the
|
||||
# client sent, so without this the header arrives as "<whatever the client
|
||||
# claimed>, <real ip>" and every IP-keyed control in the app (the login and
|
||||
# registration throttles of B-33, the SSE per-IP subscriber cap of B-38) is
|
||||
# defeated by simply rotating a fake value per request. Overwriting the header
|
||||
# with the actual peer makes the app's assumption true at the source; it also
|
||||
# reads the last hop rather than the first (app/api/client_ip.py), so the two
|
||||
# defences hold independently.
|
||||
reverse_proxy app:8123 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,38 +13,37 @@ 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
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
PYTHONPATH=. python scripts/generate_master_key.py
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --reload --port 8123
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8123/` for the test UI, `http://127.0.0.1:8123/admin`
|
||||
for the admin dashboard, `http://127.0.0.1:8123/docs` for the interactive API
|
||||
docs.
|
||||
|
||||
Or run the whole stack (app + Caddy reverse proxy with automatic TLS) via
|
||||
Docker:
|
||||
|
||||
```bash
|
||||
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 (both workflows, dev vs. production TLS).
|
||||
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
|
||||
- [flowchart/](flowchart/) — the source-of-truth flow diagrams the implementation follows node-by-node: [platform-overview.mmd](flowchart/platform-overview.mmd) (the 5-phase flow) and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) (the round/draw lifecycle)
|
||||
- [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/running-the-server.md](docs/running-the-server.md) — how to launch it with 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)
|
||||
|
||||
@@ -52,7 +51,7 @@ walkthrough (both workflows, dev vs. production TLS).
|
||||
|
||||
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
|
||||
deployment. See [CLAUDE.md](CLAUDE.md#tech-stack) for the complete list
|
||||
and the reasoning behind each choice.
|
||||
|
||||
## Testing
|
||||
@@ -62,7 +61,7 @@ python -m pytest # all tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
```
|
||||
|
||||
76 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
351 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
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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".
|
||||
|
||||
B-54: the *last* element, not the first. A proxy appends the address it saw
|
||||
to any X-Forwarded-For the client already sent, so the first element is
|
||||
attacker-controlled — with the header read from the front, rotating a fake
|
||||
value per request gave every request a fresh identity and turned all three
|
||||
IP-keyed controls above into decoration. The last element is the one written
|
||||
by the hop closest to us, i.e. by our own proxy. Exactly one trusted proxy
|
||||
sits in front of this app (Caddy, see docker-compose.yml, where `app` is
|
||||
only `expose`d on the compose network and never published to the host), so
|
||||
the last element is the real peer. The Caddyfile now also overwrites the
|
||||
header with `header_up X-Forwarded-For {remote_host}`, which collapses it to
|
||||
a single value — belt and braces: either fix alone closes B-54.
|
||||
"""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
hops = [hop.strip() for hop in forwarded.split(",") if hop.strip()]
|
||||
if hops:
|
||||
return hops[-1]
|
||||
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())
|
||||
+168
-26
@@ -1,27 +1,41 @@
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
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.auth.security import hash_password_async
|
||||
from app.config import settings
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, User
|
||||
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:
|
||||
if not settings.admin_token or x_admin_token != settings.admin_token:
|
||||
# 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",
|
||||
@@ -30,7 +44,6 @@ _CONFIG_FIELDS = (
|
||||
"fee_rate_sat_vb",
|
||||
"rbf_timeout_seconds",
|
||||
"draw_animation_seconds",
|
||||
"paused",
|
||||
)
|
||||
|
||||
|
||||
@@ -46,18 +59,41 @@ class RoundConfigResponse(BaseModel):
|
||||
|
||||
|
||||
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 = None
|
||||
round_duration_seconds: int | None = None
|
||||
round_cooldown_seconds: int | None = None
|
||||
fee_rate_sat_vb: int | None = None
|
||||
rbf_timeout_seconds: int | None = None
|
||||
draw_animation_seconds: int | None = None
|
||||
paused: bool | 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:
|
||||
return RoundConfigResponse(**{field: getattr(config, field) for field in _CONFIG_FIELDS})
|
||||
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)])
|
||||
@@ -72,10 +108,22 @@ 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 not None:
|
||||
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)
|
||||
|
||||
@@ -118,7 +166,7 @@ async def list_users(session: AsyncSession = Depends(get_session)) -> list[Admin
|
||||
username=u.username,
|
||||
address=u.address,
|
||||
balance_sats=u.cached_balance_sats,
|
||||
created_at=u.created_at.isoformat(),
|
||||
created_at=isoformat_utc(u.created_at),
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
@@ -169,7 +217,12 @@ async def reset_user_password(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
|
||||
new_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.password_hash = await hash_password_async(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)
|
||||
@@ -191,7 +244,9 @@ class AdminRoundResponse(BaseModel):
|
||||
|
||||
|
||||
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
|
||||
async def list_rounds(session: AsyncSession = Depends(get_session), limit: int = 50) -> list[AdminRoundResponse]:
|
||||
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 = {}
|
||||
@@ -203,8 +258,8 @@ async def list_rounds(session: AsyncSession = Depends(get_session), limit: int =
|
||||
AdminRoundResponse(
|
||||
id=r.id,
|
||||
status=r.status,
|
||||
opened_at=r.opened_at.isoformat(),
|
||||
closed_at=r.closed_at.isoformat() if r.closed_at else None,
|
||||
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,
|
||||
@@ -231,7 +286,7 @@ class AdminAuditLogResponse(BaseModel):
|
||||
"/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)]
|
||||
)
|
||||
async def list_audit_log(
|
||||
session: AsyncSession = Depends(get_session), limit: int = 200
|
||||
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 [
|
||||
@@ -241,7 +296,7 @@ async def list_audit_log(
|
||||
payload=json.loads(e.payload_json),
|
||||
user_id=e.user_id,
|
||||
round_id=e.round_id,
|
||||
created_at=e.created_at.isoformat(),
|
||||
created_at=isoformat_utc(e.created_at),
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
@@ -268,10 +323,13 @@ class AdminPendingTransactionResponse(BaseModel):
|
||||
)
|
||||
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]:
|
||||
entries = (
|
||||
await session.scalars(select(PendingTransaction).order_by(PendingTransaction.id.desc()))
|
||||
).all()
|
||||
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,
|
||||
@@ -283,8 +341,92 @@ async def list_pending_transactions(
|
||||
current_txid=p.current_txid,
|
||||
fee_rate_sat_vb=p.fee_rate_sat_vb,
|
||||
attempt_count=p.attempt_count,
|
||||
broadcast_at=p.broadcast_at.isoformat(),
|
||||
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")
|
||||
|
||||
# B-60: every other admin mutation (config edit, pause/resume, privkey export,
|
||||
# password reset) leaves a trace; this one silently marked a report `resolved`.
|
||||
# With one shared ADMIN_TOKEN and no per-admin identity, the audit log is the
|
||||
# only accountability there is. Before/after like config_updated, and nothing
|
||||
# written when the status doesn't actually change — re-clicking the status a
|
||||
# report already has isn't an event.
|
||||
if report.status != body.status:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"bug_report_status_changed",
|
||||
{"report_id": report_id, "from": report.status, "to": body.status},
|
||||
user_id=report.user_id,
|
||||
)
|
||||
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)
|
||||
|
||||
+15
-3
@@ -1,7 +1,8 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
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
|
||||
@@ -25,13 +26,24 @@ async def create_bet(
|
||||
) -> BetResponse:
|
||||
listener = request.app.state.electrum_listener
|
||||
if listener.client is None:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "not connected to the network, try again shortly")
|
||||
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:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from 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,
|
||||
|
||||
@@ -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
|
||||
]
|
||||
+46
-10
@@ -1,22 +1,58 @@
|
||||
"""PNG QR codes for PLM addresses.
|
||||
|
||||
Deliberately unauthenticated: the dashboard renders it with a plain `<img>`
|
||||
tag, which cannot carry the bearer token, and the payload is an address the
|
||||
caller already has. What the endpoint must not be is a free CPU amplifier
|
||||
(B-67), so two things bound the work an anonymous caller can ask for:
|
||||
|
||||
- the address is validated for real (bech32 checksum + PLM HRP) via
|
||||
`is_valid_plm_address`, the same check withdrawals and the admin
|
||||
`fee_address` validator use, instead of a shape-only regex that happily
|
||||
rendered a QR for any `plm1`-prefixed junk string;
|
||||
- the render itself is memoized per address and pushed off the event loop, so
|
||||
a repeat request costs a dict lookup and a first one never blocks the
|
||||
scheduler, the listener or any other request.
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
import qrcode
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi import APIRouter
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.api.errors import http_error
|
||||
from app.wallet.address import is_valid_plm_address
|
||||
|
||||
router = APIRouter(tags=["qr"])
|
||||
|
||||
# PLM P2WPKH addresses: bech32 HRP "plm" + separator + witness program.
|
||||
_ADDRESS_RE = re.compile(r"^plm1[a-z0-9]{10,90}$")
|
||||
# Long enough for any bech32 address, short enough that a multi-kilobyte path
|
||||
# is rejected before embit ever looks at it.
|
||||
_MAX_ADDRESS_LENGTH = 100
|
||||
|
||||
# Bounded on purpose: valid addresses are cheap to generate, so an unbounded
|
||||
# cache would just move the amplification from CPU to memory.
|
||||
_CACHE_SIZE = 512
|
||||
|
||||
|
||||
@lru_cache(maxsize=_CACHE_SIZE)
|
||||
def _render_png(address: str) -> bytes:
|
||||
image = qrcode.make(address)
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@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")
|
||||
if len(address) > _MAX_ADDRESS_LENGTH or not is_valid_plm_address(address):
|
||||
raise http_error(400, "invalid_address", "not a valid PLM bech32 address")
|
||||
|
||||
image = qrcode.make(address)
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return Response(content=buf.getvalue(), media_type="image/png")
|
||||
png = await run_in_threadpool(_render_png, address)
|
||||
# An address' QR never changes; let the browser stop asking for it.
|
||||
return Response(
|
||||
content=png,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "private, max-age=86400, immutable"},
|
||||
)
|
||||
|
||||
+76
-13
@@ -8,12 +8,14 @@ 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 RoundEventCapacityError, broadcaster
|
||||
from app.rounds.service import get_active_round
|
||||
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
|
||||
from app.rounds.service import get_active_round, round_deadline, rounds_can_open, winner_share
|
||||
|
||||
router = APIRouter(prefix="/rounds", tags=["rounds"])
|
||||
|
||||
@@ -45,9 +47,14 @@ async def round_stream(request: Request) -> Response:
|
||||
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()
|
||||
queue = broadcaster.subscribe(client_ip(request))
|
||||
except RoundEventCapacityError:
|
||||
return JSONResponse(status_code=503, content={"detail": "too many concurrent update streams"})
|
||||
|
||||
@@ -58,7 +65,9 @@ async def round_stream(request: Request) -> Response:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
await asyncio.wait_for(queue.get(), timeout=_SSE_DISCONNECT_CHECK_SECONDS)
|
||||
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:
|
||||
@@ -82,16 +91,38 @@ class CurrentRoundResponse(BaseModel):
|
||||
status: str | None = None
|
||||
opened_at: str | None = None
|
||||
closes_at: str | None = None
|
||||
# B-65: confirmed participants only — the ones the draw actually picks from and
|
||||
# whose sats are actually in the pool. The pending_* pair below is the same
|
||||
# confirmed/in-flight split the balance already exposes (see
|
||||
# wallet/balance.py's balance_sats vs pending_balance_sats), and for the same
|
||||
# reason: the authoritative number must be the one that will be paid, while the
|
||||
# player who just bet still needs to see their own bet somewhere.
|
||||
participant_count: int = 0
|
||||
bet_amount_sats: int
|
||||
jackpot_sats: int = 0
|
||||
# Inclusive of bets still building/broadcast, exactly like pending_balance_sats
|
||||
# is inclusive of unconfirmed change — not deltas. Equal to the confirmed
|
||||
# figures above when nothing is in flight, which is what has_pending_bets says.
|
||||
pending_participant_count: int = 0
|
||||
pending_jackpot_sats: int = 0
|
||||
has_pending_bets: bool = False
|
||||
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
|
||||
# B-66: false while the instance is missing configuration a round cannot run
|
||||
# without (today: fee_address) — no round will open until it's set, so this is
|
||||
# the difference between "wait, the next round is coming" and "nothing is coming
|
||||
# until the operator finishes setting this up". Distinct from lottery_paused,
|
||||
# which is a deliberate operator action rather than an unmet prerequisite.
|
||||
lottery_configured: bool = True
|
||||
user_played: bool = False
|
||||
|
||||
|
||||
@@ -113,13 +144,39 @@ async def current_round(
|
||||
draw_animation_seconds=config.draw_animation_seconds,
|
||||
chain_tip_height=chain_tip_height,
|
||||
lottery_paused=config.paused,
|
||||
lottery_configured=rounds_can_open(config),
|
||||
)
|
||||
|
||||
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).
|
||||
#
|
||||
# Split confirmed from in-flight (B-65): the draw only picks from confirmed
|
||||
# participants and the payout only spends their sats, so counting every row
|
||||
# advertised a jackpot larger than the one that would be paid, and made a
|
||||
# participant appear and then vanish again if their bet was later abandoned.
|
||||
counts = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(),
|
||||
func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0),
|
||||
func.count().filter(RoundParticipant.status == "confirmed"),
|
||||
func.coalesce(
|
||||
func.sum(RoundParticipant.bet_amount_sats).filter(
|
||||
RoundParticipant.status == "confirmed"
|
||||
),
|
||||
0,
|
||||
),
|
||||
).where(RoundParticipant.round_id == round_.id)
|
||||
)
|
||||
).one()
|
||||
all_count, all_pool_sats, participant_count, pool_amount_sats = counts
|
||||
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
|
||||
closes_at = opened_at + timedelta(seconds=config.round_duration_seconds)
|
||||
# B-61: from the round's own duration — the countdown clients are watching must
|
||||
# not jump because an operator edited the config mid-round.
|
||||
closes_at = round_deadline(round_)
|
||||
|
||||
# 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)
|
||||
@@ -137,11 +194,12 @@ async def current_round(
|
||||
|
||||
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 —
|
||||
# what's displayed should match what the winner actually receives.
|
||||
pool_amount_sats = participant_count * config.bet_amount_sats
|
||||
jackpot_sats = pool_amount_sats * 70 // 100
|
||||
# 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(),
|
||||
@@ -152,12 +210,17 @@ async def current_round(
|
||||
participant_count=participant_count,
|
||||
bet_amount_sats=config.bet_amount_sats,
|
||||
jackpot_sats=jackpot_sats,
|
||||
pending_participant_count=all_count,
|
||||
pending_jackpot_sats=winner_share(all_pool_sats),
|
||||
has_pending_bets=all_count > participant_count,
|
||||
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,
|
||||
lottery_configured=rounds_can_open(config),
|
||||
user_played=user_played,
|
||||
)
|
||||
|
||||
+36
-12
@@ -1,18 +1,23 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 hash_password, verify_password
|
||||
from app.auth.security import (
|
||||
MIN_PASSWORD_LENGTH,
|
||||
create_access_token,
|
||||
hash_password_async,
|
||||
verify_password_async,
|
||||
)
|
||||
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"])
|
||||
|
||||
_MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
id: int
|
||||
@@ -37,7 +42,7 @@ async def me(
|
||||
balance_sats=user.cached_balance_sats,
|
||||
pending_balance_sats=pending_balance_sats,
|
||||
has_pending=has_pending,
|
||||
created_at=user.created_at.isoformat(),
|
||||
created_at=isoformat_utc(user.created_at),
|
||||
)
|
||||
|
||||
|
||||
@@ -46,22 +51,41 @@ class ChangePasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/me/change-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||
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),
|
||||
) -> None:
|
||||
) -> 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 HTTPException(status.HTTP_401_UNAUTHORIZED, "current password is incorrect")
|
||||
if len(body.new_password) < _MIN_PASSWORD_LENGTH:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"new password must be at least {_MIN_PASSWORD_LENGTH} characters")
|
||||
if not await verify_password_async(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)
|
||||
user.password_hash = await hash_password_async(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):
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
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
|
||||
@@ -31,7 +32,11 @@ async def create_withdrawal(
|
||||
) -> WithdrawalResponse:
|
||||
listener = request.app.state.electrum_listener
|
||||
if listener.client is None:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "not connected to the network, try again shortly")
|
||||
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:
|
||||
@@ -39,7 +44,12 @@ async def create_withdrawal(
|
||||
session, listener.client, user, body.external_address, body.amount_sats
|
||||
)
|
||||
except WithdrawalError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from 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,
|
||||
|
||||
@@ -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()
|
||||
@@ -1,8 +1,9 @@
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
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
|
||||
@@ -15,13 +16,19 @@ async def get_current_user(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User:
|
||||
try:
|
||||
user_id = decode_access_token(credentials.credentials)
|
||||
user_id, token_version = decode_access_token(credentials.credentials)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token") from 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 HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
|
||||
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
|
||||
|
||||
|
||||
@@ -36,7 +43,10 @@ async def get_optional_user(
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
try:
|
||||
user_id = decode_access_token(auth_header.removeprefix("Bearer "))
|
||||
user_id, token_version = decode_access_token(auth_header.removeprefix("Bearer "))
|
||||
except Exception:
|
||||
return None
|
||||
return await session.scalar(select(User).where(User.id == user_id))
|
||||
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,210 @@
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
# B-56: bounds on _buckets, which is keyed by strings the caller chooses.
|
||||
# 50k entries is a few MB at ~100 bytes each — far more than any real deployment's
|
||||
# active attacker set, and small enough that filling it isn't a memory attack.
|
||||
_MAX_BUCKETS = 50_000
|
||||
# How often record_failure sweeps out spent entries. Cheap (one pass over a dict
|
||||
# that the sweep itself keeps small) and off the request's critical path in the
|
||||
# normal case, since a successful login records no failure at all.
|
||||
_SWEEP_INTERVAL_SECONDS = 60.0
|
||||
|
||||
|
||||
@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,
|
||||
max_buckets: int = _MAX_BUCKETS,
|
||||
sweep_interval_seconds: float = _SWEEP_INTERVAL_SECONDS,
|
||||
) -> None:
|
||||
self._threshold = threshold
|
||||
self._base_delay = base_delay
|
||||
self._max_delay = max_delay
|
||||
self._decay_seconds = decay_seconds
|
||||
self._max_buckets = max_buckets
|
||||
self._sweep_interval_seconds = sweep_interval_seconds
|
||||
self._buckets: dict[str, _Bucket] = {}
|
||||
self._last_sweep_at = time.monotonic()
|
||||
|
||||
def _is_spent(self, bucket: _Bucket, now: float) -> bool:
|
||||
"""Nothing left to remember: the lockout has expired *and* the failure count
|
||||
would decay to zero on the next failure anyway. Dropping such a bucket is
|
||||
indistinguishable from keeping it — which is what makes eviction safe."""
|
||||
return bucket.locked_until <= now and now - bucket.last_failure_at > self._decay_seconds
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
"""B-56: the dict was keyed by attacker-chosen strings (any username, and via
|
||||
B-54 any IP) and only ever grew — `decay_seconds` aged a bucket's counter but
|
||||
never removed the entry, so hammering login with random usernames was an
|
||||
unbounded memory leak.
|
||||
|
||||
Spent buckets go first, and they carry no information, so that alone keeps
|
||||
the dict at the size of the genuinely active attack surface. The hard cap
|
||||
below is the backstop for a burst faster than the sweep interval: it evicts
|
||||
the entries closest to expiry, i.e. the ones whose loss buys an attacker the
|
||||
least — never the freshest lockouts, which are the ones actually holding an
|
||||
attack back."""
|
||||
for key in [k for k, b in self._buckets.items() if self._is_spent(b, now)]:
|
||||
del self._buckets[key]
|
||||
self._last_sweep_at = now
|
||||
|
||||
excess = len(self._buckets) - self._max_buckets
|
||||
if excess > 0:
|
||||
by_expiry = sorted(
|
||||
self._buckets.items(), key=lambda item: (item[1].locked_until, item[1].last_failure_at)
|
||||
)
|
||||
for key, _ in by_expiry[:excess]:
|
||||
del self._buckets[key]
|
||||
|
||||
def retry_after(self, key: str) -> float:
|
||||
bucket = self._buckets.get(key)
|
||||
if bucket is None:
|
||||
return 0.0
|
||||
now = time.monotonic()
|
||||
if self._is_spent(bucket, now):
|
||||
# Self-cleaning read path: a key that's merely being probed never
|
||||
# accumulates an entry that outlives its own usefulness.
|
||||
del self._buckets[key]
|
||||
return 0.0
|
||||
remaining = bucket.locked_until - now
|
||||
return remaining if remaining > 0 else 0.0
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
if now - self._last_sweep_at >= self._sweep_interval_seconds or len(self._buckets) > self._max_buckets:
|
||||
self._prune(now)
|
||||
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 RollingQuota:
|
||||
"""How many times a key may do something in a rolling window — as opposed to
|
||||
RateLimiter above, which punishes *failures* with a growing delay.
|
||||
|
||||
B-58: registration was throttled with the failure limiter, and recorded a
|
||||
"failure" on every attempt, successful ones included. Five legitimate signups
|
||||
from one shared or NAT address locked the sixth real user out for up to 600s,
|
||||
with the backoff doubling from there — while an attacker sidestepped the whole
|
||||
thing through B-54. The intent (bound how many accounts one source can create)
|
||||
is right; failure backoff is the wrong instrument for it, since nothing here is
|
||||
a failed guess at a secret. A quota says exactly what is meant: this many
|
||||
accounts per source per window, and the answer to the one over it is "not yet",
|
||||
with an accurate wait rather than a punishment that grows.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
limit: int,
|
||||
window_seconds: float,
|
||||
max_keys: int = _MAX_BUCKETS,
|
||||
sweep_interval_seconds: float = _SWEEP_INTERVAL_SECONDS,
|
||||
) -> None:
|
||||
self._limit = limit
|
||||
self._window_seconds = window_seconds
|
||||
self._max_keys = max_keys
|
||||
self._sweep_interval_seconds = sweep_interval_seconds
|
||||
self._events: dict[str, list[float]] = {}
|
||||
self._last_sweep_at = time.monotonic()
|
||||
|
||||
def _live_events(self, key: str, now: float) -> list[float]:
|
||||
"""The key's events still inside the window, pruned in place."""
|
||||
events = self._events.get(key)
|
||||
if events is None:
|
||||
return []
|
||||
cutoff = now - self._window_seconds
|
||||
while events and events[0] <= cutoff:
|
||||
events.pop(0)
|
||||
if not events:
|
||||
del self._events[key]
|
||||
return events
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
# Same bound as RateLimiter (B-56): the keys are caller-chosen, so the dict
|
||||
# needs both a sweep and a hard cap. Eviction order is likewise "closest to
|
||||
# leaving the window first" — dropping a key with room left in its quota
|
||||
# changes nothing, dropping a full one hands out free accounts.
|
||||
for key in list(self._events):
|
||||
self._live_events(key, now)
|
||||
self._last_sweep_at = now
|
||||
|
||||
excess = len(self._events) - self._max_keys
|
||||
if excess > 0:
|
||||
by_oldest = sorted(self._events.items(), key=lambda item: item[1][-1])
|
||||
for key, _ in by_oldest[:excess]:
|
||||
del self._events[key]
|
||||
|
||||
def retry_after(self, key: str) -> float:
|
||||
"""Seconds until this key may act again — 0 while it is under quota."""
|
||||
now = time.monotonic()
|
||||
events = self._live_events(key, now)
|
||||
if len(events) < self._limit:
|
||||
return 0.0
|
||||
return events[0] + self._window_seconds - now
|
||||
|
||||
def record(self, key: str) -> None:
|
||||
"""Counts one *completed* action. Attempts that create nothing (a taken
|
||||
username, a validation error) deliberately don't consume the quota — the
|
||||
limit is on accounts that exist, not on requests."""
|
||||
now = time.monotonic()
|
||||
if now - self._last_sweep_at >= self._sweep_interval_seconds or len(self._events) > self._max_keys:
|
||||
self._prune(now)
|
||||
self._events.setdefault(key, []).append(now)
|
||||
|
||||
|
||||
_REGISTRATIONS_PER_IP = 5
|
||||
_REGISTRATION_WINDOW_SECONDS = 3600.0
|
||||
|
||||
|
||||
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)
|
||||
# B-58: a quota, not failure backoff — 5 accounts per IP per hour. The one
|
||||
# over it waits only until the oldest of the five ages out, and a busy NAT
|
||||
# is slowed rather than locked out for progressively longer.
|
||||
self.register_ip = RollingQuota(
|
||||
limit=_REGISTRATIONS_PER_IP, window_seconds=_REGISTRATION_WINDOW_SECONDS
|
||||
)
|
||||
+106
-17
@@ -1,10 +1,18 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
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.auth.security import create_access_token, hash_password, verify_password
|
||||
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_async,
|
||||
verify_password_async,
|
||||
)
|
||||
from app.db.models import User
|
||||
from app.db.session import get_session
|
||||
from app.wallet.hd import derive_user_address
|
||||
@@ -14,9 +22,42 @@ 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
|
||||
# instrument entirely: a per-IP *quota* on accounts created (B-58), since
|
||||
# bounding how many accounts one source can spin up is not the same problem
|
||||
# as slowing down guesses at a secret, and failure backoff only punished the
|
||||
# honest signups. 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):
|
||||
username: str
|
||||
password: str
|
||||
"""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):
|
||||
@@ -28,11 +69,24 @@ class TokenResponse(BaseModel):
|
||||
async def register(
|
||||
body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session)
|
||||
) -> TokenResponse:
|
||||
existing = await session.scalar(select(User).where(User.username == body.username))
|
||||
if existing is not None:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "username already taken")
|
||||
limiters = _rate_limiters(request)
|
||||
ip_key = f"ip:{_client_ip(request)}"
|
||||
# B-58: checked before the Argon2 hash below, so an IP that's out of quota
|
||||
# costs nothing to turn away. Recorded only once an account actually exists —
|
||||
# see the successful path below.
|
||||
retry_after = limiters.register_ip.retry_after(ip_key)
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
|
||||
password_hash = hash_password(body.password)
|
||||
# B-57: case-insensitive, matching the unique index on lower(username) — and
|
||||
# matching the throttle key below, which has always been lowercased.
|
||||
existing = await session.scalar(
|
||||
select(User).where(func.lower(User.username) == body.username.lower())
|
||||
)
|
||||
if existing is not None:
|
||||
raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken")
|
||||
|
||||
password_hash = await hash_password_async(body.password)
|
||||
|
||||
for _ in range(_MAX_REGISTER_RETRIES):
|
||||
max_index = await session.scalar(select(func.max(User.derivation_index)))
|
||||
@@ -47,14 +101,29 @@ async def register(
|
||||
session.add(user)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
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)
|
||||
limiters.register_ip.record(ip_key) # B-58: one account created, one slot used
|
||||
request.app.state.electrum_listener.address_for_new_user(user.id, user.address)
|
||||
return TokenResponse(access_token=create_access_token(user.id), address=user.address)
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.token_version), address=user.address
|
||||
)
|
||||
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "could not allocate a derivation index, retry")
|
||||
raise http_error(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"derivation_index_conflict",
|
||||
"could not allocate a derivation index, retry",
|
||||
)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -63,8 +132,28 @@ class LoginRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)) -> TokenResponse:
|
||||
user = await session.scalar(select(User).where(User.username == body.username))
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
|
||||
return TokenResponse(access_token=create_access_token(user.id), address=user.address)
|
||||
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(func.lower(User.username) == body.username.lower()))
|
||||
if user is None or not await verify_password_async(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
|
||||
)
|
||||
|
||||
+56
-6
@@ -1,11 +1,20 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import logging
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from argon2.exceptions import InvalidHashError, VerificationError
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -14,18 +23,59 @@ def hash_password(password: str) -> str:
|
||||
|
||||
|
||||
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 VerifyMismatchError:
|
||||
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) -> str:
|
||||
# --- B-55: the two Argon2 calls above must never run on the event loop ----------
|
||||
# Argon2 is deliberately expensive — tens of milliseconds of CPU per call, by
|
||||
# design. Called straight from an async handler that stalls the *whole* process
|
||||
# for that long: every other request, and all six background tasks (scheduler,
|
||||
# confirmation poller, RBF bumper, listener, both reconcilers). A burst of
|
||||
# unauthenticated login attempts was therefore a cheap way to delay draws and
|
||||
# confirmations, not just to slow down logins. The threadpool keeps the cost
|
||||
# where it belongs — on a worker thread, with the loop free to run everything
|
||||
# else meanwhile.
|
||||
#
|
||||
# The synchronous functions stay: they're what the wrappers call, and what tests
|
||||
# and scripts (no running loop) use directly. Every async caller must use these.
|
||||
|
||||
|
||||
async def hash_password_async(password: str) -> str:
|
||||
return await run_in_threadpool(hash_password, password)
|
||||
|
||||
|
||||
async def verify_password_async(password: str, password_hash: str) -> bool:
|
||||
return await run_in_threadpool(verify_password, password, password_hash)
|
||||
|
||||
|
||||
def create_access_token(user_id: int, token_version: int = 0) -> str:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
payload = {"sub": str(user_id), "exp": expires_at}
|
||||
# "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) -> int:
|
||||
def decode_access_token(token: str) -> tuple[int, int]:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
return int(payload["sub"])
|
||||
# .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))
|
||||
|
||||
@@ -8,10 +8,24 @@ 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 == "broadcast":
|
||||
if participant is not None and participant.status in ("building", "broadcast"):
|
||||
participant.status = "confirmed"
|
||||
participant.confirmed_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
+143
-17
@@ -1,32 +1,45 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from embit import script
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import OperationalError
|
||||
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.db.models import PendingTransaction, Round, 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
|
||||
from app.wallet.psbt_builder import (
|
||||
MAX_PARTICIPANTS_PER_ROUND,
|
||||
BuiltTransaction,
|
||||
InsufficientFundsError,
|
||||
Utxo,
|
||||
build_signed_transaction,
|
||||
)
|
||||
|
||||
|
||||
class BetError(Exception):
|
||||
class BetError(ApiError):
|
||||
pass
|
||||
|
||||
|
||||
class _RoundClosedDuringBuild(Exception):
|
||||
"""Internal signal (B-53): the round stopped accepting bets while this one was
|
||||
being built. Never leaves place_bet — it becomes a `round_closing` BetError."""
|
||||
|
||||
|
||||
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 right now, please try again shortly")
|
||||
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("the current round is closing, please try again shortly")
|
||||
if not round_accepts_bets(round_):
|
||||
raise BetError("round_closing", "the current round is closing, please try again shortly")
|
||||
|
||||
already_playing = await session.scalar(
|
||||
select(RoundParticipant).where(
|
||||
@@ -34,7 +47,27 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
)
|
||||
)
|
||||
if already_playing is not None:
|
||||
raise BetError("you already have an active bet in the current round")
|
||||
raise BetError("already_betting", "you already have an active bet in the current round")
|
||||
|
||||
# B-52: "this round can always be paid out" is an invariant, and this is where it
|
||||
# gets enforced — before any of this user's money moves. The payout has to spend
|
||||
# one pool UTXO per bet, so a round that grew past what a single payout
|
||||
# transaction may spend was unpayable: it stayed "paying_out" retrying forever,
|
||||
# and because no new round may open while one is active, the whole lottery
|
||||
# stopped. Refusing the bet costs the player one round of waiting; accepting it
|
||||
# cost everyone the platform. Counted over every participant row, not just the
|
||||
# confirmed ones: a bet that later fails frees a slot, so counting them all is
|
||||
# the conservative direction.
|
||||
participant_count = await session.scalar(
|
||||
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
|
||||
)
|
||||
if participant_count >= MAX_PARTICIPANTS_PER_ROUND:
|
||||
raise BetError(
|
||||
"round_full",
|
||||
f"this round already has its maximum of {MAX_PARTICIPANTS_PER_ROUND} players, "
|
||||
"wait for the next one",
|
||||
max_participants=MAX_PARTICIPANTS_PER_ROUND,
|
||||
)
|
||||
|
||||
bet_amount = config.bet_amount_sats
|
||||
|
||||
@@ -44,7 +77,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
)
|
||||
).all()
|
||||
if sum(u.amount_sats for u in unspent) < bet_amount:
|
||||
raise BetError("insufficient balance")
|
||||
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())
|
||||
@@ -61,14 +94,19 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
)
|
||||
except InsufficientFundsError as exc:
|
||||
raise BetError(str(exc)) from exc
|
||||
|
||||
await client.broadcast(built.raw_hex)
|
||||
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:
|
||||
row = spent_by_key[(spent.txid, spent.vout)]
|
||||
row.spent_txid = built.txid
|
||||
spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid
|
||||
await recompute_balance(session, user.id)
|
||||
|
||||
broadcast_at = datetime.now(timezone.utc)
|
||||
@@ -78,10 +116,60 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
bet_amount_sats=built.recipient_sats,
|
||||
bet_txid=built.txid,
|
||||
broadcast_at=broadcast_at,
|
||||
status="broadcast",
|
||||
status="building",
|
||||
)
|
||||
session.add(participant)
|
||||
session.add(_pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb))
|
||||
pending = _pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb)
|
||||
session.add(pending)
|
||||
|
||||
# B-53: the deadline check at the top of this function happened before the UTXO
|
||||
# scan and the signing above, so re-check it here against the clock as it is now —
|
||||
# a slow build must not sneak a bet past the round's deadline.
|
||||
#
|
||||
# And then the part the clock can't cover: a compare-and-set on the round's own
|
||||
# row, in the *same* transaction as the participant insert. The scheduler flips
|
||||
# "open" -> "closing" in a transaction of its own and only counts in-flight
|
||||
# participants afterwards, so without this a bet could commit its "building" row
|
||||
# in between and be paid into the pool while the round drew and paid out without
|
||||
# it — money credited to no round, no participant and no refund path. The UPDATE
|
||||
# takes SQLite's write lock, so the two transactions can no longer interleave:
|
||||
# either this commits first and the scheduler's subsequent in-flight count sees
|
||||
# the row, or the flip commits first and this matches zero rows and refuses the
|
||||
# bet before anything is broadcast.
|
||||
try:
|
||||
if not round_accepts_bets(round_):
|
||||
raise _RoundClosedDuringBuild
|
||||
guard = await session.execute(
|
||||
update(Round)
|
||||
.where(Round.id == round_.id, Round.status == "open")
|
||||
.values(status="open")
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if guard.rowcount != 1:
|
||||
raise _RoundClosedDuringBuild
|
||||
await session.commit()
|
||||
except (_RoundClosedDuringBuild, OperationalError) as exc:
|
||||
# OperationalError here is SQLite's write-snapshot conflict: the round row
|
||||
# changed under us, which is the same situation as the guard matching nothing.
|
||||
# Nothing has been broadcast yet, so the rollback undoes phase 1 entirely —
|
||||
# the UTXOs stay unspent and no participant row survives.
|
||||
await session.rollback()
|
||||
raise BetError(
|
||||
"round_closing", "the current round is closing, please try again shortly"
|
||||
) from exc
|
||||
|
||||
# --- 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",
|
||||
@@ -96,6 +184,40 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
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:
|
||||
@@ -106,5 +228,9 @@ def _pending_transaction(
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=fee_rate_sat_vb,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
status="pending",
|
||||
# "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",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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")
|
||||
@@ -9,6 +14,13 @@ class Settings(BaseSettings):
|
||||
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"
|
||||
@@ -17,6 +29,11 @@ class Settings(BaseSettings):
|
||||
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) —
|
||||
@@ -25,3 +42,40 @@ class Settings(BaseSettings):
|
||||
|
||||
|
||||
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))
|
||||
|
||||
+37
-1
@@ -1,9 +1,45 @@
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
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
|
||||
# six concurrent background tasks (Electrum listener, 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)
|
||||
|
||||
|
||||
|
||||
+95
-4
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import BigInteger, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy import BigInteger, ForeignKey, Index, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -13,6 +13,16 @@ def utcnow() -> datetime:
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
# B-57: usernames are compared case-insensitively, and that has to be the
|
||||
# database's job, not a convention the query layer remembers. "Bob" and "bob"
|
||||
# used to be two accounts sharing one rate-limit bucket (each locking the other
|
||||
# out) and, worse on a custodial system, a ready-made impersonation vector.
|
||||
# A functional unique index rather than a normalized column: the name stays
|
||||
# stored exactly as the user typed it, which is what /admin and the audit log
|
||||
# display. The username pattern (auth/routes.py) is ASCII-only, so lower() is
|
||||
# the whole of the normalization — no Unicode casefolding subtleties apply.
|
||||
__table_args__ = (Index("ix_users_username_lower", text("lower(username)"), unique=True),)
|
||||
|
||||
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))
|
||||
@@ -21,6 +31,12 @@ class User(Base):
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -39,13 +55,48 @@ class UtxoEvent(Base):
|
||||
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)
|
||||
# B-61: the round's own timing, snapshotted from RoundConfig when it opens.
|
||||
# Read live from the config, a mid-round edit applied retroactively: lowering
|
||||
# round_duration_seconds from 600 to 60 while a round was 300s in closed it
|
||||
# instantly, and raising it moved the closes_at every client was already
|
||||
# counting down to. Same class of bug B-11 fixed for the advertised jackpot.
|
||||
# The config row is now what the *next* round opens with; these are what this
|
||||
# round runs by. cooldown_seconds is read off the round that just closed, so
|
||||
# the gap it announced is the gap that's honoured.
|
||||
duration_seconds: Mapped[int] = mapped_column(default=600, server_default="600")
|
||||
cooldown_seconds: Mapped[int] = mapped_column(default=30, server_default="30")
|
||||
# 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)
|
||||
@@ -102,7 +153,17 @@ class RoundConfig(Base):
|
||||
|
||||
|
||||
class PendingTransaction(Base):
|
||||
"""Single source of truth for the RBF timeout->bump->rebroadcast loop."""
|
||||
"""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"
|
||||
|
||||
@@ -113,11 +174,23 @@ class PendingTransaction(Base):
|
||||
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(String)
|
||||
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)
|
||||
|
||||
|
||||
@@ -135,12 +208,30 @@ class Withdrawal(Base):
|
||||
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(String)
|
||||
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,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)
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -6,6 +8,34 @@ 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 find_new_credit_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
|
||||
"""The subset of `entries` that would actually credit something: confirmed
|
||||
(height > 0, per the Electrum convention where <= 0 means mempool) and not
|
||||
already recorded.
|
||||
|
||||
Split out from credit_confirmed_utxos so the caller
|
||||
(electrum/listener.py:refresh_user) can corroborate each *new* outpoint
|
||||
against the other configured servers before any of it is written (B-59) —
|
||||
the mirror image of what B-29 already required before a balance may go
|
||||
*down*. Only new ones: corroborating outpoints already credited would open a
|
||||
connection to every other server on every refresh, for an answer that can no
|
||||
longer change what we hold.
|
||||
"""
|
||||
existing_keys = {
|
||||
(txid, vout)
|
||||
for txid, vout in (
|
||||
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user_id))
|
||||
).all()
|
||||
}
|
||||
return [
|
||||
entry
|
||||
for entry in entries
|
||||
if entry["height"] > 0 and (entry["tx_hash"], entry["tx_pos"]) not in existing_keys
|
||||
]
|
||||
|
||||
|
||||
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
|
||||
"""Insert utxo_events for newly-confirmed entries from an Electrum
|
||||
@@ -54,3 +84,118 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l
|
||||
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
|
||||
|
||||
+114
-4
@@ -2,6 +2,67 @@ 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):
|
||||
@@ -15,6 +76,11 @@ class ElectrumClient:
|
||||
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):
|
||||
@@ -27,6 +93,7 @@ class ElectrumClient:
|
||||
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
|
||||
@@ -42,25 +109,51 @@ class ElectrumClient:
|
||||
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):
|
||||
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:
|
||||
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_event_loop().create_future()
|
||||
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()
|
||||
return await future
|
||||
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())
|
||||
@@ -76,6 +169,17 @@ class ElectrumClient:
|
||||
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])
|
||||
|
||||
@@ -92,6 +196,12 @@ class ElectrumClient:
|
||||
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():
|
||||
|
||||
+469
-30
@@ -6,95 +6,487 @@ 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
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.deposits.service import (
|
||||
credit_confirmed_utxos,
|
||||
find_new_credit_candidates,
|
||||
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 with backoff 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.
|
||||
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[[], ElectrumClient], session_factory: async_sessionmaker):
|
||||
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."""
|
||||
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:
|
||||
asyncio.create_task(self._subscribe_and_refresh(scripthash, user_id))
|
||||
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:
|
||||
await self._run_once()
|
||||
connected = await self._run_once(endpoint)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Electrum listener error, reconnecting in %ss", backoff)
|
||||
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)
|
||||
continue
|
||||
backoff = 1
|
||||
|
||||
async def _run_once(self) -> None:
|
||||
client = self._client_factory()
|
||||
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.tip_height = header["height"]
|
||||
self.tip_header_hex = header.get("hex")
|
||||
|
||||
await self._subscribe_all_users()
|
||||
self._apply_header(header)
|
||||
if self.tip_header_hex is None:
|
||||
# B-64: the header was unusable (no hex) and we have never had a tip,
|
||||
# so publishing this client would hand every consumer a connection
|
||||
# whose chain position is unknown — B-63 all over again. A server at
|
||||
# or behind a tip we already know is fine and doesn't come through
|
||||
# here: the point is only that *some* tip is established.
|
||||
raise HeaderValidationError(
|
||||
f"{endpoint} announced an unusable initial header ({header!r}) and no tip is known"
|
||||
)
|
||||
# B-63: published only now, never before the first header has been
|
||||
# applied. `self.client is not None` is what every consumer treats as
|
||||
# "the chain is reachable" — including RoundScheduler._tick, which then
|
||||
# reads tip_height as the baseline a draw must find a *later* block than.
|
||||
# Assigning it before this round-trip left a window where the connection
|
||||
# looked alive while tip_height was still 0, so a round closing inside it
|
||||
# recorded a baseline of 0 and the very first header we learned — the
|
||||
# current tip, a block mined *before* the round closed, with a hash
|
||||
# already public while bets were still open — satisfied
|
||||
# `tip_height > tip_at_close` and seeded the draw.
|
||||
self.client = client
|
||||
|
||||
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:
|
||||
await asyncio.gather(
|
||||
self._consume_headers(headers_queue),
|
||||
self._consume_scripthash(scripthash_queue),
|
||||
)
|
||||
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()
|
||||
for user in users:
|
||||
|
||||
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)
|
||||
await self.refresh_user(user_id, scripthash)
|
||||
|
||||
def _apply_header(self, header: dict) -> None:
|
||||
"""Record a new chain tip, refusing to move backwards or sideways.
|
||||
|
||||
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.
|
||||
|
||||
Two more headers are refused without ending the session, since neither is
|
||||
evidence of a hostile server the way the above are (B-64): one carrying no
|
||||
hex, and one at the height we already hold a header for.
|
||||
"""
|
||||
height = header["height"]
|
||||
header_hex = header.get("hex")
|
||||
|
||||
if not header_hex:
|
||||
# Nothing to validate and nothing to draw from — and applying the height
|
||||
# alone would break exactly the pairing this function exists to keep:
|
||||
# tip_height would describe a block tip_header_hex doesn't (and on a
|
||||
# session's first header, would publish a client whose tip is unknown,
|
||||
# which is B-63). Ignored rather than fatal: a server that only ever
|
||||
# pushed heights would freeze the draw — visibly, via B-36's
|
||||
# draw_stalled — instead of costing us the one connection that also
|
||||
# credits deposits and broadcasts transactions. _run_once separately
|
||||
# refuses to publish a client while the tip is still unknown.
|
||||
logger.warning(
|
||||
"ignoring Electrum header at height %s: no header hex to validate or to draw from", height
|
||||
)
|
||||
return
|
||||
|
||||
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 height == self.tip_height and self.tip_header_hex:
|
||||
# B-64: a second header for the height we already hold one for. Either the
|
||||
# same block re-announced (nothing to do) or a competing one — a reorg at
|
||||
# the tip, or a server swapping out the very hash a draw may be about to
|
||||
# use. The linkage check above cannot speak to this case at all, since
|
||||
# there is no height advance to check. Whichever it is, the hash committed
|
||||
# to for a height is not replaced under us: if ours turns out to be the
|
||||
# orphan, corroborate_header (B-28) refuses to seed a draw from it and the
|
||||
# draw waits for a further block instead.
|
||||
if header_hex != self.tip_header_hex:
|
||||
logger.warning(
|
||||
"ignoring a competing header at the current tip height %s "
|
||||
"(reorg at the tip, or a server disagreeing with the rest)",
|
||||
height,
|
||||
)
|
||||
return
|
||||
|
||||
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 corroborate_utxo_credit(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
|
||||
"""B-59: the mirror of corroborate_utxo_spent, for money going the other
|
||||
way. A candidate external spend could not reduce a balance without a
|
||||
quorum, but `value` and `height` for a *credit* were taken from the single
|
||||
active connection and written straight to the DB — so one hostile or broken
|
||||
server could inflate a user's displayed balance with outpoints that don't
|
||||
exist. That never spends anyone else's coins (a bet or withdrawal built on
|
||||
a phantom UTXO is refused at broadcast and rolled back), but it wedges the
|
||||
balance display and burns build attempts, and on a custodial platform a
|
||||
balance that isn't real is a support incident either way.
|
||||
|
||||
Agreement means: this server also reports the outpoint as unspent, for the
|
||||
same amount, and considers it confirmed. The height itself is not compared —
|
||||
a server still catching up reports the entry at height 0 and simply doesn't
|
||||
agree, which is the same answer, while for a genuinely confirmed outpoint
|
||||
two honest servers cannot disagree on the height anyway.
|
||||
|
||||
A failure here delays a credit, it never loses one: the next scripthash
|
||||
notification or DepositReconciler sweep (300s) retries it, and a deposit is
|
||||
only credited once the quorum agrees.
|
||||
"""
|
||||
|
||||
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
|
||||
)
|
||||
return any(
|
||||
e.get("tx_hash") == txid
|
||||
and e.get("tx_pos") == vout
|
||||
and e.get("value") == value
|
||||
and (e.get("height") or 0) > 0
|
||||
for e in entries
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return await self._corroborate_majority(
|
||||
_ask, lambda agrees: agrees, f"credit of {value} sats at {txid}:{vout}"
|
||||
)
|
||||
|
||||
async def _consume_headers(self, queue: asyncio.Queue) -> None:
|
||||
while True:
|
||||
params = await queue.get()
|
||||
for header in params:
|
||||
self.tip_height = header["height"]
|
||||
self.tip_header_hex = header.get("hex")
|
||||
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.
|
||||
@@ -105,12 +497,59 @@ class ElectrumListener:
|
||||
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)
|
||||
await self.refresh_user(user_id, scripthash)
|
||||
|
||||
async def _refresh_user(self, user_id: int, scripthash: str) -> None:
|
||||
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 every
|
||||
balance-moving candidate against other servers — new credits (B-59) as
|
||||
well as candidate external spends (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)
|
||||
credit_candidates = await find_new_credit_candidates(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)
|
||||
]
|
||||
|
||||
corroborated_credits = [
|
||||
entry
|
||||
for entry in credit_candidates
|
||||
if await self.corroborate_utxo_credit(
|
||||
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"]
|
||||
)
|
||||
]
|
||||
|
||||
credited = 0
|
||||
if corroborated_credits:
|
||||
async with self._session_factory() as session:
|
||||
credited = await credit_confirmed_utxos(session, user_id, corroborated_credits)
|
||||
if len(corroborated_credits) < len(credit_candidates):
|
||||
logger.warning(
|
||||
"%s new UTXO(s) for user_id=%s not corroborated by the other servers — "
|
||||
"not credited yet, will retry on the next refresh",
|
||||
len(credit_candidates) - len(corroborated_credits),
|
||||
user_id,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
+54
-12
@@ -2,7 +2,7 @@ import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
@@ -15,42 +15,66 @@ import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmati
|
||||
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.config import settings
|
||||
from app.api.errors import ApiError
|
||||
from app.config import settings, validate_runtime_secrets
|
||||
from app.db.base import AsyncSessionLocal
|
||||
from app.electrum.client import ElectrumClient
|
||||
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() -> ElectrumClient:
|
||||
return ElectrumClient(settings.electrum_host, settings.electrum_port, settings.electrum_use_ssl)
|
||||
def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient:
|
||||
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
listener = ElectrumListener(_make_electrum_client, AsyncSessionLocal)
|
||||
# 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
|
||||
@@ -61,20 +85,36 @@ async def lifespan(app: FastAPI):
|
||||
await listener.client.close()
|
||||
|
||||
|
||||
app = FastAPI(title="PLM Lottery", lifespan=lifespan)
|
||||
# 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": "internal server error"})
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": ApiError("internal_error", "internal server error").as_detail()},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -97,10 +137,12 @@ async def admin_panel() -> FileResponse:
|
||||
|
||||
@app.get("/guida", include_in_schema=False)
|
||||
async def user_guide() -> FileResponse:
|
||||
"""Serves docs/guida-utente.md as plain text so it opens inline in the
|
||||
browser (no markdown rendering — keeps this simple), linked from the
|
||||
navbar's help button in app/static/index.html."""
|
||||
return FileResponse("docs/guida-utente.md", media_type="text/plain; charset=utf-8")
|
||||
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")
|
||||
|
||||
@@ -8,6 +8,12 @@ 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"
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
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.
|
||||
@@ -10,6 +27,56 @@ def header_hex_to_block_hash(header_hex: str) -> str:
|
||||
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
|
||||
|
||||
+54
-14
@@ -1,16 +1,31 @@
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
|
||||
# Defensive cap on concurrent SSE subscribers. Expected load is on the order of
|
||||
# ~100 concurrent users; this is set well above that so it never engages under
|
||||
# normal use — it exists purely so a runaway/DoS-y number of open connections
|
||||
# degrades (new connections fall back to polling, see round_stream()) instead
|
||||
# of growing the in-memory subscriber set without bound. Revisit this number if
|
||||
# expected concurrency grows well past it.
|
||||
# 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 is already reached."""
|
||||
"""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:
|
||||
@@ -26,22 +41,47 @@ class RoundEventBroadcaster:
|
||||
would need a shared channel (e.g. Redis pub/sub) instead.
|
||||
"""
|
||||
|
||||
def __init__(self, max_subscribers: int = MAX_SUBSCRIBERS):
|
||||
self._subscribers: set[asyncio.Queue] = set()
|
||||
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) -> asyncio.Queue:
|
||||
if len(self._subscribers) >= self.max_subscribers:
|
||||
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._subscribers.add(queue)
|
||||
self._ip_by_queue[queue] = client_ip
|
||||
ip_queues.append(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, queue: asyncio.Queue) -> None:
|
||||
self._subscribers.discard(queue)
|
||||
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._subscribers:
|
||||
for queue in self._ip_by_queue:
|
||||
if queue.full():
|
||||
continue # a not-yet-delivered notification already covers this one
|
||||
queue.put_nowait(None)
|
||||
|
||||
+349
-34
@@ -3,17 +3,18 @@ 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 async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant, User
|
||||
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
|
||||
from app.rounds.service import open_new_round_if_needed, round_deadline, 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
|
||||
@@ -22,6 +23,20 @@ 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
|
||||
@@ -52,15 +67,26 @@ class RoundScheduler:
|
||||
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
|
||||
round_id, status = round_.id, round_.status
|
||||
# B-61: this round's own snapshotted deadline, not one recomputed from
|
||||
# whatever the config says now — an operator lowering the duration
|
||||
# mid-round used to close the round on the spot.
|
||||
deadline = round_deadline(round_)
|
||||
|
||||
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 # already drawing/paying_out; progress happens elsewhere
|
||||
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):
|
||||
if datetime.now(timezone.utc) < deadline:
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
@@ -75,10 +101,17 @@ class RoundScheduler:
|
||||
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 == "broadcast")
|
||||
.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"
|
||||
@@ -89,6 +122,27 @@ class RoundScheduler:
|
||||
async with self._session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
|
||||
# B-53: re-check for in-flight bets in the *same* session that snapshots
|
||||
# the participants. _tick's check ran in a session of its own, so a bet
|
||||
# committing its "building" row in between was counted by neither: the
|
||||
# round drew and paid out without it, while its sats still landed in the
|
||||
# pool. place_bet's compare-and-set on the round row is what makes that
|
||||
# window unreachable; this is the cheap second lock on the same door, and
|
||||
# it fails safe — the round stays "closing" and the next tick retries.
|
||||
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:
|
||||
logger.info(
|
||||
"round %s: %s bet(s) still in flight at close time, waiting", round_id, pending_count
|
||||
)
|
||||
return
|
||||
|
||||
participants = (
|
||||
await session.scalars(
|
||||
select(RoundParticipant)
|
||||
@@ -114,11 +168,13 @@ class RoundScheduler:
|
||||
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(tip_at_close)
|
||||
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:
|
||||
@@ -148,75 +204,334 @@ class RoundScheduler:
|
||||
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, tip_at_close: int) -> tuple[int, str]:
|
||||
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.
|
||||
|
||||
B-63: `tip_at_close` of 0 means the tip was *unknown* when the round closed,
|
||||
not that the chain was at height zero — and "the first block we hear about"
|
||||
is then not necessarily a block mined after the close. Rather than seed the
|
||||
draw from a hash that may already have been public while bets were open, the
|
||||
first height we do learn becomes the baseline and this waits for a block
|
||||
strictly after it. Since the Electrum listener now only publishes its client
|
||||
once a header has been applied, and _tick won't run without one, this should
|
||||
be unreachable — it stays as the local statement of what the draw actually
|
||||
requires, since nothing else in this function would notice if that stopped
|
||||
holding.
|
||||
"""
|
||||
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)
|
||||
if tip_at_close <= 0:
|
||||
tip_at_close = await self._adopt_baseline_tip(round_id)
|
||||
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)
|
||||
return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex)
|
||||
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 _adopt_baseline_tip(self, round_id: int) -> int:
|
||||
"""B-63: the height the draw must find a *later* block than, for the case
|
||||
where the tip wasn't known at closing time. Waits for a header to arrive and
|
||||
takes that height as the baseline — the block it describes may predate the
|
||||
close, which is exactly why it is used as the floor rather than as the seed —
|
||||
and records why, since a draw that waits one extra block should be explainable
|
||||
from /admin rather than looking like a stall.
|
||||
"""
|
||||
while not self._listener.tip_header_hex or self._listener.tip_height <= 0:
|
||||
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
|
||||
height = self._listener.tip_height
|
||||
logger.warning(
|
||||
"round %s: chain tip was unknown at closing time; using height %s as the draw baseline "
|
||||
"and waiting for a further block",
|
||||
round_id,
|
||||
height,
|
||||
)
|
||||
async with self._session_factory() as session:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"draw_baseline_tip_unknown",
|
||||
{"baseline_height": height},
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
return 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)
|
||||
if not config.fee_address:
|
||||
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 = await session.get(User, round_.winner_user_id)
|
||||
winner_share = round_.pool_amount_sats * 70 // 100
|
||||
commission_share = round_.pool_amount_sats - winner_share # remainder from rounding goes to fees
|
||||
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)
|
||||
pool_scripthash = address_to_scripthash(pool_address)
|
||||
entries = await client.listunspent(pool_scripthash)
|
||||
utxos = [Utxo(e["tx_hash"], e["tx_pos"], e["value"]) for e in entries if e["height"] > 0]
|
||||
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
|
||||
]
|
||||
|
||||
try:
|
||||
built = build_payout_transaction(
|
||||
signing_key=pool_key,
|
||||
from_script=pool_script_obj,
|
||||
utxos=utxos,
|
||||
winner_address=winner.address,
|
||||
winner_share_sats=winner_share,
|
||||
fee_address=config.fee_address,
|
||||
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=config.fee_rate_sat_vb,
|
||||
fee_rate_sat_vb=fee_rate,
|
||||
)
|
||||
except InsufficientFundsError:
|
||||
logger.exception("round %s payout failed: insufficient pool UTXOs", round_id)
|
||||
except InsufficientFundsError as exc:
|
||||
# Includes the "too_many_inputs" case: the pool holds enough, but spread over
|
||||
# more UTXOs than one transaction may spend, so /admin has to say which. Since
|
||||
# B-52 that means MAX_PAYOUT_TX_INPUTS, and participants are capped below it at
|
||||
# bet time (bets/service.py), so reaching it now takes pool change accumulated
|
||||
# over many rounds rather than one busy round — an operator consolidation job,
|
||||
# not a dead end for the bets of the round in progress.
|
||||
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
|
||||
|
||||
await client.broadcast(built.raw_hex)
|
||||
|
||||
# --- 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
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
pending = PendingTransaction(
|
||||
kind="payout",
|
||||
round_id=round_id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
fee_rate_sat_vb=fee_rate,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
status="pending",
|
||||
)
|
||||
# "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=round_.winner_user_id,
|
||||
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
|
||||
|
||||
+105
-12
@@ -1,23 +1,62 @@
|
||||
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.db.models import Round, RoundConfig
|
||||
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)."""
|
||||
return await session.scalar(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc()))
|
||||
(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:
|
||||
def round_deadline(round_: Round) -> datetime:
|
||||
"""When this round stops accepting bets. B-61: from the round's own snapshotted
|
||||
duration, not from the live config — an operator editing round_duration_seconds
|
||||
mid-round must not move a deadline clients are already counting down to, nor
|
||||
close an in-progress round on the spot."""
|
||||
return round_.opened_at.replace(tzinfo=timezone.utc) + timedelta(seconds=round_.duration_seconds)
|
||||
|
||||
|
||||
def round_accepts_bets(round_: Round) -> 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
|
||||
@@ -26,17 +65,32 @@ def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
|
||||
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)
|
||||
return datetime.now(timezone.utc) < round_deadline(round_)
|
||||
|
||||
|
||||
def rounds_can_open(config: RoundConfig) -> bool:
|
||||
"""Whether the instance is configured well enough to run a round at all (B-66).
|
||||
|
||||
Only fee_address today, and only because a round without one is unpayable: the
|
||||
payout pays the 30% commission to it, so build_payout_transaction cannot even be
|
||||
built. It has no column default for exactly this reason (rounds/config.py) — an
|
||||
operator must set their own, and until they do there is nothing to guess.
|
||||
|
||||
Anything else that would make a round unpayable belongs here too, next to it,
|
||||
rather than being discovered at payout time. Deliberately not about *pausing*,
|
||||
which is a decision an operator took (RoundConfig.paused) rather than a
|
||||
prerequisite they haven't met yet."""
|
||||
return bool(config.fee_address.strip())
|
||||
|
||||
|
||||
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.
|
||||
hasn't elapsed yet, the lottery is paused for maintenance, or the instance isn't
|
||||
configured well enough to pay a winner — in any of those cases 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
|
||||
@@ -48,19 +102,58 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
|
||||
config = await get_round_config(session)
|
||||
if config.paused:
|
||||
return None
|
||||
if not rounds_can_open(config):
|
||||
# B-66: a fresh instance starts with no fee_address, and a round opened
|
||||
# without one takes bets, confirms them, and only then discovers that the
|
||||
# payout cannot be built — leaving the round wedged in "paying_out",
|
||||
# retrying every 60s, with money already in the pool. Every round would
|
||||
# need its own manual recovery. Refusing to open costs nothing by
|
||||
# comparison: no money has moved yet, and it is the operator's own missing
|
||||
# setup, surfaced through GET /rounds/current's lottery_configured and the
|
||||
# admin panel rather than discovered a round too late.
|
||||
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):
|
||||
# B-61: the cooldown the closing round announced is the one honoured, so
|
||||
# editing the config never retroactively shortens or extends a gap already
|
||||
# under way. The new value applies from the next round on.
|
||||
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=last_closed.cooldown_seconds):
|
||||
return None
|
||||
|
||||
round_ = Round(status="open")
|
||||
for attempt in range(_OPEN_ROUND_ATTEMPTS):
|
||||
# B-61: the timing this round will run by, fixed at open time.
|
||||
round_ = Round(
|
||||
status="open",
|
||||
duration_seconds=config.round_duration_seconds,
|
||||
cooldown_seconds=config.round_cooldown_seconds,
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -133,6 +133,7 @@ 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 {
|
||||
@@ -144,6 +145,13 @@ td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; m
|
||||
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);
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<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>
|
||||
@@ -61,6 +62,13 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<!-- B-66: no fee_address means no round can open at all (the payout pays the
|
||||
30% commission to it, so it cannot even be built). Shown here because
|
||||
this is the one screen that can fix it. -->
|
||||
<div class="warning-banner hidden" id="admin-fee-address-warning">
|
||||
⚠️ Nessun fee address configurato: finché resta vuoto <strong>non si aprirà nessun round</strong>
|
||||
(il payout non sarebbe costruibile). Impostalo qui sotto e salva.
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<label for="admin-fee-address">Fee address (dove finisce il 30% di ogni round)</label>
|
||||
@@ -153,6 +161,22 @@
|
||||
</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>
|
||||
|
||||
|
||||
+66
-8
@@ -1,4 +1,12 @@
|
||||
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) {
|
||||
@@ -26,7 +34,9 @@ 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(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || res.statusText);
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -79,8 +89,8 @@ function stopChainStatusPolling() {
|
||||
chainStatusInterval = null;
|
||||
}
|
||||
|
||||
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit'];
|
||||
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog };
|
||||
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) {
|
||||
@@ -99,7 +109,7 @@ function showDashboard() {
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog()]);
|
||||
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog(), loadBugReports()]);
|
||||
}
|
||||
|
||||
async function adminLogin() {
|
||||
@@ -131,6 +141,11 @@ async function adminLoadConfig() {
|
||||
try {
|
||||
const data = await callAdmin('GET', '/admin/config');
|
||||
document.getElementById('admin-fee-address').value = data.fee_address;
|
||||
// B-66: an empty fee address blocks every future round, so say so here rather
|
||||
// than leaving an empty field to be noticed.
|
||||
document
|
||||
.getElementById('admin-fee-address-warning')
|
||||
.classList.toggle('hidden', !!(data.fee_address || '').trim());
|
||||
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;
|
||||
@@ -216,7 +231,7 @@ async function loadUsers() {
|
||||
<td>${u.id}</td>
|
||||
<td>${escapeHtml(u.username)}</td>
|
||||
<td class="addr">${escapeHtml(u.address)}</td>
|
||||
<td>${u.balance_sats / SATS_PER_PLM}</td>
|
||||
<td>${fmtPlm(u.balance_sats)}</td>
|
||||
<td>${fmtDate(u.created_at)}</td>
|
||||
<td>
|
||||
<button class="reveal" onclick="revealPrivkey(${u.id}, this)">Mostra</button>
|
||||
@@ -286,9 +301,9 @@ async function loadRounds() {
|
||||
<td>${badge(r.status)}</td>
|
||||
<td>${fmtDate(r.opened_at)}</td>
|
||||
<td>${r.winner_username ? escapeHtml(r.winner_username) : '—'}</td>
|
||||
<td>${r.pool_amount_sats != null ? r.pool_amount_sats / SATS_PER_PLM : '—'}</td>
|
||||
<td>${r.winner_amount_sats != null ? r.winner_amount_sats / SATS_PER_PLM : '—'}</td>
|
||||
<td>${r.fee_amount_sats != null ? r.fee_amount_sats / SATS_PER_PLM : '—'}</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>';
|
||||
@@ -336,6 +351,49 @@ async function loadAuditLog() {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
+283
-68
@@ -1,5 +1,19 @@
|
||||
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');
|
||||
@@ -13,16 +27,39 @@ function toast(message, type) {
|
||||
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 original = button.textContent;
|
||||
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.textContent = original;
|
||||
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;
|
||||
@@ -41,15 +78,49 @@ async function call(method, path, body) {
|
||||
try {
|
||||
res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal });
|
||||
} catch (e) {
|
||||
throw new Error(e.name === 'AbortError' ? 'Richiesta al server scaduta.' : e.message);
|
||||
throw new Error(e.name === 'AbortError' ? t('toast.requestTimeout') : e.message);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || res.statusText);
|
||||
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');
|
||||
@@ -83,11 +154,11 @@ let roundTimerInterval = null;
|
||||
let roundPollTimeout = null;
|
||||
let lastResultInterval = null;
|
||||
|
||||
const ROUND_STATUS_LABELS = {
|
||||
open: 'aperto',
|
||||
closing: 'in chiusura',
|
||||
drawing: 'estrazione in corso',
|
||||
paying_out: 'pagamento al vincitore in corso',
|
||||
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'];
|
||||
@@ -97,34 +168,59 @@ const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out'];
|
||||
// takes the round data so the drawing phase can surface the draw block once known.
|
||||
function drawingLabelFor(data) {
|
||||
if (data.status === 'closing') {
|
||||
return 'Round chiuso — in attesa di conferma dell\'ultima giocata prima di estrarre il vincitore…';
|
||||
return t('draw.closing');
|
||||
}
|
||||
if (data.status === 'drawing') {
|
||||
return 'In attesa del prossimo blocco per estrarre il vincitore…';
|
||||
return t('draw.drawing');
|
||||
}
|
||||
// paying_out
|
||||
if (data.draw_block_height != null) {
|
||||
return 'Vincitore estratto dal blocco #' + data.draw_block_height + ' — pagamento al vincitore in corso…';
|
||||
return t('draw.payingOutBlock', { height: data.draw_block_height });
|
||||
}
|
||||
return 'Vincitore estratto — pagamento al vincitore in corso…';
|
||||
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_LABELS = {
|
||||
waiting: 'In attesa del prossimo round',
|
||||
open: 'Round aperto',
|
||||
closing: 'Round chiuso — attesa conferma puntate',
|
||||
drawing: 'Estrazione in corso',
|
||||
paying_out: 'Pagamento al vincitore in corso',
|
||||
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.
|
||||
@@ -133,13 +229,24 @@ function updateChainStatusBar(data) {
|
||||
else if (DRAWING_STATUSES.includes(data.status)) dotKey = 'drawing';
|
||||
else dotKey = 'open';
|
||||
|
||||
const labelKey = data.round_id && data.status in CHAIN_STATUS_LABELS ? data.status : 'waiting';
|
||||
const labelKey = data.round_id && data.status in CHAIN_STATUS_KEYS ? data.status : 'waiting';
|
||||
|
||||
dot.className = 'status-dot status-' + dotKey;
|
||||
label.textContent = CHAIN_STATUS_LABELS[labelKey];
|
||||
block.textContent = 'Blocco ' + (data.chain_tip_height != null ? '#' + data.chain_tip_height : '—');
|
||||
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);
|
||||
// Two separate reasons no round will open, and telling them apart matters to the
|
||||
// reader: a pause ends when the operator resumes, while an unconfigured instance
|
||||
// (B-66) won't produce a round at all until it's set up — "come back later" would
|
||||
// be a lie. `=== false` so an older server that doesn't send the field at all
|
||||
// can't flash the banner. A pause takes precedence: it's the deliberate action.
|
||||
const notConfigured = data.lottery_configured === false;
|
||||
const noRoundsComing = !!data.lottery_paused || notConfigured;
|
||||
document.getElementById('maintenance-banner').classList.toggle('hidden', !noRoundsComing);
|
||||
if (noRoundsComing) {
|
||||
document.getElementById('maintenance-banner-text').textContent =
|
||||
data.lottery_paused ? t('maintenance.banner') : t('maintenance.notConfigured');
|
||||
}
|
||||
}
|
||||
|
||||
// After a couple of consecutive failed polls (network blip, server restart,
|
||||
@@ -150,8 +257,8 @@ const STALE_AFTER_FAILURES = 2;
|
||||
let consecutiveFetchFailures = 0;
|
||||
|
||||
function showConnectionLost() {
|
||||
document.getElementById('chain-status-dot').className = 'status-dot status-offline';
|
||||
document.getElementById('chain-status-label').textContent = 'Connessione al server persa — riprovo…';
|
||||
chainOffline = true;
|
||||
renderChainStatusBar();
|
||||
}
|
||||
|
||||
function noteFetchOutcome(ok) {
|
||||
@@ -228,7 +335,7 @@ function renderPersistedResult(result) {
|
||||
setRoundInfoVisible(false);
|
||||
setResultBoxVisible(
|
||||
true,
|
||||
result.won ? '🎉 Hai vinto! +' + (result.amount_sats / SATS_PER_PLM) + ' PLM' : 'Non hai vinto questa volta.',
|
||||
result.won ? t('result.win', { amount: formatPlm(result.amount_sats) }) : t('result.lose'),
|
||||
result.won ? 'win' : 'lose'
|
||||
);
|
||||
}
|
||||
@@ -264,14 +371,57 @@ async function checkLastRoundResult() {
|
||||
persistResult(data.round_id, data.won, data.amount_sats);
|
||||
renderPersistedResult({ won: data.won, amount_sats: data.amount_sats });
|
||||
if (data.won) {
|
||||
const won = data.amount_sats / SATS_PER_PLM;
|
||||
toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success');
|
||||
const won = formatPlm(data.amount_sats);
|
||||
toast(t('toast.roundWon', { id: data.round_id, amount: won }), 'success');
|
||||
refreshMe();
|
||||
}
|
||||
}
|
||||
|
||||
let lastJackpotValue = null;
|
||||
|
||||
// The round's own confirmed/in-flight split (B-65). The big numbers are the
|
||||
// confirmed ones — the players the draw will pick from and the pool the payout
|
||||
// will actually spend — because a jackpot advertised larger than the one paid out
|
||||
// is the kind of gap nobody forgives. What has been bet but hasn't confirmed yet
|
||||
// is shown next to them instead of being folded in, so the player who just bet
|
||||
// still sees their own bet immediately (the same reasoning as the amber
|
||||
// pending balance, see setBalanceDisplay).
|
||||
//
|
||||
// Kept out of the [data-i18n] mechanism on purpose: these come from server data,
|
||||
// so onLanguageChange() re-renders them through t() like every other dynamic bit.
|
||||
let lastRoundStats = null;
|
||||
|
||||
function renderRoundStats(data) {
|
||||
lastRoundStats = data;
|
||||
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;
|
||||
|
||||
// pending_* are inclusive of the confirmed figures (like pending_balance_sats),
|
||||
// so what's shown alongside is the difference.
|
||||
const pendingPlayers = (data.pending_participant_count || 0) - data.participant_count;
|
||||
const pendingJackpotSats = (data.pending_jackpot_sats || 0) - data.jackpot_sats;
|
||||
const show = !!data.has_pending_bets && pendingPlayers > 0;
|
||||
|
||||
const playersPendingEl = document.getElementById('round-players-pending');
|
||||
playersPendingEl.textContent = show ? t('round.playersPending', { n: pendingPlayers }) : '';
|
||||
playersPendingEl.classList.toggle('hidden', !show);
|
||||
|
||||
const jackpotPendingEl = document.getElementById('round-jackpot-pending');
|
||||
jackpotPendingEl.textContent = show
|
||||
? t('round.jackpotPending', { amount: formatPlm(pendingJackpotSats) })
|
||||
: '';
|
||||
jackpotPendingEl.classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
let timerHitZero = false;
|
||||
|
||||
function updateRoundTimer() {
|
||||
@@ -319,6 +469,21 @@ function setResultBoxVisible(show, html, cls) {
|
||||
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);
|
||||
@@ -336,18 +501,11 @@ async function refreshRound() {
|
||||
noteFetchOutcome(true);
|
||||
updateChainStatusBar(data);
|
||||
document.getElementById('round-title').textContent = data.round_id
|
||||
? 'Round #' + data.round_id + ' — ' + (ROUND_STATUS_LABELS[data.status] || data.status)
|
||||
: 'Nessun round attivo';
|
||||
document.getElementById('round-players').textContent = data.participant_count;
|
||||
const jackpotEl = document.getElementById('round-jackpot');
|
||||
const jackpotValue = data.jackpot_sats / SATS_PER_PLM;
|
||||
jackpotEl.textContent = jackpotValue;
|
||||
if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) {
|
||||
jackpotEl.classList.remove('jackpot-bump');
|
||||
void jackpotEl.offsetWidth; // restart the animation
|
||||
jackpotEl.classList.add('jackpot-bump');
|
||||
}
|
||||
lastJackpotValue = jackpotValue;
|
||||
? 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();
|
||||
renderRoundStats(data);
|
||||
if (data.server_time) serverTimeOffsetMs = new Date(data.server_time) - new Date();
|
||||
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
|
||||
updateRoundTimer();
|
||||
@@ -375,16 +533,26 @@ async function refreshRound() {
|
||||
// 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;
|
||||
data.user_played &&
|
||||
data.winner_user_id != null &&
|
||||
(alreadyKnown || elapsedMs >= minMs) &&
|
||||
myUserId != null &&
|
||||
amountReady;
|
||||
|
||||
if (canReveal) {
|
||||
const won = data.winner_user_id === myUserId;
|
||||
const won = iWon;
|
||||
if (!alreadyKnown) {
|
||||
persistResult(data.round_id, won, data.winner_amount_sats);
|
||||
if (won) {
|
||||
const wonAmount = (data.winner_amount_sats / SATS_PER_PLM);
|
||||
toast('Hai vinto il round #' + data.round_id + '! +' + wonAmount + ' PLM', 'success');
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -471,14 +639,20 @@ async function register() {
|
||||
const p = document.getElementById('reg-password').value;
|
||||
const pConfirm = document.getElementById('reg-password-confirm').value;
|
||||
if (p !== pConfirm) {
|
||||
toast('Le password non coincidono.', 'error');
|
||||
toast(t('toast.passwordMismatch'), 'error');
|
||||
return;
|
||||
}
|
||||
await withLoading(btn, 'Creazione…', async () => {
|
||||
// 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('Account creato.', 'success');
|
||||
toast(t('toast.accountCreated'), 'success');
|
||||
showDashboard();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
@@ -490,11 +664,11 @@ 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, 'Accesso…', async () => {
|
||||
await withLoading(btn, t('loading.loggingIn'), async () => {
|
||||
try {
|
||||
const data = await call('POST', '/auth/login', { username: u, password: p });
|
||||
persistSession(data, u);
|
||||
toast('Accesso riuscito.', 'success');
|
||||
toast(t('toast.loginSuccess'), 'success');
|
||||
showDashboard();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
@@ -519,7 +693,12 @@ function resetToLoggedOutUI() {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -555,9 +734,9 @@ function initAuthState() {
|
||||
async function copyAddress() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
toast('Indirizzo copiato.', 'success');
|
||||
toast(t('toast.addressCopied'), 'success');
|
||||
} catch (e) {
|
||||
toast('Impossibile copiare automaticamente.', 'error');
|
||||
toast(t('toast.copyFailed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,27 +750,27 @@ let myBalanceSats = 0; // confirmed, spendable balance — what withdrawals/bets
|
||||
// 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 = pendingBalanceSats / SATS_PER_PLM;
|
||||
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, '…', async () => {
|
||||
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 = (data.pending_balance_sats / SATS_PER_PLM) + ' PLM';
|
||||
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('it-IT');
|
||||
document.getElementById('wd-full-amount-value').textContent = data.balance_sats / SATS_PER_PLM;
|
||||
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;
|
||||
}
|
||||
@@ -615,24 +794,29 @@ async function changePassword() {
|
||||
const newPasswordConfirm = document.getElementById('settings-new-password-confirm').value;
|
||||
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
toast('Le nuove password non coincidono.', 'error');
|
||||
toast(t('toast.newPasswordMismatch'), 'error');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast('La nuova password deve avere almeno 8 caratteri.', 'error');
|
||||
toast(t('toast.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await withLoading(btn, 'Aggiornamento…', async () => {
|
||||
await withLoading(btn, t('loading.updating'), async () => {
|
||||
try {
|
||||
await call('POST', '/users/me/change-password', {
|
||||
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('Password aggiornata.', 'success');
|
||||
toast(t('toast.passwordUpdated'), 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
@@ -641,10 +825,10 @@ async function changePassword() {
|
||||
|
||||
async function placeBet() {
|
||||
const btn = document.getElementById('bet-btn');
|
||||
await withLoading(btn, 'Invio bet…', async () => {
|
||||
await withLoading(btn, t('loading.sendingBet'), async () => {
|
||||
try {
|
||||
const data = await call('POST', '/bets', {});
|
||||
toast('Bet piazzata sul round #' + data.round_id + '.', 'success');
|
||||
toast(t('toast.betPlaced', { id: data.round_id }), 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
@@ -657,13 +841,19 @@ 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 amtSats = isFullAmount
|
||||
? myBalanceSats
|
||||
: Math.round(parseFloat(document.getElementById('wd-amount').value) * SATS_PER_PLM);
|
||||
await withLoading(btn, 'Invio…', async () => {
|
||||
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('Withdrawal inviato.', 'success');
|
||||
toast(t('toast.withdrawSent'), 'success');
|
||||
document.getElementById('wd-full-amount').checked = false;
|
||||
toggleWithdrawFullAmount();
|
||||
document.getElementById('wd-amount').value = '';
|
||||
@@ -706,5 +896,30 @@ function connectRoundEvents() {
|
||||
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
|
||||
// Same reason, for the round's "+N in attesa" suffixes (B-65): repaint from what
|
||||
// was last received rather than leaving them in the old language until the
|
||||
// refreshRound() below happens to come back.
|
||||
if (lastRoundStats) renderRoundStats(lastRoundStats);
|
||||
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>
|
||||
+1230
File diff suppressed because it is too large
Load Diff
+97
-70
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -9,7 +9,7 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="hidden" id="app-navbar" aria-label="Sezioni">
|
||||
<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">
|
||||
@@ -22,13 +22,13 @@
|
||||
<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" title="Guida" aria-label="Apri la guida utente">
|
||||
<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="https://github.com/REPLACE_ME/plm-lottery/issues/new" target="_blank" rel="noopener" title="Segnala un bug" aria-label="Segnala un bug su GitHub">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v3M16 2v3M12 12v-2a2 2 0 1 1 2 2h-2Z"/><rect x="6" y="10" width="12" height="10" rx="4"/><path d="M6 15H3M21 15h-3M9 20v-3M15 20v-3"/></svg>
|
||||
<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()" title="Esci" aria-label="Esci dall'account">
|
||||
<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>
|
||||
@@ -37,19 +37,19 @@
|
||||
<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>
|
||||
Deposito
|
||||
<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>
|
||||
Bet
|
||||
<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>
|
||||
Prelievo
|
||||
<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>
|
||||
Profilo
|
||||
<span data-i18n="nav.profile">Profilo</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -59,67 +59,87 @@
|
||||
<div class="chain-bar" id="chain-bar">
|
||||
<span class="chain-status-pill">
|
||||
<span class="status-dot" id="chain-status-dot"></span>
|
||||
<span id="chain-status-label">Connessione…</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>
|
||||
<span class="chain-block mono" id="chain-block">Blocco —</span>
|
||||
</div>
|
||||
|
||||
<div class="maintenance-banner hidden" id="maintenance-banner">
|
||||
<span>⚠️</span>
|
||||
<span>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>
|
||||
<!-- Filled by renderChainStatusBar(): the text depends on *why* no round will
|
||||
open — a deliberate pause, or an instance the operator hasn't finished
|
||||
configuring (B-66) — so it renders through t() and carries no data-i18n. -->
|
||||
<span id="maintenance-banner-text"></span>
|
||||
</div>
|
||||
|
||||
<section id="landing-hero" class="hero">
|
||||
<h1>PLM Lottery</h1>
|
||||
<p class="lead">Deposita PLM, entra nel round con una quota fissa, e se viene estratto il tuo numero vinci il montepremi.</p>
|
||||
<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">1. Deposita</div>
|
||||
<div class="step-hint">Ricevi un indirizzo PLM personale, tuo per sempre</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">2. Gioca</div>
|
||||
<div class="step-hint">Una bet a quota fissa per entrare nel round corrente</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">3. Vinci</div>
|
||||
<div class="step-hint">Estrazione dal blocco, montepremi accreditato subito</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>Quota fissa dichiarata</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>Estrazione da hash di blocco</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>Prelievo libero in ogni momento</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.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')">Login</div>
|
||||
<div class="tab" id="tab-register" onclick="switchTab('register')">Registrati</div>
|
||||
<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">Username</label>
|
||||
<label for="login-username" data-i18n="auth.username">Username</label>
|
||||
<input id="login-username" autocomplete="username">
|
||||
<label for="login-password">Password</label>
|
||||
<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">Accedi</button>
|
||||
<button onclick="login()" id="login-btn" data-i18n="auth.loginBtn">Accedi</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="panel-register">
|
||||
<label for="reg-username">Username</label>
|
||||
<input id="reg-username" autocomplete="username">
|
||||
<label for="reg-password">Password</label>
|
||||
<input id="reg-password" type="password" autocomplete="new-password">
|
||||
<label for="reg-password-confirm">Conferma password</label>
|
||||
<input id="reg-password-confirm" type="password" autocomplete="new-password">
|
||||
<button onclick="register()" id="register-btn">Crea account</button>
|
||||
<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>
|
||||
|
||||
@@ -132,18 +152,22 @@
|
||||
</div>
|
||||
<div class="row-between" style="margin-top:10px" id="round-stats-row">
|
||||
<div>
|
||||
<div class="hint" style="margin-bottom:2px">Giocatori</div>
|
||||
<span class="mono" id="round-players">—</span>
|
||||
<div class="hint" style="margin-bottom:2px" data-i18n="round.players">Giocatori</div>
|
||||
<!-- The two -pending spans are filled from server data by renderRoundStats()
|
||||
(B-65), so they carry no data-i18n: an element belongs to one
|
||||
translation mechanism or the other, never both. -->
|
||||
<span class="mono" id="round-players">—</span> <span class="pending-suffix hidden" id="round-players-pending"></span>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="hint" style="margin-bottom:2px">Jackpot</div>
|
||||
<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>
|
||||
<span class="pending-suffix hidden" id="round-jackpot-pending"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="draw-state" id="draw-state">
|
||||
<div class="draw-spinner"></div>
|
||||
<div class="draw-label" id="draw-label">Estrazione del vincitore in corso…</div>
|
||||
<div class="draw-label" id="draw-label">Drawing the winner…</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden" id="draw-result"></div>
|
||||
@@ -151,81 +175,83 @@
|
||||
|
||||
<div class="dash-panel active" id="panel-deposit">
|
||||
<div class="card">
|
||||
<h2>Saldo interno</h2>
|
||||
<p class="hint">Aggiornato dopo 1 conferma sulla rete</p>
|
||||
<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" aria-label="Aggiorna saldo">
|
||||
<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>
|
||||
Aggiorna
|
||||
<span data-i18n="deposit.refreshBtn">Aggiorna</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Indirizzo di deposito</h2>
|
||||
<p class="hint">È anche l'indirizzo su cui ricevi eventuali vincite</p>
|
||||
<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()" aria-label="Copia indirizzo">
|
||||
<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" alt="QR code dell'indirizzo di deposito">
|
||||
<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>Bet</h2>
|
||||
<p class="hint">Ingresso fisso al round corrente</p>
|
||||
<button onclick="placeBet()" id="bet-btn">Piazza bet (10 PLM)</button>
|
||||
<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>Withdrawal</h2>
|
||||
<p class="hint">Invia fondi a un indirizzo PLM esterno</p>
|
||||
<label for="wd-address">Indirizzo esterno</label>
|
||||
<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">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">Importo (PLM)</label>
|
||||
<input id="wd-amount" inputmode="decimal" placeholder="es. 2">
|
||||
<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()">
|
||||
Preleva l'intero importo (<span class="mono" id="wd-full-amount-value">—</span> PLM)
|
||||
<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">Preleva</button>
|
||||
<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>Profilo</h2>
|
||||
<p class="hint">Le tue informazioni account</p>
|
||||
<label>Username</label>
|
||||
<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>Indirizzo di deposito</label>
|
||||
<label data-i18n="profile.addressLabel">Indirizzo di deposito</label>
|
||||
<div class="address-box"><span class="mono" id="profile-address">—</span></div>
|
||||
<label>Saldo interno</label>
|
||||
<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>Utente dal</label>
|
||||
<label data-i18n="profile.createdLabel">Utente dal</label>
|
||||
<div class="address-box"><span id="profile-created-at">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Impostazioni</h2>
|
||||
<p class="hint">Cambia la password del tuo account</p>
|
||||
<label for="settings-current-password">Password attuale</label>
|
||||
<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">Nuova password</label>
|
||||
<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">Conferma nuova password</label>
|
||||
<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">Aggiorna password</button>
|
||||
<button onclick="changePassword()" id="change-password-btn" data-i18n="settings.updateBtn">Aggiorna password</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,6 +261,7 @@
|
||||
|
||||
<div id="toast-container" aria-live="polite"></div>
|
||||
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -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>
|
||||
+112
-3
@@ -74,16 +74,17 @@ h1, h2, h3 { font-family: inherit; letter-spacing: -0.01em; }
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
input:focus {
|
||||
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 { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
|
||||
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;
|
||||
@@ -214,6 +215,12 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
|
||||
.balance-confirmed { color: var(--color-success); }
|
||||
.balance-pending { color: var(--color-primary); }
|
||||
|
||||
/* The in-flight part of the round's own figures (B-65): the players/jackpot next
|
||||
to it are the confirmed ones the draw and the payout will actually use, and this
|
||||
is what has been bet but hasn't confirmed yet. Same amber as .balance-pending,
|
||||
for the same "not settled" meaning. */
|
||||
.pending-suffix { color: var(--color-primary); font-size: 0.8rem; font-weight: 600; }
|
||||
|
||||
.icon { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
|
||||
.dash-panel { display: none; }
|
||||
@@ -253,6 +260,93 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
|
||||
.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;
|
||||
@@ -342,6 +436,21 @@ body::before {
|
||||
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);
|
||||
|
||||
+148
-38
@@ -9,17 +9,23 @@ from embit.finalizer import finalize_psbt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.models import PendingTransaction, User
|
||||
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 RBF_SEQUENCE, estimate_vsize
|
||||
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 # minimum relay-policy-friendly bump per BIP125
|
||||
_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):
|
||||
@@ -27,22 +33,29 @@ class RbfError(Exception):
|
||||
|
||||
|
||||
def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool:
|
||||
"""Pure decision: has this pending tx been unconfirmed for longer than the
|
||||
configured timeout (RoundConfig.rbf_timeout_seconds)? Kept separate from the
|
||||
I/O-heavy bump_fee() so it's trivially unit-testable."""
|
||||
"""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.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds)
|
||||
return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds)
|
||||
|
||||
|
||||
async def _signing_context(session: AsyncSession, pending: PendingTransaction) -> tuple:
|
||||
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 pending.kind == "payout":
|
||||
if kind == "payout":
|
||||
key = derive_pool_key()
|
||||
else:
|
||||
user = await session.get(User, pending.user_id)
|
||||
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)
|
||||
@@ -50,10 +63,19 @@ async def _signing_context(session: AsyncSession, pending: PendingTransaction) -
|
||||
|
||||
|
||||
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()
|
||||
tx = await client.get_transaction(txid_hex, verbose=True)
|
||||
value_coins = tx["vout"][vin.vout]["value"]
|
||||
return round(value_coins * 100_000_000)
|
||||
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:
|
||||
@@ -63,33 +85,71 @@ def _find_change_output(tx: Transaction, change_address: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: PendingTransaction) -> str:
|
||||
"""Rebuild `pending`'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.
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
old_tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex))
|
||||
signing_key, own_script, own_address = await _signing_context(session, pending)
|
||||
# --- 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))
|
||||
|
||||
new_fee_rate = pending.fee_rate_sat_vb + _FEE_RATE_INCREMENT
|
||||
new_fee = estimate_vsize(len(old_tx.vin), len(old_tx.vout)) * new_fee_rate
|
||||
fee_delta = new_fee - old_fee
|
||||
if fee_delta <= 0:
|
||||
fee_delta = _FEE_RATE_INCREMENT # already above the new target vsize*rate; bump by a token amount
|
||||
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")
|
||||
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
|
||||
@@ -113,17 +173,71 @@ async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: Pendi
|
||||
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
|
||||
pending.fee_rate_sat_vb = new_fee_rate
|
||||
# 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
|
||||
pending.broadcast_at = datetime.now(timezone.utc)
|
||||
# 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", pending.kind, pending.id, pending.current_txid, new_txid)
|
||||
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
|
||||
@@ -148,16 +262,12 @@ class RbfBumper:
|
||||
candidates = (
|
||||
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
|
||||
).all()
|
||||
due = [p for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)]
|
||||
due_ids = [p.id for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)]
|
||||
|
||||
for pending in due:
|
||||
async with self._session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending.id)
|
||||
if row is None or row.status != "pending":
|
||||
continue
|
||||
for pending_id in due_ids:
|
||||
try:
|
||||
await bump_fee(session, client, row)
|
||||
await bump_fee(self._session_factory, client, pending_id)
|
||||
except RbfError:
|
||||
logger.exception("could not bump pending_transaction %s", row.id)
|
||||
logger.exception("could not bump pending_transaction %s", pending_id)
|
||||
except Exception:
|
||||
logger.exception("unexpected error bumping pending_transaction %s", row.id)
|
||||
logger.exception("unexpected error bumping pending_transaction %s", pending_id)
|
||||
|
||||
+49
-6
@@ -7,7 +7,9 @@ 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__)
|
||||
|
||||
@@ -26,16 +28,57 @@ def register_handler(kind: str, handler: ConfirmationHandler) -> None:
|
||||
|
||||
async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
|
||||
async with session_factory() as session:
|
||||
pending = (
|
||||
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
|
||||
# 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()
|
||||
pending_ids = [p.id for p in pending]
|
||||
|
||||
# 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
|
||||
for pending_id, txid, kind in [(p.id, p.current_txid, p.kind) for p in pending]:
|
||||
tx = await client.get_transaction(txid, verbose=True)
|
||||
if not tx or tx.get("confirmations", 0) < 1:
|
||||
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":
|
||||
|
||||
@@ -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,262 @@
|
||||
"""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 exactly the state the
|
||||
# scheduler's payout retry picks up (B-26: `_retry_payout_if_due`, one attempt
|
||||
# per 60s), so an abandoned payout is rebuilt on its own rather than waiting
|
||||
# for an operator — the log line below is the alert, not the recovery path.
|
||||
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
|
||||
+23
-2
@@ -38,6 +38,15 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
|
||||
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.
|
||||
"""
|
||||
@@ -46,15 +55,27 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
|
||||
select(PendingTransaction).where(
|
||||
PendingTransaction.user_id == user.id,
|
||||
PendingTransaction.kind.in_(("bet", "withdrawal")),
|
||||
PendingTransaction.status == "pending",
|
||||
# "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 out in tx.vout:
|
||||
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
|
||||
|
||||
|
||||
+144
-9
@@ -17,9 +17,69 @@ _P2WPKH_OUTPUT_VBYTES = 31
|
||||
# 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 *user* transaction (bet, withdrawal) 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. Failing the build
|
||||
# with a translatable error is the honest outcome; consolidating the address is the way
|
||||
# out. This is a *user-protection* limit, which is why the payout gets its own, far
|
||||
# higher one below.
|
||||
MAX_TX_INPUTS = 50
|
||||
|
||||
# Ceiling on the payout's inputs (B-52). The payout is not a user spending their own
|
||||
# fragmented balance: it drains the pool, whose UTXO count is simply the number of bets
|
||||
# in the round, and its fee comes out of a 70% share of that whole pool. So the erosion
|
||||
# argument behind MAX_TX_INPUTS doesn't apply here — 400 inputs at 1 sat/vB cost ~27_300
|
||||
# sat, i.e. ~0.00027 PLM out of the winner's share — and reusing that limit was what made
|
||||
# any round past ~50 participants unpayable: select_utxos raised too_many_inputs, the
|
||||
# round stayed "paying_out" retrying forever, and since no new round may open while one
|
||||
# is active, the whole lottery stopped with the pool stuck (B-52).
|
||||
#
|
||||
# What actually bounds this is relay policy: a non-standard transaction is refused at
|
||||
# broadcast past 100 kvB, which at ~68 vbytes per input is ~1470 inputs. 500 stays at
|
||||
# roughly a third of that budget, and signing that many inputs costs ~0.4s of event loop
|
||||
# (measured), once per round, inside a background task.
|
||||
MAX_PAYOUT_TX_INPUTS = 500
|
||||
|
||||
# The most participants one round may hold (B-52). Enforced where the money is not yet
|
||||
# committed — app/bets/service.py refuses the bet — instead of being discovered at payout
|
||||
# time, when the bets are already in the pool and there is no way back. Deliberately below
|
||||
# MAX_PAYOUT_TX_INPUTS: the payout also has to be able to spend whatever change UTXOs
|
||||
# earlier rounds left in the pool, so the gap is the headroom for those.
|
||||
MAX_PARTICIPANTS_PER_ROUND = 400
|
||||
|
||||
|
||||
class InsufficientFundsError(Exception):
|
||||
pass
|
||||
"""`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
|
||||
@@ -43,14 +103,33 @@ 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]:
|
||||
def select_utxos(
|
||||
utxos: list[Utxo], target_sats: int, max_inputs: int = MAX_TX_INPUTS
|
||||
) -> 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."""
|
||||
out of target_sats (see build_signed_transaction), not added on top of it.
|
||||
|
||||
At most `max_inputs` are ever selected (B-48): if the largest `max_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.
|
||||
|
||||
The cap is a parameter, not the constant it used to be, because the two callers
|
||||
want different ones (B-52): MAX_TX_INPUTS protects a user from a fee that would
|
||||
eat into their own bet/withdrawal, while the payout drains a pool holding one
|
||||
UTXO per bet and needs MAX_PAYOUT_TX_INPUTS to be able to pay a full round at
|
||||
all."""
|
||||
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
|
||||
selected: list[Utxo] = []
|
||||
total = 0
|
||||
for utxo in ordered:
|
||||
if len(selected) == max_inputs:
|
||||
raise InsufficientFundsError(
|
||||
f"balance too fragmented: more than {max_inputs} inputs would be needed",
|
||||
code="too_many_inputs",
|
||||
max_inputs=max_inputs,
|
||||
)
|
||||
selected.append(utxo)
|
||||
total += utxo.amount_sats
|
||||
if total >= target_sats:
|
||||
@@ -67,6 +146,7 @@ def build_signed_transaction(
|
||||
amount_sats: int,
|
||||
change_address: str,
|
||||
fee_rate_sat_vb: int,
|
||||
reduce_amount_to_keep_change: bool = False,
|
||||
) -> BuiltTransaction:
|
||||
"""Build, sign and finalize a single-recipient P2WPKH transaction with change
|
||||
back to change_address.
|
||||
@@ -75,13 +155,52 @@ def build_signed_transaction(
|
||||
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.
|
||||
|
||||
B-62: the transaction always keeps a change output of at least DUST_LIMIT_SATS.
|
||||
Change used to be folded into the fee whenever it came out below the dust limit,
|
||||
which for an amount equal to the whole input total (the UI's "withdraw
|
||||
everything" checkbox, or a bet from a balance exactly equal to the bet amount)
|
||||
produced a single-output transaction — and `tx/broadcast.py:bump_fee` has nothing
|
||||
to shrink there, so it raised RbfError every 30s until the reconciler abandoned
|
||||
the row hours later. Adding inputs instead is no answer for this case in
|
||||
particular: the transaction already spends every UTXO the sender has.
|
||||
|
||||
What happens when the change would be too small depends on who's asking, hence
|
||||
`reduce_amount_to_keep_change`:
|
||||
|
||||
- withdrawals pass True — the amount moved is reduced just enough to leave a
|
||||
dust-limit change output. The fee already comes out of the withdrawn amount by
|
||||
design, so this is the same rule applied a little harder, and the caller
|
||||
records what was actually sent (`Withdrawal.amount_sent_sats`).
|
||||
- bets pass False (the default) and get an InsufficientFundsError instead: the
|
||||
bet is a fixed price that cannot be quietly reduced, and "a user's balance must
|
||||
never exactly equal the bet" is a documented invariant of the PLAY phase. The
|
||||
player needs a little more than the bet amount, which is what the error says.
|
||||
"""
|
||||
selected, total_in = select_utxos(utxos, amount_sats)
|
||||
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
|
||||
|
||||
change = total_in - amount_sats
|
||||
if change < DUST_LIMIT_SATS:
|
||||
if not reduce_amount_to_keep_change:
|
||||
raise InsufficientFundsError(
|
||||
f"the amount leaves no change output: {DUST_LIMIT_SATS - change} more sats are "
|
||||
"needed for the transaction to stay fee-bumpable",
|
||||
code="balance_leaves_no_change",
|
||||
required_extra_sats=DUST_LIMIT_SATS - change,
|
||||
)
|
||||
amount_sats -= DUST_LIMIT_SATS - change
|
||||
change = DUST_LIMIT_SATS
|
||||
|
||||
recipient_amount = amount_sats - fee
|
||||
if recipient_amount <= 0:
|
||||
raise InsufficientFundsError("amount too small to cover the network fee")
|
||||
change = total_in - amount_sats
|
||||
raise InsufficientFundsError(
|
||||
"amount too small to cover the network fee", code="amount_below_network_fee"
|
||||
)
|
||||
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.
|
||||
@@ -139,14 +258,30 @@ def build_payout_transaction(
|
||||
) -> 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."""
|
||||
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).
|
||||
|
||||
Selection uses MAX_PAYOUT_TX_INPUTS, not the much stricter user-facing
|
||||
MAX_TX_INPUTS (B-52) — the pool holds one UTXO per bet, so the user-protection
|
||||
cap made every round past ~50 participants impossible to pay."""
|
||||
target = winner_share_sats + commission_sats
|
||||
selected, total_in = select_utxos(utxos, target)
|
||||
selected, total_in = select_utxos(utxos, target, max_inputs=MAX_PAYOUT_TX_INPUTS)
|
||||
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
|
||||
winner_amount = winner_share_sats - fee
|
||||
if winner_amount <= 0:
|
||||
raise InsufficientFundsError("winner share too small to cover the network 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 = [
|
||||
|
||||
@@ -10,7 +10,9 @@ async def _on_withdrawal_confirmed(session: AsyncSession, pending: PendingTransa
|
||||
if pending.withdrawal_id is None:
|
||||
return
|
||||
withdrawal = await session.get(Withdrawal, pending.withdrawal_id)
|
||||
if withdrawal is not None and withdrawal.status == "broadcast":
|
||||
# "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)
|
||||
|
||||
|
||||
+108
-14
@@ -2,34 +2,77 @@ 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.balance import recompute_balance
|
||||
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 InsufficientFundsError, Utxo, build_signed_transaction
|
||||
from app.wallet.psbt_builder import (
|
||||
BuiltTransaction,
|
||||
InsufficientFundsError,
|
||||
Utxo,
|
||||
build_signed_transaction,
|
||||
)
|
||||
|
||||
|
||||
class WithdrawalError(Exception):
|
||||
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(f"amount below the minimum of {config.bet_amount_sats} 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()
|
||||
if sum(u.amount_sats for u in unspent) < amount_sats:
|
||||
raise WithdrawalError("insufficient balance")
|
||||
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())
|
||||
@@ -44,12 +87,19 @@ async def request_withdrawal(
|
||||
amount_sats=amount_sats,
|
||||
change_address=user.address,
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
# B-62: "withdraw everything" asks for the whole confirmed balance, which
|
||||
# would leave no change output and therefore nothing bump_fee could
|
||||
# shrink — the one tx shape RBF cannot rescue, and the UI's default
|
||||
# withdrawal path at that. Move a dust limit less instead of producing an
|
||||
# unbumpable transaction; amount_sent_sats below records what actually
|
||||
# went out, which is already how a fee-deducted withdrawal is reported.
|
||||
reduce_amount_to_keep_change=True,
|
||||
)
|
||||
except InsufficientFundsError as exc:
|
||||
raise WithdrawalError(str(exc)) from exc
|
||||
|
||||
await client.broadcast(built.raw_hex)
|
||||
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
|
||||
@@ -61,21 +111,32 @@ async def request_withdrawal(
|
||||
amount_requested_sats=amount_sats,
|
||||
amount_sent_sats=built.recipient_sats,
|
||||
txid=built.txid,
|
||||
status="broadcast",
|
||||
status="building",
|
||||
)
|
||||
session.add(withdrawal)
|
||||
await session.flush()
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
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="pending",
|
||||
)
|
||||
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",
|
||||
@@ -87,3 +148,36 @@ async def request_withdrawal(
|
||||
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)
|
||||
|
||||
+35
-3
@@ -36,10 +36,18 @@ 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. |
|
||||
| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: finché è vuoto **non si apre nessun round** (il payout non sarebbe costruibile, quindi il round accetterebbe scommesse per poi restare bloccato con i soldi già nel montepremi). Il pannello lo segnala con un avviso in cima ai Parametri, e la pagina utente mostra un banner "lotteria non ancora pronta". Un round già in corso non viene interrotto se svuoti il campo: chiude, estrae e paga normalmente. |
|
||||
| **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 e cooldown si applicano **dal round successivo**, non a quello già in
|
||||
corso: ogni round si porta dietro i valori con cui è stato aperto, così
|
||||
abbassare la durata mentre un round è a metà non lo chiude di colpo, e alzarla
|
||||
non sposta il countdown che i giocatori stanno già guardando. Gli altri
|
||||
parametri (bet amount, fee rate, RBF timeout) restano invece a effetto
|
||||
immediato.
|
||||
|
||||
| **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. |
|
||||
@@ -108,11 +116,23 @@ 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é. |
|
||||
| `bug_report_status_changed` | Un admin ha cambiato lo stato di una segnalazione (payload: `report_id`, stato precedente e nuovo). Con un token admin unico e condiviso, questa riga è l'unica traccia di chi tocca le segnalazioni. |
|
||||
| `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`), sempre passando `ADMIN_TOKEN`
|
||||
nell'header `X-Admin-Token`:
|
||||
(`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
|
||||
@@ -125,6 +145,18 @@ curl -X PUT https://<host>/admin/config \
|
||||
-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
|
||||
|
||||
@@ -128,6 +128,12 @@ scalata dall'importo richiesto (non si aggiunge separatamente). L'importo
|
||||
minimo prelevabile è pari alla quota fissa di ingresso al round (mostrata
|
||||
nella sezione Bet).
|
||||
|
||||
Con "Preleva l'intero importo" restano sul tuo saldo pochi satoshi (294, cioè
|
||||
0,00000294 PLM): senza quel resto la transazione non potrebbe essere
|
||||
ritrasmessa con una fee più alta se la rete fosse lenta, e resterebbe bloccata
|
||||
per ore. La cifra effettivamente inviata è quindi il saldo meno quei satoshi e
|
||||
meno la fee di rete.
|
||||
|
||||
> **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
|
||||
|
||||
@@ -3,24 +3,14 @@
|
||||
Presuppone che [setup.md](setup.md) sia già stato completato (`.env` pronto,
|
||||
master key generata, migrazioni applicate).
|
||||
|
||||
## Locale / venv (sviluppo rapido)
|
||||
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)).
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
uvicorn app.main:app --reload --port 8123
|
||||
```
|
||||
|
||||
- App su `http://127.0.0.1:8123/`
|
||||
- Pannello admin su `http://127.0.0.1:8123/admin`
|
||||
- Log applicativi in `logs/app.log` (rotante, 10MB × 5 backup)
|
||||
- Nessun TLS, nessun reverse proxy — solo per test locali sulla tua macchina.
|
||||
|
||||
Per fermarlo: `Ctrl+C`, oppure se lanciato in background con `nohup`:
|
||||
```bash
|
||||
pkill -f "uvicorn app.main:app"
|
||||
```
|
||||
|
||||
## Docker + Caddy (consigliato, anche per i test con dominio/TLS)
|
||||
## Docker + Caddy (unico workflow supportato)
|
||||
|
||||
```bash
|
||||
mkdir -p data/db data/keys data/logs # una tantum, se non già presenti
|
||||
|
||||
+28
-1
@@ -11,6 +11,13 @@ poterla avviare (in locale o via Docker). Per come avviarla poi ogni volta, vedi
|
||||
- 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`
|
||||
|
||||
@@ -29,7 +36,27 @@ cp .env.example .env
|
||||
| `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`. Nota: `.env`
|
||||
`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).
|
||||
|
||||
@@ -36,7 +36,7 @@ flowchart LR
|
||||
|
||||
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)"]
|
||||
E1["L'utente richiede un prelievo:\nindirizzo esterno + importo\n(spende solo saldo confermato\ne non ancora impegnato: una scommessa\nin attesa di conferma non lo blocca,\nma le due operazioni non vengono\nmai preparate nello stesso momento)"] --> 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"]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""snapshot round timing onto the round row (B-61)
|
||||
|
||||
Revision ID: 283844a44b4a
|
||||
Revises: c1d4a97b5e10
|
||||
Create Date: 2026-08-03 23:05:04.996492
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '283844a44b4a'
|
||||
down_revision: Union[str, Sequence[str], None] = 'c1d4a97b5e10'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column('rounds', sa.Column('duration_seconds', sa.Integer(), server_default='600', nullable=False))
|
||||
op.add_column('rounds', sa.Column('cooldown_seconds', sa.Integer(), server_default='30', nullable=False))
|
||||
|
||||
# Backfill from the live config rather than leaving the column defaults: an
|
||||
# instance running with, say, a 300s round would otherwise see every existing
|
||||
# row — including the round currently in progress — jump to 600s the moment
|
||||
# this migration lands, which is exactly the retroactive change B-61 is about.
|
||||
op.execute(
|
||||
"UPDATE rounds SET "
|
||||
"duration_seconds = coalesce((SELECT round_duration_seconds FROM round_config LIMIT 1), 600), "
|
||||
"cooldown_seconds = coalesce((SELECT round_cooldown_seconds FROM round_config LIMIT 1), 30)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('rounds', 'cooldown_seconds')
|
||||
op.drop_column('rounds', 'duration_seconds')
|
||||
# ### end Alembic commands ###
|
||||
@@ -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 ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add drawing_started_at to rounds
|
||||
|
||||
Revision ID: 9ef6a51509f7
|
||||
Revises: 943dbd74d983
|
||||
Create Date: 2026-07-27 12:31:09.907682
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9ef6a51509f7'
|
||||
down_revision: Union[str, Sequence[str], None] = '943dbd74d983'
|
||||
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.add_column('rounds', sa.Column('drawing_started_at', sa.DateTime(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('rounds', 'drawing_started_at')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,41 @@
|
||||
"""replace bug_reports.resolved with a three-state status
|
||||
|
||||
Revision ID: be71fdac734e
|
||||
Revises: ee8508d98d34
|
||||
Create Date: 2026-07-31 15:33:51.780062
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'be71fdac734e'
|
||||
down_revision: Union[str, Sequence[str], None] = 'ee8508d98d34'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema, preserving existing rows: resolved=True -> 'resolved', else 'open'.
|
||||
|
||||
'read' has no equivalent in the old boolean, so nothing backfills into it —
|
||||
every previously-open report starts the new lifecycle at 'open', which is
|
||||
correct (nobody had acknowledged it yet)."""
|
||||
op.add_column('bug_reports', sa.Column('status', sa.String(length=16), nullable=True))
|
||||
op.execute("UPDATE bug_reports SET status = CASE WHEN resolved THEN 'resolved' ELSE 'open' END")
|
||||
with op.batch_alter_table('bug_reports') as batch_op:
|
||||
batch_op.alter_column('status', nullable=False)
|
||||
batch_op.drop_column('resolved')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema. 'read' collapses back into resolved=False — the same loss
|
||||
of information any boolean-from-enum downgrade has."""
|
||||
op.add_column('bug_reports', sa.Column('resolved', sa.BOOLEAN(), nullable=True))
|
||||
op.execute("UPDATE bug_reports SET resolved = (status = 'resolved')")
|
||||
with op.batch_alter_table('bug_reports') as batch_op:
|
||||
batch_op.alter_column('resolved', nullable=False)
|
||||
batch_op.drop_column('status')
|
||||
@@ -0,0 +1,46 @@
|
||||
"""case-insensitive usernames (B-57)
|
||||
|
||||
Revision ID: c1d4a97b5e10
|
||||
Revises: be71fdac734e
|
||||
Create Date: 2026-08-03 18:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c1d4a97b5e10'
|
||||
down_revision: Union[str, Sequence[str], None] = 'be71fdac734e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# The index cannot be created while two accounts differ only by case, and
|
||||
# nothing here may guess which of them is the "real" one: both are custodial
|
||||
# accounts that may hold funds, so merging or renaming one automatically would
|
||||
# be the migration silently deciding who owns what. Fail loudly instead, naming
|
||||
# the collisions, and let the operator rename one account (and tell that user)
|
||||
# before retrying. The container runs `alembic upgrade head` at startup, so this
|
||||
# surfaces as a refusal to start rather than as a half-applied schema.
|
||||
collisions = op.get_bind().exec_driver_sql(
|
||||
"SELECT group_concat(username, ', ') FROM users "
|
||||
"GROUP BY lower(username) HAVING count(*) > 1"
|
||||
).fetchall()
|
||||
if collisions:
|
||||
groups = "; ".join(row[0] for row in collisions)
|
||||
raise RuntimeError(
|
||||
"cannot enforce case-insensitive usernames: these accounts differ only "
|
||||
f"by case and must be resolved by hand first — {groups}"
|
||||
)
|
||||
|
||||
op.create_index("ix_users_username_lower", "users", [sa.text("lower(username)")], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index("ix_users_username_lower", table_name="users")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add bug_reports table
|
||||
|
||||
Revision ID: ee8508d98d34
|
||||
Revises: 87a0c640355c
|
||||
Create Date: 2026-07-31 15:14:14.288552
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ee8508d98d34'
|
||||
down_revision: Union[str, Sequence[str], None] = '87a0c640355c'
|
||||
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('bug_reports',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('contact', sa.String(length=256), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('resolved', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('bug_reports')
|
||||
# ### end Alembic commands ###
|
||||
+165
-3
@@ -4,6 +4,11 @@ from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# A real PLM bech32 address: PUT /admin/config now validates fee_address, since a
|
||||
# foreign-chain address there would send every round's commission to a script
|
||||
# nobody can spend (B-05).
|
||||
_VALID_FEE_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch, tmp_path):
|
||||
@@ -62,6 +67,15 @@ async def test_admin_rejects_wrong_token(client):
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_rejects_non_ascii_token_with_403_not_500(client):
|
||||
"""B-46: secrets.compare_digest raises TypeError on a non-ASCII str, which
|
||||
used to bubble up as a 500 instead of the expected 403. httpx encodes str
|
||||
header values as ASCII client-side, so the raw UTF-8 bytes are passed
|
||||
directly to reproduce what a real non-ASCII header on the wire looks like."""
|
||||
resp = await client.get("/admin/config", headers={"X-Admin-Token": "café".encode("utf-8")})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_reads_and_updates_config(client):
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
|
||||
@@ -70,15 +84,15 @@ async def test_admin_reads_and_updates_config(client):
|
||||
assert resp.json()["fee_address"] == ""
|
||||
|
||||
resp = await client.put(
|
||||
"/admin/config", headers=headers, json={"fee_address": "plm1qfeeaddress", "bet_amount_sats": 500_000_000}
|
||||
"/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS, "bet_amount_sats": 500_000_000}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["fee_address"] == "plm1qfeeaddress"
|
||||
assert body["fee_address"] == _VALID_FEE_ADDRESS
|
||||
assert body["bet_amount_sats"] == 500_000_000
|
||||
|
||||
resp = await client.get("/admin/config", headers=headers)
|
||||
assert resp.json()["fee_address"] == "plm1qfeeaddress"
|
||||
assert resp.json()["fee_address"] == _VALID_FEE_ADDRESS
|
||||
|
||||
|
||||
async def test_admin_can_pause_and_resume_the_lottery(client):
|
||||
@@ -200,6 +214,10 @@ async def test_admin_resets_user_password(client):
|
||||
assert refreshed.password_hash != old_hash
|
||||
assert verify_password(new_password, refreshed.password_hash)
|
||||
assert not verify_password("original-password", refreshed.password_hash)
|
||||
# B-34: the reset must bump token_version so a session opened before
|
||||
# the reset (e.g. an attacker who had the old password) is evicted
|
||||
# immediately rather than staying valid until the JWT naturally expires.
|
||||
assert refreshed.token_version == 1
|
||||
|
||||
|
||||
async def test_admin_reset_password_requires_token(client):
|
||||
@@ -211,3 +229,147 @@ async def test_admin_reset_password_404_for_unknown_user(client):
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
resp = await client.post("/admin/users/999/reset-password", headers=headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_config_update_is_audit_logged(client):
|
||||
"""B-10: /pause and /resume were logged but a config change wasn't, so the most
|
||||
sensitive setting in the system — fee_address, where 30% of every pool goes —
|
||||
could be changed without leaving any trace."""
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
|
||||
|
||||
resp = await client.get("/admin/audit-log", headers=headers)
|
||||
entries = [e for e in resp.json() if e["event_type"] == "config_updated"]
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["payload"]["fee_address"] == {"from": "", "to": _VALID_FEE_ADDRESS}
|
||||
|
||||
|
||||
async def test_config_update_without_changes_logs_nothing(client):
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
|
||||
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
|
||||
|
||||
resp = await client.get("/admin/audit-log", headers=headers)
|
||||
assert len([e for e in resp.json() if e["event_type"] == "config_updated"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"fee_address": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"}, # valid bech32, wrong chain
|
||||
{"fee_address": "plm1qbogus"}, # right HRP, broken checksum
|
||||
{"fee_address": "garbage"},
|
||||
{"fee_rate_sat_vb": 0}, # fee-less txs are never relayed: everything would stall
|
||||
{"round_duration_seconds": 0}, # a round that expires the instant it opens
|
||||
{"bet_amount_sats": -1},
|
||||
{"rbf_timeout_seconds": 1},
|
||||
],
|
||||
)
|
||||
async def test_config_rejects_unusable_values(client, payload):
|
||||
"""B-05: every one of these was accepted before. The bc1 case is the worst — it
|
||||
parses as a valid witness program, so each round's commission would be broadcast
|
||||
to a script nobody holds the key for."""
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
resp = await client.put("/admin/config", headers=headers, json=payload)
|
||||
assert resp.status_code == 422
|
||||
|
||||
# and nothing was written
|
||||
current = (await client.get("/admin/config", headers=headers)).json()
|
||||
for field, value in payload.items():
|
||||
assert current[field] != value
|
||||
|
||||
|
||||
async def test_pause_cannot_be_toggled_through_the_config_endpoint(client):
|
||||
"""B-10: `paused` used to be settable here, bypassing the audit-logged
|
||||
pause/resume endpoints."""
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
resp = await client.put("/admin/config", headers=headers, json={"paused": True})
|
||||
assert resp.status_code in (200, 422) # ignored or refused, but never applied
|
||||
assert (await client.get("/admin/config", headers=headers)).json()["paused"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ["/admin/rounds", "/admin/audit-log", "/admin/pending-transactions"])
|
||||
@pytest.mark.parametrize("bad_limit", [0, -1, 501])
|
||||
async def test_admin_list_endpoints_reject_out_of_range_limit(client, endpoint, bad_limit):
|
||||
"""B-45: `limit` had no bounds — `-1` means "everything" on SQLite, so an
|
||||
unvalidated limit could dump the entire table in one response."""
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
resp = await client.get(endpoint, headers=headers, params={"limit": bad_limit})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_admin_list_rounds_respects_limit(client):
|
||||
from app.db import base as db_base
|
||||
from app.db.models import Round
|
||||
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
session.add_all([Round(status="closed") for _ in range(3)])
|
||||
await session.commit()
|
||||
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
resp = await client.get("/admin/rounds", headers=headers, params={"limit": 2})
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
|
||||
async def test_admin_audit_log_respects_limit(client):
|
||||
from app.db import base as db_base
|
||||
from app.audit.log import write_audit_log
|
||||
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
for _ in range(3):
|
||||
await write_audit_log(session, "test_event", {})
|
||||
await session.commit()
|
||||
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
resp = await client.get("/admin/audit-log", headers=headers, params={"limit": 2})
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
|
||||
async def _make_pending_transaction(session, *, kind="bet", status="pending"):
|
||||
from app.db.models import PendingTransaction
|
||||
import secrets as _secrets
|
||||
|
||||
tx = PendingTransaction(
|
||||
kind=kind,
|
||||
current_txid=_secrets.token_hex(32),
|
||||
fee_rate_sat_vb=1,
|
||||
raw_tx_hex="00",
|
||||
status=status,
|
||||
)
|
||||
session.add(tx)
|
||||
return tx
|
||||
|
||||
|
||||
async def test_admin_pending_transactions_respects_limit(client):
|
||||
from app.db import base as db_base
|
||||
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
for _ in range(3):
|
||||
await _make_pending_transaction(session)
|
||||
await session.commit()
|
||||
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
resp = await client.get("/admin/pending-transactions", headers=headers, params={"limit": 2})
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
|
||||
async def test_admin_pending_transactions_status_filter(client):
|
||||
from app.db import base as db_base
|
||||
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
await _make_pending_transaction(session, status="pending")
|
||||
await _make_pending_transaction(session, status="confirmed")
|
||||
await _make_pending_transaction(session, status="failed")
|
||||
await session.commit()
|
||||
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
resp = await client.get(
|
||||
"/admin/pending-transactions", headers=headers, params={"status": "confirmed"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
entries = resp.json()
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["status"] == "confirmed"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.api.timeutil import isoformat_utc
|
||||
|
||||
|
||||
def test_naive_datetime_is_stamped_utc():
|
||||
# SQLite/aiosqlite round-trips DateTime columns as naive even though every
|
||||
# value written is UTC (app.db.models.utcnow) — this is the exact shape
|
||||
# returned by the ORM after a read (B-35).
|
||||
naive = datetime(2026, 7, 27, 6, 56, 47, 489110)
|
||||
result = isoformat_utc(naive)
|
||||
assert result == "2026-07-27T06:56:47.489110+00:00"
|
||||
|
||||
|
||||
def test_aware_datetime_is_left_unchanged():
|
||||
aware = datetime(2026, 7, 27, 6, 56, 47, tzinfo=timezone.utc)
|
||||
assert isoformat_utc(aware) == aware.isoformat()
|
||||
|
||||
|
||||
def test_none_passes_through():
|
||||
assert isoformat_utc(None) is None
|
||||
@@ -0,0 +1,206 @@
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
|
||||
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
|
||||
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
|
||||
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
||||
|
||||
import app.wallet.hd as hd
|
||||
|
||||
hd._account_key = None
|
||||
hd.generate_master_key()
|
||||
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db import base as db_base
|
||||
|
||||
import app.db.models # noqa: F401
|
||||
|
||||
db_base.engine = create_async_engine(settings.database_url)
|
||||
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
|
||||
|
||||
from app.db import session as db_session
|
||||
|
||||
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
|
||||
|
||||
async with db_base.engine.begin() as conn:
|
||||
await conn.run_sync(db_base.Base.metadata.create_all)
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.auth.routes import router as auth_router
|
||||
from app.electrum.listener import ElectrumListener
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router)
|
||||
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
await db_base.engine.dispose()
|
||||
|
||||
|
||||
async def _register(client, username="alice", password="original-password"):
|
||||
resp = await client.post("/auth/register", json={"username": username, "password": password})
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
async def test_login_locks_out_after_repeated_failures(client):
|
||||
await _register(client)
|
||||
|
||||
for _ in range(5):
|
||||
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
|
||||
assert resp.status_code == 429
|
||||
assert resp.json()["detail"]["code"] == "rate_limited"
|
||||
|
||||
# Even the *correct* password is refused while locked out — the throttle
|
||||
# protects against a lucky guess landing inside the backoff window too.
|
||||
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
async def test_unknown_username_and_wrong_password_share_a_bucket_and_response(client):
|
||||
await _register(client, username="bob")
|
||||
|
||||
for _ in range(5):
|
||||
resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["detail"]["code"] == "invalid_credentials"
|
||||
|
||||
resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"})
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
async def test_login_failures_against_one_account_do_not_lock_out_another(client):
|
||||
await _register(client, username="alice")
|
||||
await _register(client, username="carol", password="carols-password")
|
||||
|
||||
for _ in range(6):
|
||||
await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
|
||||
|
||||
# Different username, but same IP (the test client always looks the same) —
|
||||
# only the per-username bucket should be exhausted, not the whole IP, since
|
||||
# the per-username threshold (5) is hit well before the shared IP bucket's.
|
||||
resp = await client.post("/auth/login", json={"username": "carol", "password": "carols-password"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
async def test_successful_login_resets_the_username_bucket(client):
|
||||
await _register(client)
|
||||
|
||||
for _ in range(4):
|
||||
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
async def test_registration_is_quota_limited_per_ip(client): # B-58
|
||||
"""Five accounts per IP per hour. The sixth is told to wait, not punished with a
|
||||
backoff that doubles from there."""
|
||||
for i in range(5):
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": f"user{i}", "password": "a-strong-password"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": "user5", "password": "a-strong-password"}
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
assert resp.json()["detail"]["code"] == "rate_limited"
|
||||
|
||||
|
||||
# --- B-57: usernames are one namespace, case included ---------------------------
|
||||
|
||||
|
||||
async def test_registration_refuses_a_username_differing_only_by_case(client):
|
||||
""""Bob" and "bob" used to be two accounts. On a custodial system that's an
|
||||
impersonation vector — and the two also shared a single rate-limit bucket, since
|
||||
the throttle key has always been lowercased, so each could lock the other out."""
|
||||
await _register(client, username="Bob")
|
||||
|
||||
resp = await client.post("/auth/register", json={"username": "bob", "password": "another-password"})
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["code"] == "username_taken"
|
||||
|
||||
|
||||
async def test_login_accepts_the_username_in_any_case(client):
|
||||
"""The flip side of the same rule: one account, reachable however it's typed."""
|
||||
await _register(client, username="Alice", password="original-password")
|
||||
|
||||
resp = await client.post("/auth/login", json={"username": "ALICE", "password": "original-password"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["access_token"]
|
||||
|
||||
|
||||
async def test_the_database_itself_rejects_a_case_variant(client):
|
||||
"""Not just the pre-check in the handler: two requests racing between the SELECT
|
||||
and the INSERT must still leave only one account, which is what the unique index
|
||||
on lower(username) guarantees."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.db.base import AsyncSessionLocal
|
||||
from app.db.models import User
|
||||
|
||||
await _register(client, username="Carol")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
session.add(
|
||||
User(username="CAROL", password_hash="x", derivation_index=999, address="plm1-unused")
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_failed_registrations_do_not_consume_the_quota(client): # B-58
|
||||
"""The limit is on accounts that exist, not on requests: record_failure used to
|
||||
fire on every attempt, so five signups — successful ones included — locked the
|
||||
sixth real user out for up to 600s from a shared or NAT address. Attempts that
|
||||
create nothing must leave the quota untouched."""
|
||||
await _register(client, username="taken")
|
||||
|
||||
for _ in range(10):
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": "taken", "password": "a-strong-password"}
|
||||
)
|
||||
assert resp.status_code == 409 # username_taken, no account created
|
||||
|
||||
# Four slots left out of five, all still usable.
|
||||
for i in range(4):
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": f"genuine{i}", "password": "a-strong-password"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
async def test_the_quota_reports_how_long_to_wait(client): # B-58
|
||||
for i in range(5):
|
||||
await _register(client, username=f"quotauser{i}", password="a-strong-password")
|
||||
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": "one-too-many", "password": "a-strong-password"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 429
|
||||
retry_after = resp.json()["detail"]["params"]["retry_after_seconds"]
|
||||
assert 0 < retry_after <= 3601 # bounded by the window, not by a growing penalty
|
||||
@@ -5,11 +5,14 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from app.bets.service import place_bet
|
||||
from app.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.models import PendingTransaction, User, UtxoEvent
|
||||
from app.db.models import PendingTransaction, RoundConfig, User, UtxoEvent
|
||||
from app.wallet.balance import compute_pending_balance, recompute_balance
|
||||
from app.wallet.hd import derive_user_address
|
||||
|
||||
|
||||
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
||||
|
||||
|
||||
class FakeElectrumClient:
|
||||
async def broadcast(self, raw_tx_hex: str) -> str:
|
||||
return "fake-network-txid"
|
||||
@@ -31,6 +34,13 @@ async def session_factory(tmp_path, monkeypatch):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# B-66: a round only opens on an instance that could actually pay a winner, so
|
||||
# every test that expects one needs a fee address configured — the column has no
|
||||
# default on purpose (an operator must set their own).
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||
session.add(RoundConfig(fee_address=_FEE_ADDRESS))
|
||||
await session.commit()
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
hd._account_key = None
|
||||
@@ -91,6 +101,54 @@ async def test_pending_balance_matches_confirmed_when_nothing_in_flight(session_
|
||||
assert pending_balance == 2_000_000_000
|
||||
|
||||
|
||||
async def test_pending_balance_does_not_double_count_change_already_credited(session_factory):
|
||||
"""The Electrum listener (event-driven) and the confirmation poller (10s
|
||||
cadence) independently react to the same change output confirming. When the
|
||||
listener wins that race — the common case — the change is already a
|
||||
UtxoEvent inside cached_balance_sats while the PendingTransaction row is
|
||||
still "pending". compute_pending_balance must not add the change a second
|
||||
time in that window."""
|
||||
user_id = await _make_funded_user(session_factory, 4, 1_500_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
await place_bet(session, client, user)
|
||||
|
||||
async with session_factory() as session:
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
from embit.transaction import Transaction
|
||||
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
|
||||
tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex))
|
||||
change_vout, change_out = next(
|
||||
(i, out) for i, out in enumerate(tx.vout) if out.script_pubkey.address(network=PLM_MAINNET) == user.address
|
||||
)
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
# Simulate the listener having already credited the change output as
|
||||
# confirmed, before the poller has flipped `pending.status`.
|
||||
session.add(
|
||||
UtxoEvent(
|
||||
user_id=user_id,
|
||||
txid=pending.current_txid,
|
||||
vout=change_vout,
|
||||
amount_sats=change_out.value,
|
||||
confirmed_height=101,
|
||||
)
|
||||
)
|
||||
await recompute_balance(session, user_id)
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
pending_balance, has_pending = await compute_pending_balance(session, user)
|
||||
|
||||
assert has_pending is True # the PendingTransaction row is still "pending"
|
||||
assert pending_balance == user.cached_balance_sats # already-credited change isn't added again
|
||||
|
||||
|
||||
async def test_pending_balance_ignores_other_users_pending_transactions(session_factory):
|
||||
user_id = await _make_funded_user(session_factory, 2, 2_000_000_000)
|
||||
other_user_id = await _make_funded_user(session_factory, 3, 1_500_000_000)
|
||||
|
||||
+308
-2
@@ -1,15 +1,20 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.bets.service import BetError, place_bet
|
||||
from app.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.service import open_new_round_if_needed
|
||||
from app.wallet.hd import derive_user_address
|
||||
from app.wallet.psbt_builder import MAX_PARTICIPANTS_PER_ROUND, MAX_TX_INPUTS
|
||||
|
||||
|
||||
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
||||
|
||||
|
||||
class FakeElectrumClient:
|
||||
@@ -33,6 +38,13 @@ async def session_factory(tmp_path, monkeypatch):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# B-66: a round only opens on an instance that could actually pay a winner, so
|
||||
# every test that expects one needs a fee address configured — the column has no
|
||||
# default on purpose (an operator must set their own).
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||
session.add(RoundConfig(fee_address=_FEE_ADDRESS))
|
||||
await session.commit()
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
hd._account_key = None
|
||||
@@ -91,6 +103,103 @@ async def test_place_bet_rejects_insufficient_balance(session_factory):
|
||||
await place_bet(session, client, user)
|
||||
|
||||
|
||||
async def test_place_bet_reports_a_too_fragmented_balance_distinctly(session_factory): # B-48
|
||||
# 100 x 0.15 PLM = 15 PLM, plenty for a 10 PLM bet, but the 50 largest inputs
|
||||
# only add up to 7.5 PLM — so the build must fail with its own code, not with
|
||||
# the "you have no funds" one, and must carry the cap for the translation.
|
||||
user_id = await _make_funded_user(session_factory, 20, 15_000_000)
|
||||
async with session_factory() as session:
|
||||
for i in range(99):
|
||||
session.add(
|
||||
UtxoEvent(
|
||||
user_id=user_id,
|
||||
txid=f"{i:064x}",
|
||||
vout=0,
|
||||
amount_sats=15_000_000,
|
||||
confirmed_height=100,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, client, user)
|
||||
|
||||
assert excinfo.value.code == "too_many_inputs"
|
||||
assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS}
|
||||
assert not client.broadcasted
|
||||
|
||||
|
||||
async def _fill_round_with_participants(session_factory, round_id: int, count: int) -> None:
|
||||
"""Participant rows only, no real bets: what the cap counts is rows, and building
|
||||
`count` genuine transactions would just make the test slow without exercising
|
||||
anything the other tests don't already cover."""
|
||||
async with session_factory() as session:
|
||||
for i in range(count):
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=round_id,
|
||||
user_id=10_000 + i, # placeholder ids; the cap check never joins users
|
||||
bet_amount_sats=1_000_000_000,
|
||||
bet_txid=f"{i:064x}",
|
||||
status="confirmed",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_place_bet_rejects_the_bet_past_the_participant_cap(session_factory): # B-52
|
||||
"""The payout has to spend one pool UTXO per bet, so a round is only ever allowed
|
||||
to grow to what a single payout transaction can drain. Enforced here, before the
|
||||
player's money moves — not discovered at payout time, when the bets are already in
|
||||
the pool and the round can no longer be paid at all."""
|
||||
user_id = await _make_funded_user(session_factory, 30, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
round_id = round_.id
|
||||
await _fill_round_with_participants(session_factory, round_id, MAX_PARTICIPANTS_PER_ROUND)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, client, user)
|
||||
|
||||
assert excinfo.value.code == "round_full"
|
||||
assert excinfo.value.params == {"max_participants": MAX_PARTICIPANTS_PER_ROUND}
|
||||
assert not client.broadcasted
|
||||
|
||||
# Refused cleanly: no participant row, and the user's UTXO is still spendable.
|
||||
async with session_factory() as session:
|
||||
assert await session.scalar(
|
||||
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_id)
|
||||
) == MAX_PARTICIPANTS_PER_ROUND
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
assert utxo.spent_txid is None
|
||||
|
||||
|
||||
async def test_place_bet_still_accepts_the_last_slot_under_the_cap(session_factory): # B-52
|
||||
user_id = await _make_funded_user(session_factory, 31, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
round_id = round_.id
|
||||
await _fill_round_with_participants(session_factory, round_id, MAX_PARTICIPANTS_PER_ROUND - 1)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(session, client, user)
|
||||
|
||||
assert participant.status == "broadcast"
|
||||
assert client.broadcasted
|
||||
|
||||
|
||||
async def test_place_bet_rejects_second_bet_same_round(session_factory):
|
||||
user_id = await _make_funded_user(session_factory, 2, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
@@ -118,7 +227,8 @@ async def test_place_bet_rejects_after_timer_expires_even_if_still_open(session_
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address="", round_duration_seconds=60))
|
||||
config = (await session.scalars(select(RoundConfig))).one() # seeded by the fixture
|
||||
config.round_duration_seconds = 60
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
round_.opened_at = datetime.now(timezone.utc) - timedelta(seconds=61)
|
||||
await session.commit()
|
||||
@@ -133,3 +243,199 @@ async def test_place_bet_rejects_after_timer_expires_even_if_still_open(session_
|
||||
assert len(participants) == 0
|
||||
round_ = (await session.scalars(select(Round))).one()
|
||||
assert round_.status == "open" # scheduler hasn't ticked — status is unchanged, only the check is deadline-aware
|
||||
|
||||
|
||||
class RejectingElectrumClient:
|
||||
"""A node that refuses the transaction — fee too low, dust output, mempool
|
||||
conflict, or simply an unreachable server."""
|
||||
|
||||
async def broadcast(self, raw_tx_hex: str) -> str:
|
||||
raise RuntimeError("min relay fee not met")
|
||||
|
||||
|
||||
async def test_failed_broadcast_leaves_nothing_behind(session_factory):
|
||||
"""B-07/B-08: the broadcast used to happen before anything was written, so a
|
||||
rejection left the UTXOs marked spent with no rows to explain it, and the caller
|
||||
got an opaque HTTP 500. Now it's a translatable error and a full rollback."""
|
||||
user_id = await _make_funded_user(session_factory, 4, 3_000_000_000)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(BetError, match="refused"):
|
||||
await place_bet(session, RejectingElectrumClient(), user)
|
||||
|
||||
async with session_factory() as session:
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
assert utxo.spent_txid is None # released, so the user can bet again
|
||||
assert (await session.scalars(select(RoundParticipant))).all() == []
|
||||
assert (await session.scalars(select(PendingTransaction))).all() == []
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 3_000_000_000
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert "bet_broadcast_failed" in events
|
||||
assert "bet_placed" not in events
|
||||
|
||||
|
||||
async def test_failed_broadcast_publishes_an_sse_update(session_factory): # B-49
|
||||
"""The rollback moves as much state as the successful path does, so it must ping
|
||||
the dashboards the same way — otherwise the phantom bet stays on screen until the
|
||||
next poll."""
|
||||
user_id = await _make_funded_user(session_factory, 21, 3_000_000_000)
|
||||
async with session_factory() as session:
|
||||
# Open the round up front: place_bet would otherwise open it itself, and that
|
||||
# publish() would satisfy the assertion below whether or not the rollback ever
|
||||
# published one of its own.
|
||||
await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
|
||||
queue = broadcaster.subscribe()
|
||||
try:
|
||||
while not queue.empty():
|
||||
queue.get_nowait()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(BetError, match="refused"):
|
||||
await place_bet(session, RejectingElectrumClient(), user)
|
||||
|
||||
assert not queue.empty()
|
||||
finally:
|
||||
broadcaster.unsubscribe(queue)
|
||||
|
||||
|
||||
async def test_failed_broadcast_reports_the_broadcast_failed_code(session_factory):
|
||||
user_id = await _make_funded_user(session_factory, 5, 3_000_000_000)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
try:
|
||||
await place_bet(session, RejectingElectrumClient(), user)
|
||||
assert False, "expected BetError"
|
||||
except BetError as exc:
|
||||
assert exc.code == "broadcast_failed"
|
||||
|
||||
|
||||
async def test_bet_is_persisted_before_it_is_broadcast(session_factory):
|
||||
"""The ordering guarantee behind B-08: by the time the network call happens, the
|
||||
rows already exist, so a crash there is recoverable rather than silent."""
|
||||
user_id = await _make_funded_user(session_factory, 6, 3_000_000_000)
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
class ObservingClient:
|
||||
async def broadcast(self, raw_tx_hex: str) -> str:
|
||||
# Read committed state from an independent session, mid-broadcast.
|
||||
async with session_factory() as probe:
|
||||
seen["pending"] = [
|
||||
(p.kind, p.status) for p in (await probe.scalars(select(PendingTransaction))).all()
|
||||
]
|
||||
seen["participants"] = [
|
||||
(p.status) for p in (await probe.scalars(select(RoundParticipant))).all()
|
||||
]
|
||||
return "network-txid"
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
await place_bet(session, ObservingClient(), user)
|
||||
|
||||
assert seen["pending"] == [("bet", "building")]
|
||||
assert seen["participants"] == ["building"]
|
||||
|
||||
|
||||
# --- B-53: a bet must never pay into the pool of a round it was left out of ------
|
||||
|
||||
|
||||
async def _assert_bet_left_no_trace(session_factory, user_id: int, balance_before: int) -> None:
|
||||
async with session_factory() as session:
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
assert utxo.spent_txid is None # nothing reserved, so the user can bet next round
|
||||
assert (await session.scalars(select(RoundParticipant))).all() == []
|
||||
assert (await session.scalars(select(PendingTransaction))).all() == []
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == balance_before # the rollback undid the recompute too
|
||||
|
||||
|
||||
async def test_place_bet_refuses_when_the_round_closed_between_the_check_and_the_commit(
|
||||
session_factory, monkeypatch
|
||||
): # B-53
|
||||
"""The scheduler flips "open" -> "closing" in a transaction of its own and only
|
||||
then counts in-flight bets. A bet whose deadline check passed just before that
|
||||
flip must not be able to commit its participant row afterwards: it would be
|
||||
excluded from the draw (only "confirmed" participants are drawn) while its sats
|
||||
still landed in the pool address — credited to no round, with no refund path.
|
||||
|
||||
round_accepts_bets is forced to pass so the refusal can only come from the
|
||||
compare-and-set on the round row, which is the part that survives the race the
|
||||
wall-clock check cannot see."""
|
||||
user_id = await _make_funded_user(session_factory, 40, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
round_id = round_.id
|
||||
|
||||
monkeypatch.setattr("app.bets.service.round_accepts_bets", lambda *args, **kwargs: True)
|
||||
|
||||
async with session_factory() as session:
|
||||
# What the scheduler's own tick would have committed a moment earlier.
|
||||
(await session.get(Round, round_id)).status = "closing"
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
balance_before = user.cached_balance_sats
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, client, user)
|
||||
|
||||
assert excinfo.value.code == "round_closing"
|
||||
assert not client.broadcasted # refused before any money moved
|
||||
await _assert_bet_left_no_trace(session_factory, user_id, balance_before)
|
||||
|
||||
|
||||
async def test_place_bet_rechecks_the_deadline_after_building_the_transaction(
|
||||
session_factory, monkeypatch
|
||||
): # B-53
|
||||
"""The first deadline check happens before the UTXO scan and the signing, so a
|
||||
slow build could carry a bet past the round's deadline. It is re-checked against
|
||||
the clock as it is at commit time."""
|
||||
user_id = await _make_funded_user(session_factory, 41, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
|
||||
checks: list[bool] = []
|
||||
|
||||
def _accepts_then_expires(*args, **kwargs) -> bool:
|
||||
checks.append(True)
|
||||
return len(checks) == 1 # open when the bet arrived, expired by the time it was built
|
||||
|
||||
monkeypatch.setattr("app.bets.service.round_accepts_bets", _accepts_then_expires)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
balance_before = user.cached_balance_sats
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, client, user)
|
||||
|
||||
assert len(checks) == 2 # the re-check really ran
|
||||
assert excinfo.value.code == "round_closing"
|
||||
assert not client.broadcasted
|
||||
await _assert_bet_left_no_trace(session_factory, user_id, balance_before)
|
||||
|
||||
|
||||
async def test_place_bet_still_succeeds_while_the_round_is_open(session_factory): # B-53
|
||||
"""The guard must not refuse the normal path: an open, in-time round still takes
|
||||
bets, and the round's status is left untouched by the compare-and-set."""
|
||||
user_id = await _make_funded_user(session_factory, 42, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(session, client, user)
|
||||
|
||||
assert participant.status == "broadcast"
|
||||
async with session_factory() as session:
|
||||
round_ = (await session.scalars(select(Round))).one()
|
||||
assert round_.status == "open"
|
||||
|
||||
+402
-13
@@ -1,9 +1,10 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from embit import script
|
||||
from embit.bip32 import HDKey
|
||||
from embit.transaction import Transaction
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
@@ -11,7 +12,7 @@ from app.db.base import Base
|
||||
from app.db.models import PendingTransaction, User
|
||||
from app.tx.broadcast import RbfError, bump_fee, should_bump
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import Utxo, build_signed_transaction
|
||||
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, Utxo, build_signed_transaction, estimate_vsize
|
||||
|
||||
|
||||
def _key(seed_byte: int) -> HDKey:
|
||||
@@ -22,7 +23,7 @@ def _key(seed_byte: int) -> HDKey:
|
||||
def test_should_bump_false_before_timeout():
|
||||
pending = PendingTransaction(
|
||||
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
|
||||
broadcast_at=datetime.now(timezone.utc),
|
||||
broadcast_at=datetime.now(timezone.utc), last_broadcast_at=datetime.now(timezone.utc),
|
||||
)
|
||||
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
|
||||
|
||||
@@ -31,6 +32,7 @@ def test_should_bump_true_after_timeout():
|
||||
pending = PendingTransaction(
|
||||
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
)
|
||||
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is True
|
||||
|
||||
@@ -39,17 +41,41 @@ def test_should_bump_false_when_not_pending():
|
||||
pending = PendingTransaction(
|
||||
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="confirmed",
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
)
|
||||
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
|
||||
|
||||
|
||||
def test_should_bump_measures_from_last_broadcast_not_first(monkeypatch):
|
||||
"""B-27 regression: a tx first broadcast long ago, but bumped recently, must not
|
||||
be due for another bump yet — should_bump has to look at last_broadcast_at, not
|
||||
the original broadcast_at, or every tick would try to re-bump it."""
|
||||
pending = PendingTransaction(
|
||||
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=10_000),
|
||||
last_broadcast_at=datetime.now(timezone.utc),
|
||||
)
|
||||
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""B-40: _prevout_amount now asks for the raw (non-verbose) transaction and
|
||||
reads its output value as an integer via embit, rather than a verbose reply's
|
||||
float "value" field — so this fake must hand back a real, parseable raw tx
|
||||
whose vout[0] carries the requested amount (every test here spends vout 0 of
|
||||
its fixture UTXO)."""
|
||||
|
||||
def __init__(self, prevout_values: dict[str, int]):
|
||||
self._prevout_values = prevout_values
|
||||
self.broadcasted: list[str] = []
|
||||
|
||||
async def get_transaction(self, txid: str, verbose: bool = False) -> dict:
|
||||
return {"vout": {0: {"value": self._prevout_values[txid] / 100_000_000}}}
|
||||
async def get_transaction(self, txid: str, verbose: bool = False) -> str:
|
||||
assert verbose is False
|
||||
fake_prevout_tx = Transaction(
|
||||
vin=[TransactionInput(b"\x00" * 32, 0)],
|
||||
vout=[TransactionOutput(self._prevout_values[txid], script.Script(b"\x00\x14" + b"\x00" * 20))],
|
||||
)
|
||||
return fake_prevout_tx.serialize().hex()
|
||||
|
||||
async def broadcast(self, raw_tx_hex: str) -> str:
|
||||
self.broadcasted.append(raw_tx_hex)
|
||||
@@ -116,9 +142,7 @@ async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory):
|
||||
|
||||
client = FakeClient({utxo_txid: utxo_amount})
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
new_txid = await bump_fee(session, client, row)
|
||||
new_txid = await bump_fee(session_factory, client, pending_id)
|
||||
|
||||
assert client.broadcasted
|
||||
assert new_txid != built.txid
|
||||
@@ -136,16 +160,21 @@ async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory):
|
||||
assert row.attempt_count == 2
|
||||
|
||||
|
||||
async def test_bump_fee_raises_when_no_change_output(session_factory):
|
||||
async def test_bump_fee_leaves_broadcast_at_untouched(session_factory):
|
||||
"""B-27 regression: bump_fee must only ever update last_broadcast_at. Before
|
||||
this, it overwrote broadcast_at on every bump — the same field
|
||||
tx/reconcile.py's abandon-after-N-hours grace period measures from — so a
|
||||
repeatedly-bumped-but-never-mined tx reset that clock forever and was never
|
||||
abandoned."""
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
|
||||
signer = derive_user_key(0)
|
||||
my_address = derive_user_address(0)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
to_address = script.p2wpkh(_key(98).to_public()).address(network=PLM_MAINNET)
|
||||
to_address = script.p2wpkh(_key(97).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
utxo_amount = 10_000_000 # exact amount, no change output
|
||||
utxo_txid = "22" * 32
|
||||
utxo_amount = 150_000_000
|
||||
utxo_txid = "33" * 32
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
@@ -156,6 +185,59 @@ async def test_bump_fee_raises_when_no_change_output(session_factory):
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
original_broadcast_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
async with session_factory() as session:
|
||||
user = User(username="carol", password_hash="x", derivation_index=0, address=my_address)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
pending = PendingTransaction(
|
||||
kind="bet",
|
||||
user_id=user.id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=1,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
status="pending",
|
||||
broadcast_at=original_broadcast_at,
|
||||
last_broadcast_at=original_broadcast_at,
|
||||
)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
pending_id = pending.id
|
||||
|
||||
client = FakeClient({utxo_txid: utxo_amount})
|
||||
before_bump = datetime.now(timezone.utc)
|
||||
|
||||
await bump_fee(session_factory, client, pending_id)
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
assert row.broadcast_at.replace(tzinfo=timezone.utc) == original_broadcast_at
|
||||
assert row.last_broadcast_at.replace(tzinfo=timezone.utc) >= before_bump
|
||||
|
||||
|
||||
async def test_bump_fee_raises_when_no_change_output(session_factory):
|
||||
"""The guard still matters after B-62 even though the builder no longer produces
|
||||
this shape: a single-output transaction broadcast before that change can still be
|
||||
sitting in `pending` across the deploy, and it must fail loudly rather than
|
||||
silently shrink the recipient's output. Hence a hand-built tx here — the point is
|
||||
exactly that build_signed_transaction won't make one any more."""
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
from app.wallet.psbt_builder import RBF_SEQUENCE
|
||||
|
||||
signer = derive_user_key(0)
|
||||
my_address = derive_user_address(0)
|
||||
to_address = script.p2wpkh(_key(98).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
utxo_amount = 10_000_000 # entirely consumed by the single recipient output
|
||||
utxo_txid = "22" * 32
|
||||
legacy_tx = Transaction(
|
||||
vin=[TransactionInput(bytes.fromhex(utxo_txid), 0, sequence=RBF_SEQUENCE)],
|
||||
vout=[TransactionOutput(utxo_amount - 141, script.Script.from_address(to_address))],
|
||||
)
|
||||
built = SimpleNamespace(raw_hex=legacy_tx.serialize().hex(), txid=legacy_tx.txid().hex())
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(username="bob", password_hash="x", derivation_index=0, address=my_address)
|
||||
session.add(user)
|
||||
@@ -175,7 +257,314 @@ async def test_bump_fee_raises_when_no_change_output(session_factory):
|
||||
|
||||
client = FakeClient({utxo_txid: utxo_amount})
|
||||
|
||||
with pytest.raises(RbfError):
|
||||
await bump_fee(session_factory, client, pending_id)
|
||||
|
||||
|
||||
async def test_bump_fee_retargets_every_stored_txid(session_factory):
|
||||
"""B-02/B-20: a bump changes the txid, and everything that recorded the old one
|
||||
has to follow — the participant's bet_txid (whose staleness used to wedge the
|
||||
round forever), the UTXO's spent_txid (which the reconciler matches on), and
|
||||
replaced_by_txid, which was never written at all."""
|
||||
from app.db.models import Round, RoundParticipant, UtxoEvent
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
|
||||
signer = derive_user_key(0)
|
||||
my_address = derive_user_address(0)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
to_address = script.p2wpkh(_key(98).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
utxo_amount = 150_000_000
|
||||
utxo_txid = "22" * 32
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
||||
to_address=to_address,
|
||||
amount_sats=10_000_000,
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(username="bob", password_hash="x", derivation_index=0, address=my_address)
|
||||
session.add(user)
|
||||
session.add(Round(id=1, status="open"))
|
||||
await session.flush()
|
||||
session.add(
|
||||
UtxoEvent(
|
||||
user_id=user.id, txid=utxo_txid, vout=0, amount_sats=utxo_amount,
|
||||
confirmed_height=5, spent_txid=built.txid,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=1, user_id=user.id, bet_amount_sats=built.recipient_sats,
|
||||
bet_txid=built.txid, status="broadcast",
|
||||
)
|
||||
)
|
||||
pending = PendingTransaction(
|
||||
kind="bet", round_id=1, user_id=user.id, current_txid=built.txid, fee_rate_sat_vb=1,
|
||||
raw_tx_hex=built.raw_hex, status="pending",
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
pending_id = pending.id
|
||||
|
||||
new_txid = await bump_fee(session_factory, FakeClient({utxo_txid: utxo_amount}), pending_id)
|
||||
|
||||
async with session_factory() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
assert row.current_txid == new_txid
|
||||
assert row.replaced_by_txid == built.txid # points backwards at what it replaced
|
||||
|
||||
participant = (await session.scalars(select(RoundParticipant))).one()
|
||||
assert participant.bet_txid == new_txid
|
||||
|
||||
utxo = (await session.scalars(select(UtxoEvent))).one()
|
||||
assert utxo.spent_txid == new_txid
|
||||
|
||||
|
||||
# --- B-32: the bump delta must always meet BIP125's relay-mandated minimum, and
|
||||
# escalation must stop at a ceiling instead of retrying forever. ------------------
|
||||
|
||||
|
||||
async def test_bump_fee_meets_bip125_minimum_when_old_fee_already_exceeds_target(session_factory):
|
||||
"""old_fee (as bump_fee computes it from the actual prevout amounts) can end
|
||||
up higher than vsize * target_fee_rate — e.g. because dust change was folded
|
||||
into the original fee (wallet/psbt_builder.py's DUST_LIMIT_SATS handling).
|
||||
The naive `target_fee - old_fee` goes negative in that case; the previous
|
||||
fallback was a flat 1-satoshi total bump, nowhere near BIP125 rule 4's
|
||||
required minimum, so the node rejected it every time and — since bump_fee
|
||||
raised before touching `pending` — the next tick retried identically every
|
||||
30 seconds, forever. Simulated here by reporting a prevout inflated beyond
|
||||
what was actually spent, which has the same effect on old_fee as dust
|
||||
absorption would."""
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
|
||||
signer = derive_user_key(0)
|
||||
my_address = derive_user_address(0)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
to_address = script.p2wpkh(_key(96).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
utxo_amount = 150_000_000
|
||||
utxo_txid = "55" * 32
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
||||
to_address=to_address,
|
||||
amount_sats=10_000_000,
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(username="dave", password_hash="x", derivation_index=0, address=my_address)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
pending = PendingTransaction(
|
||||
kind="bet",
|
||||
user_id=user.id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=1,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
status="pending",
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
pending_id = pending.id
|
||||
|
||||
# Reports a prevout inflated well beyond what was actually spent — has the
|
||||
# same effect on old_fee as dust absorption would have: old_fee ends up far
|
||||
# above vsize * target_fee_rate (target_fee_rate = 2 here).
|
||||
inflated_excess = 50_000
|
||||
client = FakeClient({utxo_txid: utxo_amount + inflated_excess})
|
||||
|
||||
new_txid = await bump_fee(session_factory, client, pending_id)
|
||||
|
||||
assert client.broadcasted
|
||||
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
|
||||
old_tx = Transaction.parse(bytes.fromhex(built.raw_hex))
|
||||
old_change = next(o.value for o in old_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
|
||||
new_change = next(o.value for o in new_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
|
||||
|
||||
vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout))
|
||||
min_valid_delta = vsize * 1 # BIP125 rule 4's floor at a 1 sat/vB incremental relay fee
|
||||
assert min_valid_delta > 1 # meaningfully more than the old flat "1 satoshi" fallback
|
||||
assert old_change - new_change == min_valid_delta
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
old_fee_as_bump_fee_computed_it = built.fee_sats + inflated_excess
|
||||
expected_rate = (old_fee_as_bump_fee_computed_it + min_valid_delta) // vsize
|
||||
assert row.fee_rate_sat_vb == expected_rate
|
||||
assert row.fee_rate_sat_vb > 2 # the actual rate, not the naive (and too-low) target
|
||||
|
||||
|
||||
async def test_bump_fee_refuses_once_at_the_max_fee_rate(session_factory):
|
||||
"""Without a ceiling, a stuck transaction's fee rate climbed by 1 sat/vB every
|
||||
30 seconds forever, eating further and further into the user's change."""
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
|
||||
signer = derive_user_key(0)
|
||||
my_address = derive_user_address(0)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
to_address = script.p2wpkh(_key(95).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
utxo_amount = 150_000_000
|
||||
utxo_txid = "66" * 32
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
||||
to_address=to_address,
|
||||
amount_sats=10_000_000,
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(username="erin", password_hash="x", derivation_index=0, address=my_address)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
pending = PendingTransaction(
|
||||
kind="bet",
|
||||
user_id=user.id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=MAX_FEE_RATE_SAT_VB,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
status="pending",
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
pending_id = pending.id
|
||||
|
||||
client = FakeClient({utxo_txid: utxo_amount})
|
||||
|
||||
with pytest.raises(RbfError):
|
||||
await bump_fee(session, client, row)
|
||||
await bump_fee(session_factory, client, pending_id)
|
||||
|
||||
assert not client.broadcasted
|
||||
|
||||
|
||||
# --- B-40: bump_fee must not hold a DB session open across its network calls,
|
||||
# and a row that's no longer pending by the time it runs is a quiet no-op. -------
|
||||
|
||||
|
||||
async def test_bump_fee_holds_no_session_open_during_network_calls(session_factory):
|
||||
"""The get_transaction-per-input reads and the broadcast must happen with no
|
||||
DB session held open — the same shape used elsewhere for this reason (B-18,
|
||||
electrum/listener.py's refresh_user for B-31) — otherwise a session sits
|
||||
idle in the pool for the whole duration of what can be several slow network
|
||||
round-trips."""
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
|
||||
signer = derive_user_key(0)
|
||||
my_address = derive_user_address(0)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
to_address = script.p2wpkh(_key(94).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
utxo_amount = 150_000_000
|
||||
utxo_txid = "77" * 32
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
||||
to_address=to_address,
|
||||
amount_sats=10_000_000,
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(username="frank", password_hash="x", derivation_index=0, address=my_address)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
pending = PendingTransaction(
|
||||
kind="bet",
|
||||
user_id=user.id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=1,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
status="pending",
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
pending_id = pending.id
|
||||
|
||||
open_count = {"n": 0}
|
||||
|
||||
class _TrackedSession:
|
||||
def __init__(self, inner):
|
||||
self._inner = inner
|
||||
|
||||
async def __aenter__(self):
|
||||
result = await self._inner.__aenter__()
|
||||
open_count["n"] += 1
|
||||
return result
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
open_count["n"] -= 1
|
||||
return await self._inner.__aexit__(*exc)
|
||||
|
||||
def tracking_session_factory():
|
||||
return _TrackedSession(session_factory())
|
||||
|
||||
class TrackingClient(FakeClient):
|
||||
async def get_transaction(self, txid, verbose=False):
|
||||
assert open_count["n"] == 0, "a session was held open during a network call"
|
||||
return await super().get_transaction(txid, verbose)
|
||||
|
||||
async def broadcast(self, raw_tx_hex):
|
||||
assert open_count["n"] == 0, "a session was held open during the broadcast"
|
||||
return await super().broadcast(raw_tx_hex)
|
||||
|
||||
client = TrackingClient({utxo_txid: utxo_amount})
|
||||
await bump_fee(tracking_session_factory, client, pending_id)
|
||||
|
||||
assert client.broadcasted
|
||||
assert open_count["n"] == 0 # nothing left open afterwards either
|
||||
|
||||
|
||||
async def test_bump_fee_is_a_noop_when_no_longer_pending(session_factory):
|
||||
"""A row can legitimately confirm (or otherwise leave "pending") between
|
||||
being read as due and RbfBumper actually attempting the bump — a normal
|
||||
race, not an error. Must return quietly rather than raising or touching
|
||||
the network."""
|
||||
async with session_factory() as session:
|
||||
user = User(username="grace", password_hash="x", derivation_index=0, address="plm1qxxx")
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
pending = PendingTransaction(
|
||||
kind="bet",
|
||||
user_id=user.id,
|
||||
current_txid="already-confirmed-txid",
|
||||
fee_rate_sat_vb=1,
|
||||
raw_tx_hex="00",
|
||||
status="confirmed",
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
|
||||
)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
pending_id = pending.id
|
||||
|
||||
client = FakeClient({})
|
||||
|
||||
result = await bump_fee(session_factory, client, pending_id)
|
||||
|
||||
assert result is None
|
||||
assert not client.broadcasted
|
||||
|
||||
|
||||
async def test_bump_fee_is_a_noop_when_the_row_is_gone(session_factory):
|
||||
client = FakeClient({})
|
||||
result = await bump_fee(session_factory, client, 999_999)
|
||||
assert result is None
|
||||
assert not client.broadcasted
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
|
||||
monkeypatch.setattr(settings, "admin_token", "test-admin-token")
|
||||
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
|
||||
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
|
||||
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
||||
|
||||
import app.wallet.hd as hd
|
||||
|
||||
hd._account_key = None
|
||||
hd.generate_master_key()
|
||||
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db import base as db_base
|
||||
|
||||
import app.db.models # noqa: F401
|
||||
|
||||
db_base.engine = create_async_engine(settings.database_url)
|
||||
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
|
||||
|
||||
from app.db import session as db_session
|
||||
|
||||
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
|
||||
|
||||
async with db_base.engine.begin() as conn:
|
||||
await conn.run_sync(db_base.Base.metadata.create_all)
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.routes.admin import router as admin_router
|
||||
from app.api.routes.bug_reports import router as bug_reports_router
|
||||
from app.auth.routes import router as auth_router
|
||||
from app.electrum.listener import ElectrumListener
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router)
|
||||
app.include_router(bug_reports_router)
|
||||
app.include_router(admin_router)
|
||||
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
await db_base.engine.dispose()
|
||||
|
||||
|
||||
_ADMIN_HEADERS = {"X-Admin-Token": "test-admin-token"}
|
||||
|
||||
|
||||
async def _register(client, username="alice", password="original-password"):
|
||||
resp = await client.post("/auth/register", json={"username": username, "password": password})
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
async def test_anonymous_bug_report_has_no_user(client):
|
||||
resp = await client.post("/bug-reports", json={"description": "the bet button does nothing"})
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = await client.get("/admin/bug-reports", headers=_ADMIN_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
reports = resp.json()
|
||||
assert len(reports) == 1
|
||||
assert reports[0]["description"] == "the bet button does nothing"
|
||||
assert reports[0]["user_id"] is None
|
||||
assert reports[0]["username"] is None
|
||||
assert reports[0]["status"] == "open"
|
||||
|
||||
|
||||
async def test_logged_in_bug_report_is_attributed_to_the_user(client):
|
||||
token = await _register(client)
|
||||
|
||||
resp = await client.post(
|
||||
"/bug-reports",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"description": "withdrawal amount looks wrong", "contact": "alice@example.com"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = await client.get("/admin/bug-reports", headers=_ADMIN_HEADERS)
|
||||
reports = resp.json()
|
||||
assert reports[0]["username"] == "alice"
|
||||
assert reports[0]["contact"] == "alice@example.com"
|
||||
|
||||
|
||||
async def test_user_can_see_own_report_status(client):
|
||||
token = await _register(client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
resp = await client.post("/bug-reports", headers=headers, json={"description": "some bug"})
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
resp = await client.get("/bug-reports/mine", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
reports = resp.json()
|
||||
assert len(reports) == 1
|
||||
assert reports[0]["id"] == report_id
|
||||
assert reports[0]["status"] == "open"
|
||||
|
||||
await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||
)
|
||||
resp = await client.get("/bug-reports/mine", headers=headers)
|
||||
assert resp.json()[0]["status"] == "read"
|
||||
|
||||
|
||||
async def test_bug_reports_mine_requires_auth(client):
|
||||
resp = await client.get("/bug-reports/mine")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_bug_reports_mine_only_returns_own_reports(client):
|
||||
alice_token = await _register(client, username="alice")
|
||||
bob_token = await _register(client, username="bob", password="bob-password")
|
||||
|
||||
await client.post(
|
||||
"/bug-reports", headers={"Authorization": f"Bearer {alice_token}"}, json={"description": "alice's bug"}
|
||||
)
|
||||
|
||||
resp = await client.get("/bug-reports/mine", headers={"Authorization": f"Bearer {bob_token}"})
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
async def test_blank_description_is_rejected(client):
|
||||
resp = await client.post("/bug-reports", json={"description": " "})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_admin_bug_reports_requires_token(client):
|
||||
resp = await client.get("/admin/bug-reports")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_can_move_through_open_read_resolved(client):
|
||||
resp = await client.post("/bug-reports", json={"description": "some bug"})
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "read"
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "resolved"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "resolved"
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "open"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "open"
|
||||
|
||||
|
||||
async def test_update_status_rejects_unknown_value(client):
|
||||
resp = await client.post("/bug-reports", json={"description": "some bug"})
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "bogus"}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_update_status_unknown_report_is_404(client):
|
||||
resp = await client.post(
|
||||
"/admin/bug-reports/999/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- B-60: the status change is an admin mutation, so it leaves a trace ----------
|
||||
|
||||
|
||||
async def _audit_entries(event_type: str) -> list:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import AsyncSessionLocal
|
||||
from app.db.models import AuditLog
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
return (
|
||||
await session.scalars(select(AuditLog).where(AuditLog.event_type == event_type))
|
||||
).all()
|
||||
|
||||
|
||||
async def test_status_change_is_audit_logged(client): # B-60
|
||||
"""One shared ADMIN_TOKEN and no per-admin identity means the audit log is the
|
||||
only accountability there is — a report could be silently marked resolved."""
|
||||
token = await _register(client, username="reporter")
|
||||
resp = await client.post(
|
||||
"/bug-reports",
|
||||
json={"description": "some bug"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "resolved"}
|
||||
)
|
||||
|
||||
entries = await _audit_entries("bug_report_status_changed")
|
||||
assert len(entries) == 1
|
||||
import json
|
||||
|
||||
payload = json.loads(entries[0].payload_json)
|
||||
assert payload == {"report_id": report_id, "from": "open", "to": "resolved"}
|
||||
assert entries[0].user_id is not None # the report's author, so it's traceable both ways
|
||||
|
||||
|
||||
async def test_setting_the_status_it_already_has_logs_nothing(client): # B-60
|
||||
"""Same rule as config_updated: an edit that changes nothing isn't an event, or
|
||||
the log fills with noise that hides the real changes."""
|
||||
resp = await client.post("/bug-reports", json={"description": "some bug"})
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
for _ in range(3):
|
||||
await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "open"}
|
||||
)
|
||||
|
||||
assert await _audit_entries("bug_report_status_changed") == []
|
||||
@@ -0,0 +1,44 @@
|
||||
"""B-43: the Caddyfile must keep sending baseline security headers. Caddy adds
|
||||
none of these on its own, and the JWT lives in localStorage, so a regression
|
||||
here silently reopens an XSS/clickjacking exposure with no test ever failing
|
||||
in the Python suite (the Caddyfile isn't imported/exercised by anything else)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
CADDYFILE = (Path(__file__).parent.parent.parent / "Caddyfile").read_text()
|
||||
|
||||
|
||||
def test_header_block_present():
|
||||
assert "header {" in CADDYFILE
|
||||
|
||||
|
||||
def test_hsts_is_set():
|
||||
assert "Strict-Transport-Security" in CADDYFILE
|
||||
assert "max-age=" in CADDYFILE
|
||||
|
||||
|
||||
def test_nosniff_is_set():
|
||||
assert 'X-Content-Type-Options "nosniff"' in CADDYFILE
|
||||
|
||||
|
||||
def test_frame_ancestors_are_blocked():
|
||||
assert 'X-Frame-Options "DENY"' in CADDYFILE
|
||||
assert "frame-ancestors 'none'" in CADDYFILE
|
||||
|
||||
|
||||
def test_referrer_policy_is_set():
|
||||
assert "Referrer-Policy" in CADDYFILE
|
||||
|
||||
|
||||
def test_csp_default_src_is_self():
|
||||
assert "Content-Security-Policy" in CADDYFILE
|
||||
assert "default-src 'self'" in CADDYFILE
|
||||
|
||||
|
||||
def test_forwarded_for_is_overwritten_with_the_real_peer(): # B-54
|
||||
"""Caddy appends to a client-supplied X-Forwarded-For instead of replacing it,
|
||||
so without this directive the header's first element is whatever the caller
|
||||
claimed. app/api/client_ip.py reads the last hop and so holds on its own, but
|
||||
this is what makes the header itself trustworthy — losing it silently weakens
|
||||
every IP-keyed control (B-33's throttles, B-38's SSE cap)."""
|
||||
assert "header_up X-Forwarded-For {remote_host}" in CADDYFILE
|
||||
@@ -0,0 +1,65 @@
|
||||
"""app.api.client_ip is shared by the login/registration throttles (B-33) and
|
||||
the SSE per-IP subscriber cap (B-38) — both depend on it correctly preferring
|
||||
X-Forwarded-For (Caddy reverse-proxies every request, see Caddyfile) over
|
||||
request.client.host, which would otherwise be the proxy's own address.
|
||||
|
||||
Which *element* of that header it reads is a security property, not a detail:
|
||||
B-54 below is the whole reason all three controls hold at all."""
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.api.client_ip import client_ip
|
||||
|
||||
|
||||
def _request(*, forwarded: str | None = None, client_host: str | None = "127.0.0.1") -> Request:
|
||||
headers = [(b"x-forwarded-for", forwarded.encode())] if forwarded else []
|
||||
scope = {
|
||||
"type": "http",
|
||||
"headers": headers,
|
||||
"client": (client_host, 12345) if client_host else None,
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
|
||||
def test_client_ip_prefers_x_forwarded_for():
|
||||
request = _request(forwarded="5.6.7.8", client_host="10.0.0.1")
|
||||
assert client_ip(request) == "5.6.7.8"
|
||||
|
||||
|
||||
def test_client_ip_takes_the_last_hop_of_a_forwarded_chain(): # B-54
|
||||
"""The hop closest to us — the one our own proxy appended. Exactly one trusted
|
||||
proxy sits in front of the app, so this is the real peer."""
|
||||
request = _request(forwarded="5.6.7.8, 10.0.0.1, 172.17.0.1")
|
||||
assert client_ip(request) == "172.17.0.1"
|
||||
|
||||
|
||||
def test_client_ip_ignores_a_client_supplied_prefix(): # B-54
|
||||
"""Caddy *appends* to whatever the client sent, so the front of the header is
|
||||
attacker-controlled. Reading it from the front let anyone mint a fresh identity
|
||||
per request and walk straight through the login/registration throttles (B-33)
|
||||
and the SSE per-IP subscriber cap (B-38). Two requests spoofing different
|
||||
values must still key to the same real IP."""
|
||||
first = _request(forwarded="1.1.1.1, 203.0.113.9")
|
||||
second = _request(forwarded="2.2.2.2, 203.0.113.9")
|
||||
assert client_ip(first) == client_ip(second) == "203.0.113.9"
|
||||
|
||||
|
||||
def test_client_ip_strips_whitespace():
|
||||
request = _request(forwarded=" 5.6.7.8 , 10.0.0.1 ")
|
||||
assert client_ip(request) == "10.0.0.1"
|
||||
|
||||
|
||||
def test_client_ip_falls_back_when_the_header_is_empty():
|
||||
"""An empty or comma-only header used to yield "" — a single shared bucket every
|
||||
caller lands in, which is its own throttle-evasion trick."""
|
||||
assert client_ip(_request(forwarded=" , ", client_host="10.0.0.1")) == "10.0.0.1"
|
||||
|
||||
|
||||
def test_client_ip_falls_back_to_request_client_without_the_header():
|
||||
request = _request(forwarded=None, client_host="10.0.0.1")
|
||||
assert client_ip(request) == "10.0.0.1"
|
||||
|
||||
|
||||
def test_client_ip_falls_back_to_unknown_with_neither():
|
||||
request = _request(forwarded=None, client_host=None)
|
||||
assert client_ip(request) == "unknown"
|
||||
@@ -0,0 +1,47 @@
|
||||
"""B-69: in-code comments that describe the rest of the system must stay true.
|
||||
|
||||
Three had rotted: the reconciler still called the payout retry "a future
|
||||
payout-retry routine — still an open gap" long after B-26 shipped it,
|
||||
app/db/base.py sized the SQLite busy timeout against "five" background tasks
|
||||
when there are six, and app/auth/routes.py cited the wrong B-nn. A comment is
|
||||
invisible to every other test in the suite, so the claims are pinned here.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _read(relative: str) -> str:
|
||||
return (_ROOT / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_background_task_count_in_the_busy_timeout_comment_is_right():
|
||||
started = len(re.findall(r"asyncio\.create_task\(", _read("app/main.py")))
|
||||
assert started == 6, "the lifespan's task count changed — update app/db/base.py's comment"
|
||||
|
||||
comment = _read("app/db/base.py").split("_SQLITE_BUSY_TIMEOUT_MS", 1)[0]
|
||||
match = re.search(r"(\w+) concurrent background tasks", comment)
|
||||
assert match, "app/db/base.py no longer explains what the busy timeout is sized for"
|
||||
words = {"four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8}
|
||||
assert words.get(match.group(1)) == started
|
||||
|
||||
|
||||
def test_the_reconciler_does_not_call_the_payout_retry_an_open_gap():
|
||||
source = _read("app/tx/reconcile.py")
|
||||
assert "still an open gap" not in source
|
||||
# It exists (B-26) and is what actually recovers an abandoned payout, so the
|
||||
# comment must point at it rather than at an operator.
|
||||
assert "B-26" in source
|
||||
|
||||
|
||||
def test_the_payout_retry_the_comment_points_at_still_exists():
|
||||
assert "_retry_payout_if_due" in _read("app/rounds/scheduler.py")
|
||||
|
||||
|
||||
def test_the_login_throttle_comment_cites_its_own_finding():
|
||||
limiters = _read("app/auth/routes.py").split("_rate_limiters", 1)[1][:1500]
|
||||
assert "B-33" in limiters
|
||||
assert "B-31" not in limiters # B-31 is the resubscribe fan-out, a different fix
|
||||
@@ -0,0 +1,51 @@
|
||||
"""B-15: secrets that would only break at first use must stop the app at startup."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import MIN_JWT_SECRET_LENGTH, ConfigError, Settings, validate_runtime_secrets
|
||||
|
||||
|
||||
def _settings(**overrides) -> Settings:
|
||||
base = {
|
||||
"jwt_secret": "x" * MIN_JWT_SECRET_LENGTH,
|
||||
"xprv_encryption_key": "a-fernet-key",
|
||||
}
|
||||
base.update(overrides)
|
||||
# _env_file=None so a developer's real .env can't make this test pass or fail.
|
||||
return Settings(_env_file=None, **base)
|
||||
|
||||
|
||||
def test_valid_secrets_pass():
|
||||
validate_runtime_secrets(_settings())
|
||||
|
||||
|
||||
def test_empty_jwt_secret_is_refused():
|
||||
"""An empty JWT_SECRET makes PyJWT raise InvalidKeyError on every single login —
|
||||
a 500 with no hint about the real cause, on a container that started up healthy."""
|
||||
with pytest.raises(ConfigError, match="JWT_SECRET"):
|
||||
validate_runtime_secrets(_settings(jwt_secret=""))
|
||||
|
||||
|
||||
def test_short_jwt_secret_is_refused():
|
||||
with pytest.raises(ConfigError, match="JWT_SECRET"):
|
||||
validate_runtime_secrets(_settings(jwt_secret="x" * (MIN_JWT_SECRET_LENGTH - 1)))
|
||||
|
||||
|
||||
def test_empty_xprv_encryption_key_is_refused():
|
||||
"""Without it Fernet fails on the first key derivation — i.e. the first time
|
||||
anyone registers or a transaction needs signing."""
|
||||
with pytest.raises(ConfigError, match="XPRV_ENCRYPTION_KEY"):
|
||||
validate_runtime_secrets(_settings(xprv_encryption_key=" "))
|
||||
|
||||
|
||||
def test_all_problems_are_reported_at_once():
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
validate_runtime_secrets(_settings(jwt_secret="", xprv_encryption_key=""))
|
||||
message = str(exc_info.value)
|
||||
assert "JWT_SECRET" in message and "XPRV_ENCRYPTION_KEY" in message
|
||||
|
||||
|
||||
def test_an_empty_admin_token_is_not_fatal():
|
||||
"""require_admin already denies every request when it's unset, so the effect is a
|
||||
locked admin panel rather than an open one — no reason to refuse to boot."""
|
||||
validate_runtime_secrets(_settings(admin_token=""))
|
||||
+199
-12
@@ -1,41 +1,83 @@
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
import app.bets.confirmation # noqa: F401 (registers the "bet" handler)
|
||||
import app.rounds.confirmation # noqa: F401 (registers the "payout" handler)
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant, User
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.tx.confirmation import poll_once
|
||||
from app.wallet.hd import derive_user_address
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, confirmations_by_txid: dict[str, int]):
|
||||
self._confirmations = confirmations_by_txid
|
||||
"""B-41: poll_once now asks blockchain.scripthash.get_history rather than a
|
||||
verbose blockchain.transaction.get, so this hands back a flat history —
|
||||
height > 0 means confirmed at that height, 0 (or absent) means still in the
|
||||
mempool. The scripthash argument is ignored: every candidate's derived
|
||||
address is looked up against the same known universe of txids, which is
|
||||
fine since matching happens on tx_hash, not on which address asked."""
|
||||
|
||||
async def get_transaction(self, txid: str, verbose: bool = False) -> dict:
|
||||
return {"confirmations": self._confirmations.get(txid, 0)}
|
||||
def __init__(self, heights_by_txid: dict[str, int]):
|
||||
self._heights = heights_by_txid
|
||||
|
||||
async def get_history(self, scripthash: str) -> list[dict]:
|
||||
return [{"tx_hash": txid, "height": height} for txid, height in self._heights.items()]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session_factory():
|
||||
async def session_factory(tmp_path, monkeypatch):
|
||||
# own_address_for (B-41) derives each row's address via the HD wallet, so
|
||||
# poll_once now needs a real master key — same bootstrap test_broadcast.py
|
||||
# and test_reconcile.py use.
|
||||
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"xprv_encryption_key",
|
||||
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
|
||||
)
|
||||
from app.wallet import hd
|
||||
|
||||
hd._account_key = None
|
||||
hd.generate_master_key()
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
hd._account_key = None
|
||||
|
||||
|
||||
async def _make_user(session, derivation_index: int) -> User:
|
||||
user = User(
|
||||
username=f"user{derivation_index}",
|
||||
password_hash="x",
|
||||
derivation_index=derivation_index,
|
||||
address=derive_user_address(derivation_index),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def test_bet_confirmation_marks_participant_confirmed(session_factory):
|
||||
async with session_factory() as session:
|
||||
user = await _make_user(session, 0)
|
||||
session.add(Round(id=1, status="open"))
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=1, user_id=1, bet_amount_sats=1_000, bet_txid="tx1", status="broadcast"
|
||||
round_id=1, user_id=user.id, bet_amount_sats=1_000, bet_txid="tx1", status="broadcast"
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
PendingTransaction(kind="bet", round_id=1, user_id=1, current_txid="tx1", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending")
|
||||
PendingTransaction(
|
||||
kind="bet", round_id=1, user_id=user.id, current_txid="tx1", fee_rate_sat_vb=1,
|
||||
raw_tx_hex="00", status="pending",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -53,9 +95,17 @@ async def test_bet_confirmation_marks_participant_confirmed(session_factory):
|
||||
|
||||
async def test_unconfirmed_tx_is_left_pending(session_factory):
|
||||
async with session_factory() as session:
|
||||
user = await _make_user(session, 0)
|
||||
session.add(Round(id=2, status="open"))
|
||||
session.add(RoundParticipant(round_id=2, user_id=1, bet_amount_sats=1_000, bet_txid="tx2", status="broadcast"))
|
||||
session.add(PendingTransaction(kind="bet", round_id=2, user_id=1, current_txid="tx2", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"))
|
||||
session.add(
|
||||
RoundParticipant(round_id=2, user_id=user.id, bet_amount_sats=1_000, bet_txid="tx2", status="broadcast")
|
||||
)
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="bet", round_id=2, user_id=user.id, current_txid="tx2", fee_rate_sat_vb=1,
|
||||
raw_tx_hex="00", status="pending",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
client = FakeClient({"tx2": 0})
|
||||
@@ -70,7 +120,11 @@ async def test_unconfirmed_tx_is_left_pending(session_factory):
|
||||
async def test_payout_confirmation_closes_round(session_factory):
|
||||
async with session_factory() as session:
|
||||
session.add(Round(id=3, status="paying_out", payout_txid="tx3"))
|
||||
session.add(PendingTransaction(kind="payout", round_id=3, current_txid="tx3", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"))
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="payout", round_id=3, current_txid="tx3", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
client = FakeClient({"tx3": 2})
|
||||
@@ -80,3 +134,136 @@ async def test_payout_confirmation_closes_round(session_factory):
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, 3)
|
||||
assert round_.status == "closed"
|
||||
|
||||
|
||||
class ExplodingClient:
|
||||
"""Answers for one address's history and raises for the other's — the
|
||||
get_history equivalent of a server that no longer knows a particular tx
|
||||
(dropped from the mempool, replaced by a bump)."""
|
||||
|
||||
def __init__(self, heights_by_txid: dict[str, int], exploding_scripthash: str):
|
||||
self._heights = heights_by_txid
|
||||
self._exploding = exploding_scripthash
|
||||
|
||||
async def get_history(self, scripthash: str) -> list[dict]:
|
||||
if scripthash == self._exploding:
|
||||
raise RuntimeError("server error")
|
||||
return [{"tx_hash": txid, "height": height} for txid, height in self._heights.items()]
|
||||
|
||||
|
||||
async def test_one_unresolvable_candidate_does_not_block_the_others(session_factory):
|
||||
"""B-03: the lookup used to be unguarded, so a single failing candidate aborted
|
||||
the whole pass — nothing confirmed again until an operator intervened, which in
|
||||
turn meant no round could ever close. B-41 changed the failure unit from "one
|
||||
txid" to "one address's history", but the isolation guarantee is the same."""
|
||||
async with session_factory() as session:
|
||||
good_user = await _make_user(session, 0)
|
||||
gone_user = await _make_user(session, 1)
|
||||
session.add(Round(id=10, status="open"))
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=10, user_id=good_user.id, bet_amount_sats=1_000, bet_txid="good", status="broadcast"
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="bet", round_id=10, user_id=gone_user.id, current_txid="gone", fee_rate_sat_vb=1,
|
||||
raw_tx_hex="00", status="pending",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="bet", round_id=10, user_id=good_user.id, current_txid="good", fee_rate_sat_vb=1,
|
||||
raw_tx_hex="00", status="pending",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
exploding_scripthash = address_to_scripthash(derive_user_address(1))
|
||||
confirmed = await poll_once(
|
||||
session_factory, ExplodingClient({"good": 1}, exploding_scripthash=exploding_scripthash)
|
||||
)
|
||||
assert confirmed == 1 # the healthy one still got processed
|
||||
|
||||
async with session_factory() as session:
|
||||
participant = (await session.scalars(select(RoundParticipant).where(RoundParticipant.round_id == 10))).one()
|
||||
assert participant.status == "confirmed"
|
||||
rows = {p.current_txid: p.status for p in (await session.scalars(select(PendingTransaction))).all()}
|
||||
assert rows["good"] == "confirmed"
|
||||
assert rows["gone"] == "pending" # left for the reconciler to judge, not abandoned here
|
||||
|
||||
|
||||
async def test_bet_confirms_after_an_rbf_bump_changed_the_txid(session_factory):
|
||||
"""B-02: the handler used to match on bet_txid, so a bumped bet confirmed under
|
||||
a txid no participant carried — the participant stayed "broadcast" forever and
|
||||
the round could never close. It now resolves by (round_id, user_id)."""
|
||||
async with session_factory() as session:
|
||||
user = await _make_user(session, 0)
|
||||
session.add(Round(id=11, status="open"))
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=11, user_id=user.id, bet_amount_sats=1_000, bet_txid="old-txid", status="broadcast"
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="bet", round_id=11, user_id=user.id, current_txid="bumped-txid", fee_rate_sat_vb=2,
|
||||
raw_tx_hex="00", status="pending", replaced_by_txid="old-txid",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert await poll_once(session_factory, FakeClient({"bumped-txid": 1})) == 1
|
||||
|
||||
async with session_factory() as session:
|
||||
participant = (await session.scalars(select(RoundParticipant).where(RoundParticipant.round_id == 11))).one()
|
||||
assert participant.status == "confirmed"
|
||||
|
||||
|
||||
async def test_payout_confirms_after_an_rbf_bump_changed_the_txid(session_factory):
|
||||
async with session_factory() as session:
|
||||
session.add(Round(id=12, status="paying_out", payout_txid="old-payout"))
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="payout", round_id=12, current_txid="bumped-payout", fee_rate_sat_vb=2,
|
||||
raw_tx_hex="00", status="pending",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert await poll_once(session_factory, FakeClient({"bumped-payout": 1})) == 1
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(Round, 12)).status == "closed"
|
||||
|
||||
|
||||
async def test_poll_once_caches_history_per_scripthash(session_factory):
|
||||
"""Two pending bets from the same user share one address — fetching its
|
||||
history twice in one pass would be wasteful."""
|
||||
async with session_factory() as session:
|
||||
user = await _make_user(session, 0)
|
||||
session.add(Round(id=20, status="open"))
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="bet", round_id=20, user_id=user.id, current_txid="tx-a", fee_rate_sat_vb=1,
|
||||
raw_tx_hex="00", status="pending",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="withdrawal", user_id=user.id, current_txid="tx-b", fee_rate_sat_vb=1,
|
||||
raw_tx_hex="00", status="pending",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
class CountingClient:
|
||||
async def get_history(self, scripthash: str) -> list[dict]:
|
||||
call_count["n"] += 1
|
||||
return [{"tx_hash": "tx-a", "height": 0}, {"tx_hash": "tx-b", "height": 0}]
|
||||
|
||||
await poll_once(session_factory, CountingClient())
|
||||
|
||||
assert call_count["n"] == 1
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Regression tests for B-39: SQLite must run in WAL mode with a busy_timeout,
|
||||
since this app has five concurrent background tasks plus every HTTP handler
|
||||
sharing one database file, and the default rollback-journal mode lets a writer
|
||||
block every reader and fails a second writer immediately instead of waiting."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.db.base import _SQLITE_BUSY_TIMEOUT_MS, _register_sqlite_pragmas
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sqlite_engine(tmp_path):
|
||||
# WAL needs a real file (it writes a companion -wal/-shm file alongside it) —
|
||||
# ":memory:" wouldn't exercise the same path.
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/test.db")
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _pragma(engine, name: str):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.exec_driver_sql(f"PRAGMA {name}")
|
||||
return result.fetchone()[0]
|
||||
|
||||
|
||||
async def test_register_sqlite_pragmas_enables_wal_and_busy_timeout(sqlite_engine):
|
||||
_register_sqlite_pragmas(sqlite_engine)
|
||||
|
||||
assert (await _pragma(sqlite_engine, "journal_mode")).lower() == "wal"
|
||||
assert await _pragma(sqlite_engine, "busy_timeout") == _SQLITE_BUSY_TIMEOUT_MS
|
||||
assert await _pragma(sqlite_engine, "synchronous") == 1 # NORMAL
|
||||
|
||||
|
||||
async def test_register_sqlite_pragmas_applies_to_every_new_connection(sqlite_engine):
|
||||
"""The pool can open more than one underlying DBAPI connection over the
|
||||
engine's lifetime — the pragmas must be re-applied to each one, not just
|
||||
the first, or a later connection would silently fall back to SQLite's
|
||||
defaults."""
|
||||
_register_sqlite_pragmas(sqlite_engine)
|
||||
|
||||
async with sqlite_engine.connect() as first:
|
||||
await first.exec_driver_sql("PRAGMA journal_mode")
|
||||
|
||||
async with sqlite_engine.connect() as second:
|
||||
result = await second.exec_driver_sql("PRAGMA busy_timeout")
|
||||
assert result.fetchone()[0] == _SQLITE_BUSY_TIMEOUT_MS
|
||||
|
||||
|
||||
def test_register_sqlite_pragmas_is_a_noop_for_other_dialects():
|
||||
"""Must not touch (or crash on) a non-sqlite engine — e.g. a future
|
||||
PostgreSQL DATABASE_URL, which neither needs nor understands these
|
||||
pragmas."""
|
||||
|
||||
class _FakeDialect:
|
||||
name = "postgresql"
|
||||
|
||||
class _FakeEngine:
|
||||
dialect = _FakeDialect()
|
||||
|
||||
_register_sqlite_pragmas(_FakeEngine()) # must not raise
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Regression tests for B-30: a periodic sweep must catch a deposit whose
|
||||
scripthash notification was silently lost, independent of whatever the
|
||||
notification-driven path (electrum/listener.py:refresh_user) is doing."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.models import User
|
||||
from app.deposits.reconcile import DepositReconciler
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session_factory():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _seed_users(session_factory, addresses: list[str]) -> list[int]:
|
||||
async with session_factory() as session:
|
||||
ids = []
|
||||
for i, address in enumerate(addresses):
|
||||
user = User(username=f"user{i}", password_hash="x", derivation_index=i, address=address)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
ids.append(user.id)
|
||||
await session.commit()
|
||||
return ids
|
||||
|
||||
|
||||
# Real, decodable PLM bech32 addresses (address_to_scripthash actually parses
|
||||
# them) — arbitrary otherwise.
|
||||
_ADDRESSES = [
|
||||
"plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd",
|
||||
"plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n",
|
||||
"plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx",
|
||||
]
|
||||
|
||||
|
||||
class FakeListener:
|
||||
def __init__(self, *, fail_for: set[int] | None = None, disconnect_after: int | None = None):
|
||||
self.client = object() # truthy: "connected"
|
||||
self.refreshed: list[int] = []
|
||||
self._fail_for = fail_for or set()
|
||||
self._disconnect_after = disconnect_after
|
||||
|
||||
async def refresh_user(self, user_id: int, scripthash: str) -> None:
|
||||
self.refreshed.append(user_id)
|
||||
if self._disconnect_after is not None and len(self.refreshed) >= self._disconnect_after:
|
||||
self.client = None
|
||||
if user_id in self._fail_for:
|
||||
raise RuntimeError(f"listunspent failed for user {user_id}")
|
||||
|
||||
|
||||
async def test_sweep_once_refreshes_every_user(session_factory):
|
||||
user_ids = await _seed_users(session_factory, _ADDRESSES)
|
||||
listener = FakeListener()
|
||||
reconciler = DepositReconciler(session_factory, listener)
|
||||
|
||||
await reconciler._sweep_once()
|
||||
|
||||
assert listener.refreshed == user_ids
|
||||
|
||||
|
||||
async def test_sweep_once_continues_past_a_failing_user(session_factory):
|
||||
"""One user's refresh failing (a transient network hiccup) must not stop the
|
||||
sweep from reaching the rest — mirrors poll_once's per-item isolation."""
|
||||
user_ids = await _seed_users(session_factory, _ADDRESSES)
|
||||
listener = FakeListener(fail_for={user_ids[1]})
|
||||
reconciler = DepositReconciler(session_factory, listener)
|
||||
|
||||
await reconciler._sweep_once()
|
||||
|
||||
assert listener.refreshed == user_ids
|
||||
|
||||
|
||||
async def test_sweep_once_stops_when_the_connection_drops_mid_sweep(session_factory):
|
||||
"""No point continuing once the connection is gone — the next reconnect's own
|
||||
_subscribe_all_users will cover everyone anyway."""
|
||||
user_ids = await _seed_users(session_factory, _ADDRESSES)
|
||||
listener = FakeListener(disconnect_after=1)
|
||||
reconciler = DepositReconciler(session_factory, listener)
|
||||
|
||||
await reconciler._sweep_once()
|
||||
|
||||
assert listener.refreshed == user_ids[:1]
|
||||
|
||||
|
||||
async def test_sweep_once_does_nothing_with_no_users(session_factory):
|
||||
listener = FakeListener()
|
||||
reconciler = DepositReconciler(session_factory, listener)
|
||||
|
||||
await reconciler._sweep_once() # must not raise
|
||||
|
||||
assert listener.refreshed == []
|
||||
+157
-2
@@ -1,9 +1,15 @@
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.models import User
|
||||
from app.deposits.service import credit_confirmed_utxos
|
||||
from app.db.models import AuditLog, User, UtxoEvent
|
||||
from app.deposits.service import (
|
||||
credit_confirmed_utxos,
|
||||
find_utxos_missing_from,
|
||||
mark_utxos_spent_externally,
|
||||
reinstate_reappeared_utxos,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -52,3 +58,152 @@ async def test_idempotent_on_repeated_notification(session_factory, user_id):
|
||||
assert first == 1
|
||||
assert second == 0
|
||||
assert user.cached_balance_sats == 7_000_000
|
||||
|
||||
|
||||
# --- B-29: detecting a UTXO spent outside the platform is now a three-step,
|
||||
# corroborate-before-you-mark process, split across find_utxos_missing_from
|
||||
# (read-only candidate detection), the caller's own corroboration against other
|
||||
# servers (electrum/listener.py, not exercised here), and mark_utxos_spent_
|
||||
# externally (persistence only, once a candidate is already confirmed). ---------
|
||||
|
||||
|
||||
async def test_find_utxos_missing_from_returns_the_missing_candidate(session_factory, user_id):
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
)
|
||||
|
||||
# A different outpoint present in this refresh — our own tracked one is
|
||||
# genuinely absent from it, not just from an entirely empty reply.
|
||||
other_entries = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000}]
|
||||
async with session_factory() as session:
|
||||
candidates = await find_utxos_missing_from(session, user_id, other_entries)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].txid == "dd" * 32
|
||||
assert candidates[0].spent_txid is None # read-only: nothing is marked yet
|
||||
|
||||
|
||||
async def test_find_utxos_missing_from_returns_nothing_when_present(session_factory, user_id):
|
||||
entries = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 100, "value": 3_000_000}]
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(session, user_id, entries)
|
||||
|
||||
async with session_factory() as session:
|
||||
candidates = await find_utxos_missing_from(session, user_id, entries)
|
||||
assert candidates == []
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 3_000_000
|
||||
|
||||
|
||||
async def test_find_utxos_missing_from_skips_a_totally_empty_response(session_factory, user_id):
|
||||
"""B-29: an entirely empty listunspent for a funded address reads as an
|
||||
incomplete/broken response, not proof of a full external sweep — it would
|
||||
otherwise flag every UTXO of this user as missing from one bad reply."""
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
candidates = await find_utxos_missing_from(session, user_id, [])
|
||||
assert candidates == []
|
||||
|
||||
|
||||
async def test_mark_utxos_spent_externally_marks_and_corrects_balance(session_factory, user_id):
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
marked = await mark_utxos_spent_externally(session, user_id, [utxo.id])
|
||||
assert marked == 1
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 0
|
||||
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
assert utxo.spent_txid == "external-spend"
|
||||
|
||||
audit_events = (await session.scalars(select(AuditLog))).all()
|
||||
assert any(e.event_type == "utxo_spent_externally" for e in audit_events)
|
||||
|
||||
|
||||
async def test_mark_utxos_spent_externally_skips_an_already_resolved_row(session_factory, user_id):
|
||||
"""Something else (a legitimate platform spend, or a prior refresh) may have
|
||||
resolved the row between the caller reading the candidate list and finishing
|
||||
corroboration — mark_utxos_spent_externally must not clobber that."""
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
)
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
utxo_id = utxo.id
|
||||
utxo.spent_txid = "some-real-platform-txid"
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
marked = await mark_utxos_spent_externally(session, user_id, [utxo_id])
|
||||
assert marked == 0
|
||||
utxo = await session.get(UtxoEvent, utxo_id)
|
||||
assert utxo.spent_txid == "some-real-platform-txid" # untouched
|
||||
|
||||
|
||||
async def test_reinstate_reappeared_utxos_clears_the_mark_and_restores_balance(session_factory, user_id):
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
)
|
||||
utxo_id = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().id
|
||||
|
||||
async with session_factory() as session:
|
||||
await mark_utxos_spent_externally(session, user_id, [utxo_id])
|
||||
|
||||
# The outpoint reappears as unspent in a later refresh.
|
||||
entries = [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
|
||||
async with session_factory() as session:
|
||||
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
|
||||
assert reinstated == 1
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 20_000_000
|
||||
|
||||
utxo = await session.get(UtxoEvent, utxo_id)
|
||||
assert utxo.spent_txid is None
|
||||
|
||||
audit_events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert "utxo_external_spend_reinstated" in audit_events
|
||||
|
||||
|
||||
async def test_reinstate_reappeared_utxos_ignores_unmarked_rows(session_factory, user_id):
|
||||
entries = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 100, "value": 3_000_000}]
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(session, user_id, entries)
|
||||
|
||||
async with session_factory() as session:
|
||||
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
|
||||
assert reinstated == 0
|
||||
|
||||
|
||||
async def test_find_new_credit_candidates_skips_unconfirmed_and_already_known(session_factory, user_id): # B-59
|
||||
"""What the caller has to corroborate before crediting: only entries that would
|
||||
actually write something. Re-corroborating what we already hold would open a
|
||||
connection to every other server on every refresh, for an answer that can no
|
||||
longer change anything."""
|
||||
from app.deposits.service import find_new_credit_candidates
|
||||
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 1_000}]
|
||||
)
|
||||
|
||||
entries = [
|
||||
{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # already credited
|
||||
{"tx_hash": "bb" * 32, "tx_pos": 0, "height": 0, "value": 2_000}, # still in the mempool
|
||||
{"tx_hash": "cc" * 32, "tx_pos": 1, "height": 101, "value": 3_000}, # genuinely new
|
||||
]
|
||||
async with session_factory() as session:
|
||||
candidates = await find_new_credit_candidates(session, user_id, entries)
|
||||
|
||||
assert [(c["tx_hash"], c["tx_pos"]) for c in candidates] == [("cc" * 32, 1)]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""B-68: CLAUDE.md and README must describe the code as it is now.
|
||||
|
||||
The audit found both files still asserting things the code had moved past —
|
||||
JWT "no revocation" after token_version implemented exactly that, /report-bug
|
||||
"a placeholder" after it shipped with admin triage, three stale test counts, a
|
||||
code map missing three modules, and README links to a file and an anchor that
|
||||
no longer exist. None of that is catchable by reading the code, so it is
|
||||
pinned here instead.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
CLAUDE_MD = (_ROOT / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
README = (_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _collected_test_count() -> int:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "--collect-only", "-q"],
|
||||
cwd=_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
match = re.search(r"(\d+) tests? collected", result.stdout)
|
||||
assert match, f"could not parse the collection summary:\n{result.stdout[-2000:]}"
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def test_documented_test_counts_match_reality():
|
||||
actual = _collected_test_count()
|
||||
documented = [int(n) for n in re.findall(r"(\d+) tests\b", CLAUDE_MD)]
|
||||
documented += [int(n) for n in re.findall(r"(\d+) unit tests\b", README)]
|
||||
assert documented, "no test count found in CLAUDE.md or README — did the wording change?"
|
||||
for count in documented:
|
||||
assert count == actual, (
|
||||
f"docs claim {count} tests, the suite collects {actual} — "
|
||||
"update the counts in CLAUDE.md (twice) and README.md"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stale",
|
||||
[
|
||||
"no revocation", # token_version implements it (app/auth/dependencies.py)
|
||||
"`/report-bug` are placeholders", # /report-bug shipped, only /guida is a stub
|
||||
],
|
||||
)
|
||||
def test_claude_md_has_no_stale_claims(stale):
|
||||
assert stale not in CLAUDE_MD
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module",
|
||||
["rate_limit.py", "client_ip.py", "bug_reports"],
|
||||
)
|
||||
def test_code_map_covers_every_package_member(module):
|
||||
code_map = CLAUDE_MD.split("## Code map", 1)[1].split("## Background tasks", 1)[0]
|
||||
assert module in code_map
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"link",
|
||||
["(flowchart.mmd)", "CLAUDE.md#tech-stack-mvp"],
|
||||
)
|
||||
def test_readme_has_no_dead_links(link):
|
||||
assert link not in README
|
||||
|
||||
|
||||
def test_readme_relative_links_resolve():
|
||||
for target in re.findall(r"\]\(([^)#]+)(?:#[^)]*)?\)", README):
|
||||
if target.startswith(("http://", "https://", "mailto:")):
|
||||
continue
|
||||
assert (_ROOT / target).exists(), f"README links {target}, which does not exist"
|
||||
|
||||
|
||||
def test_claude_md_anchors_into_itself_resolve():
|
||||
headings = {
|
||||
re.sub(r"[^a-z0-9 -]", "", line.lstrip("# ").lower()).replace(" ", "-")
|
||||
for line in CLAUDE_MD.splitlines()
|
||||
if line.startswith("#")
|
||||
}
|
||||
for anchor in re.findall(r"\(CLAUDE\.md#([a-z0-9-]+)\)", README + CLAUDE_MD):
|
||||
assert anchor in headings, f"anchor #{anchor} matches no CLAUDE.md heading"
|
||||
@@ -0,0 +1,28 @@
|
||||
"""B-42: Swagger/ReDoc/OpenAPI JSON must not be reachable unless explicitly enabled —
|
||||
they enumerate the whole API surface, admin endpoints included."""
|
||||
|
||||
import importlib
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _reload_main():
|
||||
import app.main
|
||||
|
||||
return importlib.reload(app.main)
|
||||
|
||||
|
||||
def test_docs_disabled_by_default(monkeypatch):
|
||||
monkeypatch.setattr(settings, "enable_api_docs", False)
|
||||
main = _reload_main()
|
||||
assert main.app.docs_url is None
|
||||
assert main.app.redoc_url is None
|
||||
assert main.app.openapi_url is None
|
||||
|
||||
|
||||
def test_docs_enabled_when_configured(monkeypatch):
|
||||
monkeypatch.setattr(settings, "enable_api_docs", True)
|
||||
main = _reload_main()
|
||||
assert main.app.docs_url == "/docs"
|
||||
assert main.app.redoc_url == "/redoc"
|
||||
assert main.app.openapi_url == "/openapi.json"
|
||||
@@ -0,0 +1,18 @@
|
||||
"""B-44: README and docs/running-the-server.md must not document a bare
|
||||
`uvicorn --reload` workflow — the server always runs via Docker, in dev and
|
||||
production alike (CLAUDE.md's "Commands" section), and the two files had
|
||||
drifted back to contradicting that policy."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
def test_readme_has_no_bare_uvicorn_command():
|
||||
readme = (REPO_ROOT / "README.md").read_text()
|
||||
assert "uvicorn app.main:app --reload" not in readme
|
||||
|
||||
|
||||
def test_running_the_server_doc_has_no_bare_uvicorn_command():
|
||||
doc = (REPO_ROOT / "docs" / "running-the-server.md").read_text()
|
||||
assert "uvicorn app.main:app --reload" not in doc
|
||||
+40
-9
@@ -1,19 +1,50 @@
|
||||
import pytest
|
||||
|
||||
from app.rounds.draw import draw_winner, header_hex_to_block_hash
|
||||
from app.rounds.draw import (
|
||||
draw_winner,
|
||||
header_hex_to_block_hash,
|
||||
header_meets_its_own_target,
|
||||
header_prev_hash,
|
||||
)
|
||||
|
||||
|
||||
def test_header_hex_to_block_hash_matches_known_mainnet_block():
|
||||
# Real PLM mainnet block 477486: header from blockchain.block.header, hash
|
||||
# cross-checked against the blockhash reported by blockchain.transaction.get
|
||||
# for a tx confirmed in that block.
|
||||
header_hex = (
|
||||
# Real PLM mainnet block 477486: header from blockchain.block.header, hash
|
||||
# cross-checked against the blockhash reported by blockchain.transaction.get for a
|
||||
# tx confirmed in that block.
|
||||
_REAL_HEADER_HEX = (
|
||||
"0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000"
|
||||
"009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875"
|
||||
"e6a47a61b1ad8012582"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_header_hex_to_block_hash_matches_known_mainnet_block():
|
||||
known_block_hash = "00000000000008788b55ade13b74d54ceffda9e54315b802411be1ca65064e86"
|
||||
assert header_hex_to_block_hash(header_hex) == known_block_hash
|
||||
assert header_hex_to_block_hash(_REAL_HEADER_HEX) == known_block_hash
|
||||
|
||||
|
||||
def test_header_meets_its_own_target_accepts_a_real_mined_header():
|
||||
"""B-28: a genuinely mined mainnet header must pass its own self-consistency
|
||||
check — this isn't just a synthetic-header property."""
|
||||
assert header_meets_its_own_target(_REAL_HEADER_HEX) is True
|
||||
|
||||
|
||||
def test_header_meets_its_own_target_rejects_a_tampered_header():
|
||||
"""Flipping a single nonce bit changes the hash completely (avalanche effect)
|
||||
without changing the claimed difficulty, so a tampered-but-otherwise-real
|
||||
header should almost certainly fail — this is what would catch a
|
||||
hostile/MITM'd server replaying a real header with a doctored field."""
|
||||
tampered = bytearray(bytes.fromhex(_REAL_HEADER_HEX))
|
||||
tampered[-1] ^= 0xFF # flip the last byte of the nonce
|
||||
assert header_meets_its_own_target(tampered.hex()) is False
|
||||
|
||||
|
||||
def test_header_meets_its_own_target_rejects_wrong_length():
|
||||
assert header_meets_its_own_target("aa" * 10) is False
|
||||
|
||||
|
||||
def test_header_prev_hash_matches_the_known_previous_block():
|
||||
# Block 477486's predecessor, 477485 — independently known from the same chain.
|
||||
assert header_prev_hash(_REAL_HEADER_HEX) == "000000000000170c8179321dfd323075bfe7365f7c10f51402d10052e49ec3fb"
|
||||
|
||||
|
||||
def test_draw_winner_is_deterministic_and_within_range():
|
||||
|
||||
@@ -75,3 +75,83 @@ async def test_notification_delivered_to_subscription_queue():
|
||||
assert params == ["abcd", "newstatus"]
|
||||
|
||||
client._read_task.cancel()
|
||||
|
||||
|
||||
async def test_request_times_out_instead_of_hanging_forever(monkeypatch):
|
||||
"""B-01: a server that owes us a reply and never sends one used to hang the
|
||||
caller permanently — which meant a POST /bets could hang while holding the
|
||||
per-user lock, and the confirmation poller could stop polling for good."""
|
||||
from app.electrum import client as client_module
|
||||
|
||||
monkeypatch.setattr(client_module, "_REQUEST_TIMEOUT_SECONDS", 0.05)
|
||||
client, reader, writer = await _client_with_fake_transport()
|
||||
|
||||
try:
|
||||
await client.request("blockchain.transaction.get", ["deadbeef"])
|
||||
assert False, "expected ElectrumError"
|
||||
except ElectrumError as exc:
|
||||
assert "timed out" in str(exc)
|
||||
|
||||
# The connection is torn down, so callers stop reusing a server that owes us.
|
||||
assert client._closed.is_set()
|
||||
client._read_task.cancel()
|
||||
|
||||
|
||||
async def test_wait_closed_resolves_when_the_read_loop_dies():
|
||||
"""B-01: the read loop dying used to be invisible — the listener sat on its
|
||||
notification queues forever and never reconnected."""
|
||||
client, reader, writer = await _client_with_fake_transport()
|
||||
|
||||
waiter = asyncio.create_task(client.wait_closed())
|
||||
reader.feed_eof() # peer closed the connection
|
||||
|
||||
await asyncio.wait_for(waiter, timeout=1)
|
||||
client._read_task.cancel()
|
||||
|
||||
|
||||
async def test_pending_request_fails_when_the_connection_drops():
|
||||
client, reader, writer = await _client_with_fake_transport()
|
||||
|
||||
task = asyncio.create_task(client.request("server.ping"))
|
||||
await asyncio.sleep(0)
|
||||
reader.feed_eof()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=1)
|
||||
assert False, "expected ElectrumError"
|
||||
except ElectrumError:
|
||||
pass
|
||||
|
||||
|
||||
def test_parse_endpoints_puts_the_primary_first_and_dedupes():
|
||||
from app.electrum.client import ElectrumEndpoint, parse_endpoints
|
||||
|
||||
endpoints = parse_endpoints(
|
||||
"primary.example", 50002, True, "second.example:50002, third.example:50001:notls, primary.example:50002"
|
||||
)
|
||||
assert endpoints == [
|
||||
ElectrumEndpoint("primary.example", 50002, True),
|
||||
ElectrumEndpoint("second.example", 50002, True),
|
||||
ElectrumEndpoint("third.example", 50001, False),
|
||||
]
|
||||
|
||||
|
||||
def test_parse_endpoints_handles_an_empty_fallback_list():
|
||||
from app.electrum.client import ElectrumEndpoint, parse_endpoints
|
||||
|
||||
assert parse_endpoints("only.example", 50002, True, "") == [ElectrumEndpoint("only.example", 50002, True)]
|
||||
assert parse_endpoints("only.example", 50002, True, " , ") == [
|
||||
ElectrumEndpoint("only.example", 50002, True)
|
||||
]
|
||||
|
||||
|
||||
def test_parse_endpoints_rejects_a_malformed_entry():
|
||||
"""A typo in a fallback server must fail at startup, not during the outage when
|
||||
the fallback is the thing that's needed."""
|
||||
import pytest
|
||||
|
||||
from app.electrum.client import parse_endpoints
|
||||
|
||||
for bad in ["nohost:", "host:notaport", "host", "host:50002:weird"]:
|
||||
with pytest.raises(ValueError):
|
||||
parse_endpoints("primary.example", 50002, True, bad)
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
"""Listener-level behaviour: server rotation on failure (the fallback-servers
|
||||
feature), the chain-tip monotonicity guard (B-19), header validation and
|
||||
multi-server corroboration (B-28), the new-user subscribe task's retention and
|
||||
error logging (B-30), and bounded-concurrency, non-blocking resubscribe on
|
||||
reconnect (B-31).
|
||||
|
||||
The reconnect loop itself (B-01) is covered from the client side in
|
||||
test_electrum_client.py — what's asserted here is that the listener *acts* on a
|
||||
dead connection by moving to the next server instead of retrying the same one.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.models import User, UtxoEvent
|
||||
from app.electrum.client import ElectrumEndpoint
|
||||
from app.electrum.listener import ElectrumListener
|
||||
from app.rounds.draw import HeaderValidationError, header_hex_to_block_hash, header_meets_its_own_target
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session_factory():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
_ENDPOINTS = [
|
||||
ElectrumEndpoint("first.example", 50002, True),
|
||||
ElectrumEndpoint("second.example", 50002, True),
|
||||
ElectrumEndpoint("third.example", 50001, False),
|
||||
]
|
||||
|
||||
# A regtest-style trivial difficulty target (~50% of hashes satisfy it), so mining
|
||||
# a real, self-consistent test header takes a handful of nonce attempts rather than
|
||||
# needing actual mainnet-grade hashpower. Not a valid PLM mainnet difficulty —
|
||||
# irrelevant here, since header_meets_its_own_target only checks self-consistency.
|
||||
_EASY_BITS = 0x207FFFFF
|
||||
|
||||
|
||||
def _build_header(prev_hash_hex: str, nonce: int, *, bits: int = _EASY_BITS) -> str:
|
||||
return (
|
||||
struct.pack("<I", 1) # version
|
||||
+ bytes.fromhex(prev_hash_hex)[::-1]
|
||||
+ bytes.fromhex("00" * 32) # merkle_root, irrelevant to the checks under test
|
||||
+ struct.pack("<I", 0) # timestamp
|
||||
+ struct.pack("<I", bits)
|
||||
+ struct.pack("<I", nonce)
|
||||
).hex()
|
||||
|
||||
|
||||
def _mine_header(prev_hash_hex: str, *, bits: int = _EASY_BITS) -> str:
|
||||
"""A real header that satisfies its own claimed target — good enough to
|
||||
exercise header_meets_its_own_target/_apply_header for real, without needing
|
||||
genuine PLM-mainnet-grade hashpower."""
|
||||
for nonce in range(100_000):
|
||||
header_hex = _build_header(prev_hash_hex, nonce, bits=bits)
|
||||
if header_meets_its_own_target(header_hex):
|
||||
return header_hex
|
||||
raise RuntimeError("failed to mine a test header within the attempt budget")
|
||||
|
||||
|
||||
async def test_rotates_to_the_next_server_after_a_failed_session(session_factory):
|
||||
"""One unreachable server should cost a single attempt, not an outage: every
|
||||
deposit credit, broadcast and confirmation goes through this one connection."""
|
||||
attempted: list[str] = []
|
||||
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
|
||||
async def failing_run_once(endpoint):
|
||||
attempted.append(endpoint.host)
|
||||
if len(attempted) >= 5:
|
||||
raise asyncio.CancelledError # stop the loop
|
||||
raise ConnectionRefusedError("nope")
|
||||
|
||||
listener._run_once = failing_run_once
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await listener.run()
|
||||
|
||||
# Round-robin over all three, wrapping around — never the same one twice in a row.
|
||||
assert attempted == [
|
||||
"first.example",
|
||||
"second.example",
|
||||
"third.example",
|
||||
"first.example",
|
||||
"second.example",
|
||||
]
|
||||
|
||||
|
||||
async def test_backoff_only_sleeps_after_every_server_has_been_tried(session_factory, monkeypatch):
|
||||
"""A genuinely offline network must back off, but not before the alternatives have
|
||||
had their turn."""
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds):
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
attempts = {"n": 0}
|
||||
|
||||
async def failing_run_once(endpoint):
|
||||
attempts["n"] += 1
|
||||
if attempts["n"] > 6:
|
||||
raise asyncio.CancelledError
|
||||
raise ConnectionRefusedError("nope")
|
||||
|
||||
listener._run_once = failing_run_once
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await listener.run()
|
||||
|
||||
# 6 failures over 3 servers = 2 completed cycles = 2 sleeps, growing.
|
||||
assert sleeps == [1, 2]
|
||||
|
||||
|
||||
async def test_a_connected_session_resets_the_backoff(session_factory, monkeypatch):
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds):
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
outcomes = iter([False, False, False, True, False, False, False])
|
||||
|
||||
async def run_once(endpoint):
|
||||
try:
|
||||
connected = next(outcomes)
|
||||
except StopIteration:
|
||||
raise asyncio.CancelledError from None
|
||||
if not connected:
|
||||
raise ConnectionRefusedError("nope")
|
||||
return True # connected, then dropped
|
||||
|
||||
listener._run_once = run_once
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await listener.run()
|
||||
|
||||
# First cycle of 3 failures sleeps 1s; the successful connection resets the
|
||||
# counter, so the next 3 failures sleep 1s again rather than 2s.
|
||||
assert sleeps == [1, 1]
|
||||
|
||||
|
||||
async def test_listener_with_no_endpoints_gives_up_loudly(session_factory):
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, [])
|
||||
await listener.run() # returns instead of spinning or crashing
|
||||
assert listener.current_endpoint is None
|
||||
|
||||
|
||||
# --- B-30: address_for_new_user's subscribe task must be retained (not fire-and-
|
||||
# forget) and its failure must be observable, not silently swallowed. -------------
|
||||
|
||||
|
||||
async def test_address_for_new_user_does_nothing_without_a_connection(session_factory):
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
listener.address_for_new_user(1, "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd") # listener.client is None
|
||||
assert listener._background_tasks == set()
|
||||
|
||||
|
||||
async def test_address_for_new_user_retains_and_logs_a_failed_subscribe_task(session_factory, caplog):
|
||||
"""Before B-30, this task was fire-and-forget: an AssertionError (self.client
|
||||
turning None mid-flight) or any other failure vanished into asyncio's default
|
||||
unretrieved-exception handler instead of being logged anywhere the operator
|
||||
could see, and nothing kept the task alive in the meantime."""
|
||||
|
||||
class FailingClient:
|
||||
async def subscribe_scripthash(self, scripthash):
|
||||
raise ConnectionResetError("dropped mid-subscribe")
|
||||
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
listener.client = FailingClient()
|
||||
|
||||
listener.address_for_new_user(1, "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd")
|
||||
assert len(listener._background_tasks) == 1 # retained while in flight
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await asyncio.gather(*list(listener._background_tasks), return_exceptions=True)
|
||||
await asyncio.sleep(0) # let the done_callbacks (scheduled via call_soon) run
|
||||
|
||||
assert listener._background_tasks == set() # discarded once done
|
||||
assert "could not subscribe" in caplog.text
|
||||
|
||||
|
||||
def test_tip_never_moves_backwards(session_factory):
|
||||
"""B-19: `self.tip_height = header["height"]` accepted a lower height, and
|
||||
_wait_for_next_block waits for tip_height > tip_at_close — so a regression
|
||||
silently added a block to the draw's wait. The hash must not move either: it's
|
||||
the draw's entropy source, and a mismatched height/hash pair would be worse than
|
||||
a stale one."""
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
|
||||
header_100 = _mine_header("00" * 32)
|
||||
listener._apply_header({"height": 100, "hex": header_100})
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100)
|
||||
|
||||
# A lower height is ignored purely on height, before any header validation even
|
||||
# runs — reorg or server switch, not a real advance.
|
||||
listener._apply_header({"height": 99, "hex": "bb"})
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100)
|
||||
|
||||
header_101 = _mine_header(header_hex_to_block_hash(header_100))
|
||||
listener._apply_header({"height": 101, "hex": header_101})
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (101, header_101)
|
||||
|
||||
|
||||
# --- B-28: a hostile or MITM'd server can no longer single-handedly decide the
|
||||
# draw's entropy — header self-consistency/linkage checks, and multi-server
|
||||
# corroboration for the block the draw actually uses. ---------------------------
|
||||
|
||||
|
||||
def test_apply_header_rejects_one_that_fails_its_own_pow_target(session_factory):
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
# Real mainnet-grade difficulty (genesis-era Bitcoin bits): satisfying it by
|
||||
# chance is astronomically unlikely, so this header is self-inconsistent.
|
||||
forged = _build_header("00" * 32, nonce=0, bits=0x1D00FFFF)
|
||||
|
||||
with pytest.raises(HeaderValidationError):
|
||||
listener._apply_header({"height": 100, "hex": forged})
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (0, None) # untouched
|
||||
|
||||
|
||||
def test_apply_header_rejects_one_that_does_not_chain_from_the_tip(session_factory):
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
header_100 = _mine_header("00" * 32)
|
||||
listener._apply_header({"height": 100, "hex": header_100})
|
||||
|
||||
# A single-block advance (101 = 100 + 1) whose prev_block claims an unrelated
|
||||
# chain — well-formed and self-consistently mined, but not actually built on
|
||||
# top of our current tip.
|
||||
disconnected = _mine_header("ff" * 32)
|
||||
|
||||
with pytest.raises(HeaderValidationError):
|
||||
listener._apply_header({"height": 101, "hex": disconnected})
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched
|
||||
|
||||
|
||||
def test_apply_header_ignores_a_competing_header_at_the_current_tip_height(session_factory, caplog): # B-64
|
||||
"""The linkage check only fires on a single-block advance, so a header at the
|
||||
height we already hold one for used to be applied on nothing but its own
|
||||
self-consistency — replacing the very hash a draw may be about to be seeded
|
||||
with. Whichever it is (a reorg at the tip, or a server disagreeing with the
|
||||
rest), the hash committed to for a height is not swapped under us; if ours is
|
||||
the orphan, corroborate_header refuses to draw from it anyway."""
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
header_100 = _mine_header("00" * 32)
|
||||
listener._apply_header({"height": 100, "hex": header_100})
|
||||
|
||||
competing_100 = _mine_header("11" * 32) # same height, well-formed, different block
|
||||
assert competing_100 != header_100
|
||||
with caplog.at_level(logging.WARNING):
|
||||
listener._apply_header({"height": 100, "hex": competing_100})
|
||||
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched
|
||||
assert "competing header" in caplog.text
|
||||
|
||||
|
||||
def test_apply_header_treats_the_same_header_re_announced_as_a_no_op(session_factory): # B-64
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
header_100 = _mine_header("00" * 32)
|
||||
listener._apply_header({"height": 100, "hex": header_100})
|
||||
|
||||
listener._apply_header({"height": 100, "hex": header_100}) # must not raise
|
||||
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100)
|
||||
|
||||
|
||||
def test_apply_header_ignores_one_carrying_no_hex(session_factory, caplog): # B-64
|
||||
"""A hex-less header can be neither validated nor drawn from, and applying its
|
||||
height alone used to *clear* the hex we already had — leaving tip_height and
|
||||
tip_header_hex describing different blocks, which is the one thing this function
|
||||
exists to prevent."""
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
header_100 = _mine_header("00" * 32)
|
||||
listener._apply_header({"height": 100, "hex": header_100})
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
listener._apply_header({"height": 101}) # height only, no hex
|
||||
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # pair intact
|
||||
assert "no header hex" in caplog.text
|
||||
|
||||
|
||||
def test_apply_header_skips_linkage_check_across_a_height_gap(session_factory):
|
||||
"""A reconnect (or the very first header of a session) hands us whatever the
|
||||
server's current tip is — which is legitimately not a single-block advance
|
||||
from whatever we last saw. There's no full header chain to check linkage
|
||||
against in that case, so only self-consistency is enforced."""
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
header_100 = _mine_header("00" * 32)
|
||||
listener._apply_header({"height": 100, "hex": header_100})
|
||||
|
||||
header_150 = _mine_header("ff" * 32) # unrelated prev_block, height jumps by 50
|
||||
listener._apply_header({"height": 150, "hex": header_150}) # must not raise
|
||||
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (150, header_150)
|
||||
|
||||
|
||||
async def _endpoint_client_factory(responses: dict[str, object]):
|
||||
"""Builds a client_factory whose fake clients answer blockchain.block.header
|
||||
per-endpoint according to `responses`: a header hex string to agree/disagree
|
||||
with, `None` to simulate an unreachable server, or an Exception instance to
|
||||
simulate a request failure."""
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, answer):
|
||||
self._answer = answer
|
||||
self.closed = False
|
||||
|
||||
async def connect(self):
|
||||
if isinstance(self._answer, Exception):
|
||||
raise self._answer
|
||||
|
||||
async def request(self, method, params):
|
||||
assert method == "blockchain.block.header"
|
||||
if self._answer is None:
|
||||
raise ConnectionRefusedError("unreachable")
|
||||
return self._answer
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
def factory(endpoint):
|
||||
return _FakeClient(responses[endpoint.host])
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
async def test_corroborate_header_true_with_no_other_servers_configured(session_factory):
|
||||
single = [ElectrumEndpoint("only.example", 50002, True)]
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
|
||||
assert await listener.corroborate_header(100, "deadbeef") is True
|
||||
|
||||
|
||||
async def test_corroborate_header_true_when_others_agree(session_factory):
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": header_hex, "second.example": header_hex, "third.example": header_hex}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_header(100, expected_hash) is True
|
||||
|
||||
|
||||
async def test_corroborate_header_never_asks_the_currently_active_endpoint(session_factory):
|
||||
"""The active connection is exactly what a hostile server or a MITM would
|
||||
control — corroborating against it too would defeat the point."""
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
# first.example (the active endpoint) would raise if ever queried.
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": RuntimeError("must not be called"), "second.example": header_hex, "third.example": header_hex}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
assert listener.current_endpoint.host == "first.example"
|
||||
|
||||
assert await listener.corroborate_header(100, expected_hash) is True
|
||||
|
||||
|
||||
async def test_corroborate_header_false_when_majority_disagrees(session_factory):
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
disagreeing_hex = _mine_header("11" * 32)
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": header_hex, "second.example": disagreeing_hex, "third.example": disagreeing_hex}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_header(100, expected_hash) is False
|
||||
|
||||
|
||||
async def test_corroborate_header_false_when_nobody_responds(session_factory):
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": "irrelevant", "second.example": None, "third.example": ConnectionRefusedError("down")}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_header(100, "deadbeef") is False
|
||||
|
||||
|
||||
# --- B-29: a UTXO absent from our own connection's listunspent must be
|
||||
# corroborated by other configured servers before it's treated as genuinely spent
|
||||
# outside the platform. ------------------------------------------------------------
|
||||
|
||||
|
||||
async def _listunspent_client_factory(responses: dict[str, object]):
|
||||
"""Builds a client_factory whose fake clients answer listunspent per-endpoint:
|
||||
a list of entries to report as unspent, `None` to simulate an unreachable
|
||||
server (fails at listunspent), or an Exception instance to simulate a connect
|
||||
failure."""
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, answer):
|
||||
self._answer = answer
|
||||
|
||||
async def connect(self):
|
||||
if isinstance(self._answer, Exception):
|
||||
raise self._answer
|
||||
|
||||
async def listunspent(self, scripthash):
|
||||
if self._answer is None:
|
||||
raise ConnectionRefusedError("unreachable")
|
||||
return self._answer
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
def factory(endpoint):
|
||||
return _FakeClient(responses[endpoint.host])
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
async def test_corroborate_utxo_spent_true_with_no_other_servers_configured(session_factory):
|
||||
single = [ElectrumEndpoint("only.example", 50002, True)]
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
|
||||
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True
|
||||
|
||||
|
||||
async def test_corroborate_utxo_spent_true_when_others_agree_its_gone(session_factory):
|
||||
factory = await _listunspent_client_factory({"first.example": [], "second.example": [], "third.example": []})
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True
|
||||
|
||||
|
||||
async def test_corroborate_utxo_spent_false_when_majority_still_see_it_unspent(session_factory):
|
||||
still_there = [{"tx_hash": "dd" * 32, "tx_pos": 0}]
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": still_there, "third.example": still_there}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False
|
||||
|
||||
|
||||
async def test_corroborate_utxo_spent_false_when_nobody_responds(session_factory):
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": None, "third.example": ConnectionRefusedError("down")}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False
|
||||
|
||||
|
||||
class _ActiveClient:
|
||||
"""Stands in for `self.client`, the listener's one active connection —
|
||||
refresh_user only ever calls listunspent on it."""
|
||||
|
||||
def __init__(self, entries: list[dict]):
|
||||
self._entries = entries
|
||||
|
||||
async def listunspent(self, scripthash):
|
||||
return self._entries
|
||||
|
||||
|
||||
async def _seed_funded_user(session_factory, *, username: str, address: str) -> int:
|
||||
from app.wallet.balance import recompute_balance
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(username=username, password_hash="x", derivation_index=0, address=address)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
session.add(
|
||||
UtxoEvent(user_id=user.id, txid="dd" * 32, vout=0, amount_sats=20_000_000, confirmed_height=100)
|
||||
)
|
||||
await recompute_balance(session, user.id)
|
||||
await session.commit()
|
||||
return user.id
|
||||
|
||||
|
||||
# An unrelated outpoint present alongside our own connection's listunspent reply —
|
||||
# keeps `entries` non-empty so find_utxos_missing_from's "entirely empty response"
|
||||
# guard doesn't swallow these tests; our own tracked UTXO is still genuinely
|
||||
# absent from it.
|
||||
_UNRELATED_ENTRY = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000}]
|
||||
|
||||
|
||||
async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(session_factory):
|
||||
user_id = await _seed_funded_user(session_factory, username="bob", address="plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd")
|
||||
|
||||
# The others agree the tracked UTXO is gone, and agree about the unrelated
|
||||
# entry — which B-59 now requires before that one may be credited.
|
||||
others_factory = await _listunspent_client_factory(
|
||||
{
|
||||
"first.example": _UNRELATED_ENTRY,
|
||||
"second.example": _UNRELATED_ENTRY,
|
||||
"third.example": _UNRELATED_ENTRY,
|
||||
}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.client = _ActiveClient(_UNRELATED_ENTRY) # our own connection no longer sees the UTXO either
|
||||
|
||||
await listener.refresh_user(user_id, "scripthash")
|
||||
|
||||
async with session_factory() as session:
|
||||
utxo = (
|
||||
await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.txid == "dd" * 32))
|
||||
).one()
|
||||
assert utxo.spent_txid == "external-spend"
|
||||
user = await session.get(User, user_id)
|
||||
# The original 20_000_000 is spent; the unrelated entry the "active"
|
||||
# connection also reported gets freshly credited alongside it.
|
||||
assert user.cached_balance_sats == 1_000_000
|
||||
|
||||
|
||||
async def test_refresh_user_does_not_mark_when_corroboration_fails(session_factory):
|
||||
"""The single most important case: our own connection alone reporting the
|
||||
UTXO missing must not be enough — before B-29 this zeroed the balance on one
|
||||
bad reply."""
|
||||
user_id = await _seed_funded_user(session_factory, username="carol", address="plm1qtest2")
|
||||
|
||||
still_there = [{"tx_hash": "dd" * 32, "tx_pos": 0}] + _UNRELATED_ENTRY
|
||||
others_factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": still_there, "third.example": still_there}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.client = _ActiveClient(_UNRELATED_ENTRY)
|
||||
|
||||
await listener.refresh_user(user_id, "scripthash")
|
||||
|
||||
async with session_factory() as session:
|
||||
utxo = (
|
||||
await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.txid == "dd" * 32))
|
||||
).one()
|
||||
assert utxo.spent_txid is None
|
||||
user = await session.get(User, user_id)
|
||||
# Untouched, plus the unrelated entry credited alongside it.
|
||||
assert user.cached_balance_sats == 21_000_000
|
||||
|
||||
|
||||
# --- B-31: resubscribing on reconnect must be bounded-concurrency and must not
|
||||
# block tip updates (and so an in-flight draw) for its entire duration. -----------
|
||||
|
||||
|
||||
def _fake_address(i: int) -> str:
|
||||
"""A real, decodable PLM bech32 P2WPKH address (address_to_scripthash
|
||||
actually parses it) — distinct per index, since User.address is unique."""
|
||||
from embit import script
|
||||
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
|
||||
payload = (i + 1).to_bytes(20, "big")
|
||||
return script.Script(b"\x00\x14" + payload).address(network=PLM_MAINNET)
|
||||
|
||||
|
||||
async def _seed_users(session_factory, count: int) -> None:
|
||||
async with session_factory() as session:
|
||||
for i in range(count):
|
||||
session.add(
|
||||
User(username=f"user{i}", password_hash="x", derivation_index=i, address=_fake_address(i))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_subscribe_all_users_bounds_concurrency(session_factory):
|
||||
"""B-31: at thousands of users, subscribing one at a time meant thousands of
|
||||
sequential round-trips. Concurrency must be bounded (not unlimited either —
|
||||
a huge user base shouldn't open thousands of simultaneous requests)."""
|
||||
user_count = 45
|
||||
await _seed_users(session_factory, user_count)
|
||||
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
in_flight = 0
|
||||
max_in_flight = 0
|
||||
calls = []
|
||||
|
||||
async def fake_subscribe_and_refresh(scripthash, user_id):
|
||||
nonlocal in_flight, max_in_flight
|
||||
in_flight += 1
|
||||
max_in_flight = max(max_in_flight, in_flight)
|
||||
calls.append(user_id)
|
||||
await asyncio.sleep(0) # yield, so genuinely-concurrent calls interleave
|
||||
in_flight -= 1
|
||||
|
||||
listener._subscribe_and_refresh = fake_subscribe_and_refresh
|
||||
|
||||
await listener._subscribe_all_users()
|
||||
|
||||
assert len(calls) == user_count
|
||||
assert 1 < max_in_flight <= 20 # bounded, and actually concurrent (not serial)
|
||||
|
||||
|
||||
async def test_subscribe_all_users_continues_past_a_failing_user(session_factory):
|
||||
await _seed_users(session_factory, 5)
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
succeeded = []
|
||||
|
||||
async def flaky_subscribe_and_refresh(scripthash, user_id):
|
||||
if user_id == 3:
|
||||
raise ConnectionResetError("dropped mid-subscribe")
|
||||
succeeded.append(user_id)
|
||||
|
||||
listener._subscribe_and_refresh = flaky_subscribe_and_refresh
|
||||
|
||||
await listener._subscribe_all_users() # must not raise
|
||||
|
||||
assert succeeded == [1, 2, 4, 5]
|
||||
|
||||
|
||||
class _FakeConnectClient:
|
||||
"""A minimally-real ElectrumClient double: enough of connect/subscribe/notify/
|
||||
ping/wait_closed/close to drive ElectrumListener._run_once end-to-end."""
|
||||
|
||||
def __init__(self, header: dict):
|
||||
self._header = header
|
||||
self._queues: dict[str, asyncio.Queue] = {}
|
||||
self._closed = asyncio.Event()
|
||||
|
||||
async def connect(self):
|
||||
pass
|
||||
|
||||
async def subscribe_headers(self):
|
||||
return self._header
|
||||
|
||||
def notifications(self, method: str) -> asyncio.Queue:
|
||||
return self._queues.setdefault(method, asyncio.Queue())
|
||||
|
||||
async def ping(self):
|
||||
pass
|
||||
|
||||
async def wait_closed(self):
|
||||
await self._closed.wait()
|
||||
|
||||
async def close(self):
|
||||
self._closed.set()
|
||||
|
||||
|
||||
async def _wait_until(predicate, *, timeout: float = 2.0, interval: float = 0.01) -> None:
|
||||
async def _poll():
|
||||
while not predicate():
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
await asyncio.wait_for(_poll(), timeout=timeout)
|
||||
|
||||
|
||||
async def test_run_once_publishes_the_client_only_once_the_tip_is_known(session_factory): # B-63
|
||||
"""`self.client is not None` is what every consumer reads as "the chain is
|
||||
reachable" — RoundScheduler._tick included, which then takes tip_height as the
|
||||
baseline a draw must find a *later* block than. Publishing the client before the
|
||||
first header left a window where the connection looked alive at tip_height 0, so a
|
||||
round closing inside it would have seeded its draw from a block mined before the
|
||||
close, whose hash was already public while bets were open."""
|
||||
header_hex = _mine_header("00" * 32)
|
||||
client = _FakeConnectClient({"height": 100, "hex": header_hex})
|
||||
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
|
||||
|
||||
seen_while_subscribing: list[tuple[object, int]] = []
|
||||
original_subscribe_headers = client.subscribe_headers
|
||||
|
||||
async def observing_subscribe_headers():
|
||||
# Exactly the window that used to be exposed: connected, but no header yet.
|
||||
seen_while_subscribing.append((listener.client, listener.tip_height))
|
||||
return await original_subscribe_headers()
|
||||
|
||||
client.subscribe_headers = observing_subscribe_headers
|
||||
|
||||
run_once_task = asyncio.create_task(listener._run_once(_ENDPOINTS[0]))
|
||||
try:
|
||||
await _wait_until(lambda: listener.client is not None)
|
||||
# Whenever the client is visible, the tip is already known — never 0.
|
||||
assert listener.tip_height == 100
|
||||
assert listener.tip_header_hex == header_hex
|
||||
assert seen_while_subscribing == [(None, 0)]
|
||||
finally:
|
||||
await client.close()
|
||||
await run_once_task
|
||||
|
||||
|
||||
async def test_run_once_refuses_a_session_whose_initial_header_carries_no_hex(session_factory): # B-64
|
||||
"""A hex-less header is ignored rather than fatal (a server that only pushes
|
||||
heights must not cost us the connection that also credits deposits) — but on the
|
||||
*first* header of a process there is no tip to fall back on, and publishing the
|
||||
client anyway would hand consumers a connection whose chain position is unknown,
|
||||
which is exactly what B-63 closed."""
|
||||
client = _FakeConnectClient({"height": 100}) # no "hex"
|
||||
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
|
||||
|
||||
# Bounded: without the guard _run_once goes on to wait on the session's tasks,
|
||||
# which nothing in this test ever ends — a regression must fail, not hang.
|
||||
with pytest.raises(HeaderValidationError):
|
||||
await asyncio.wait_for(listener._run_once(_ENDPOINTS[0]), timeout=5)
|
||||
|
||||
assert listener.client is None
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (0, None)
|
||||
|
||||
|
||||
async def test_run_once_leaves_no_client_published_when_the_first_header_is_rejected(
|
||||
session_factory,
|
||||
): # B-63
|
||||
"""A fabricated first header ends the session (B-28). The client must never
|
||||
become visible on the way out either, or consumers would briefly see a
|
||||
connection whose tip was never established."""
|
||||
client = _FakeConnectClient({"height": 100, "hex": "00" * 80}) # fails its own target
|
||||
|
||||
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
|
||||
|
||||
with pytest.raises(HeaderValidationError):
|
||||
await asyncio.wait_for(listener._run_once(_ENDPOINTS[0]), timeout=5)
|
||||
|
||||
assert listener.client is None
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (0, None)
|
||||
|
||||
|
||||
async def test_run_once_keeps_consuming_headers_while_resubscribing(session_factory):
|
||||
"""The core B-31 fix: before this, _subscribe_all_users ran to completion
|
||||
*before* the header-consuming task even started, so a reconnect with many
|
||||
users froze tip_height — and so _wait_for_next_block's draw wait — for the
|
||||
entire resubscribe. It must now keep advancing while resubscribing is still
|
||||
in flight."""
|
||||
await _seed_users(session_factory, 3)
|
||||
|
||||
header_hex = _mine_header("00" * 32)
|
||||
client = _FakeConnectClient({"height": 100, "hex": header_hex})
|
||||
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
|
||||
|
||||
subscribe_started = asyncio.Event()
|
||||
|
||||
async def blocked_subscribe_and_refresh(scripthash, user_id):
|
||||
subscribe_started.set()
|
||||
await asyncio.sleep(3600) # simulates a slow sweep; cancelled on cleanup
|
||||
|
||||
listener._subscribe_and_refresh = blocked_subscribe_and_refresh
|
||||
|
||||
run_once_task = asyncio.create_task(listener._run_once(_ENDPOINTS[0]))
|
||||
try:
|
||||
await asyncio.wait_for(subscribe_started.wait(), timeout=2)
|
||||
|
||||
# Resubscribing is still stuck mid-flight — but a new tip must still be
|
||||
# processed, proving the header consumer isn't blocked behind it.
|
||||
headers_queue = client.notifications("blockchain.headers.subscribe")
|
||||
next_header_hex = _mine_header(header_hex_to_block_hash(header_hex))
|
||||
await headers_queue.put([{"height": 101, "hex": next_header_hex}])
|
||||
await _wait_until(lambda: listener.tip_height == 101)
|
||||
|
||||
assert listener.tip_header_hex == next_header_hex
|
||||
finally:
|
||||
await client.close()
|
||||
await run_once_task
|
||||
|
||||
|
||||
# --- B-59: a credit must clear the same quorum a debit already had to (B-29) -----
|
||||
|
||||
_PHANTOM = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 200, "value": 5_000_000}]
|
||||
|
||||
|
||||
async def test_corroborate_utxo_credit_true_when_others_report_the_same_outpoint(session_factory):
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": _PHANTOM, "second.example": _PHANTOM, "third.example": _PHANTOM}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_credit("scripthash", "77" * 32, 0, 5_000_000) is True
|
||||
|
||||
|
||||
async def test_corroborate_utxo_credit_false_when_the_amount_differs(session_factory):
|
||||
"""Agreement is on the amount too, not just on the outpoint existing — the
|
||||
inflated number is the whole point of the attack."""
|
||||
smaller = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 200, "value": 1_000}]
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": smaller, "second.example": smaller, "third.example": _PHANTOM}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_credit("scripthash", "77" * 32, 0, 5_000_000) is False
|
||||
|
||||
|
||||
async def test_corroborate_utxo_credit_false_when_others_call_it_unconfirmed(session_factory):
|
||||
"""height <= 0 is Electrum's "still in the mempool" — a server that hasn't seen
|
||||
the block yet doesn't corroborate a 1-conf credit."""
|
||||
mempool = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 0, "value": 5_000_000}]
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": mempool, "second.example": mempool, "third.example": _PHANTOM}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_credit("scripthash", "77" * 32, 0, 5_000_000) is False
|
||||
|
||||
|
||||
async def test_corroborate_utxo_credit_false_when_nobody_responds(session_factory):
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": None, "second.example": None, "third.example": ConnectionRefusedError("down")}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_credit("scripthash", "77" * 32, 0, 5_000_000) is False
|
||||
|
||||
|
||||
async def test_refresh_user_does_not_credit_a_utxo_only_our_own_server_reports(session_factory):
|
||||
"""The mirror of B-29's headline case: before this, one hostile or broken
|
||||
server could inflate a user's displayed balance with an outpoint that doesn't
|
||||
exist. The credit is withheld, not lost — the next refresh retries it."""
|
||||
user_id = await _seed_funded_user(session_factory, username="dave", address="plm1qtest3")
|
||||
|
||||
others_factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": [], "third.example": []}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
# Our own connection reports the tracked UTXO plus a phantom one nobody else has.
|
||||
listener.client = _ActiveClient(
|
||||
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
|
||||
)
|
||||
|
||||
await listener.refresh_user(user_id, "scripthash")
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (
|
||||
await session.scalars(select(UtxoEvent).where(UtxoEvent.txid == "77" * 32))
|
||||
).all() == []
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 20_000_000 # unchanged, not inflated
|
||||
|
||||
|
||||
async def test_refresh_user_credits_once_the_others_corroborate(session_factory):
|
||||
user_id = await _seed_funded_user(session_factory, username="erin", address="plm1qtest4")
|
||||
|
||||
# first.example is the active endpoint, which _corroborate_majority never asks —
|
||||
# the quorum here is second + third.
|
||||
others_factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": _PHANTOM, "third.example": _PHANTOM}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.client = _ActiveClient(
|
||||
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
|
||||
)
|
||||
|
||||
await listener.refresh_user(user_id, "scripthash")
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 25_000_000
|
||||
@@ -0,0 +1,16 @@
|
||||
"""B-47: raw_tx_hex (a full raw signed transaction hex) and payload_json (an
|
||||
arbitrary audit payload) must stay `Text`, not a bare `String`/`VARCHAR` with
|
||||
no length -- SQLite and PostgreSQL accept that, but other backends (e.g.
|
||||
MySQL) require a length on VARCHAR and would reject it."""
|
||||
|
||||
from sqlalchemy import Text
|
||||
|
||||
from app.db.models import AuditLog, PendingTransaction
|
||||
|
||||
|
||||
def test_pending_transaction_raw_tx_hex_is_text():
|
||||
assert isinstance(PendingTransaction.__table__.c.raw_tx_hex.type, Text)
|
||||
|
||||
|
||||
def test_audit_log_payload_json_is_text():
|
||||
assert isinstance(AuditLog.__table__.c.payload_json.type, Text)
|
||||
@@ -4,7 +4,15 @@ from embit.bip32 import HDKey
|
||||
from embit.transaction import Transaction
|
||||
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction, estimate_vsize
|
||||
from app.wallet.psbt_builder import (
|
||||
MAX_PARTICIPANTS_PER_ROUND,
|
||||
MAX_PAYOUT_TX_INPUTS,
|
||||
MAX_TX_INPUTS,
|
||||
InsufficientFundsError,
|
||||
Utxo,
|
||||
build_payout_transaction,
|
||||
estimate_vsize,
|
||||
)
|
||||
|
||||
|
||||
def _key(seed_byte: int) -> HDKey:
|
||||
@@ -75,6 +83,55 @@ def test_payout_adds_change_output_when_pool_utxos_exceed_target():
|
||||
assert len(parsed.vout) == 3
|
||||
|
||||
|
||||
def test_payout_spends_more_utxos_than_a_user_transaction_may(): # B-52
|
||||
"""The pool holds one UTXO per bet, so a round with more participants than
|
||||
MAX_TX_INPUTS used to be impossible to pay out: select_utxos raised
|
||||
too_many_inputs, the round stayed "paying_out" retrying every 60s forever, and
|
||||
since no new round may open while one is active, the lottery stopped for good.
|
||||
The payout gets its own, far higher cap for exactly this reason."""
|
||||
pool_key = _key(40)
|
||||
pool_script = script.p2wpkh(pool_key.to_public())
|
||||
pool_address = pool_script.address(network=PLM_MAINNET)
|
||||
winner_address = script.p2wpkh(_key(41).to_public()).address(network=PLM_MAINNET)
|
||||
fee_address = script.p2wpkh(_key(42).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
# One 10 PLM bet per participant, one UTXO each, just past the user-facing cap.
|
||||
participants = MAX_TX_INPUTS + 1
|
||||
bet_sats = 1_000_000_000
|
||||
pool_amount = bet_sats * participants
|
||||
winner_share = pool_amount * 70 // 100
|
||||
commission = pool_amount - winner_share
|
||||
utxos = [Utxo(f"{i:064x}", 0, bet_sats) for i in range(participants)]
|
||||
|
||||
built = build_payout_transaction(
|
||||
signing_key=pool_key,
|
||||
from_script=pool_script,
|
||||
utxos=utxos,
|
||||
winner_address=winner_address,
|
||||
winner_share_sats=winner_share,
|
||||
fee_address=fee_address,
|
||||
commission_sats=commission,
|
||||
change_address=pool_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
assert len(built.spent_utxos) == participants # every bet had to be spent
|
||||
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
|
||||
assert len(parsed.vin) == participants
|
||||
assert built.fee_sats == estimate_vsize(participants, 3)
|
||||
assert built.winner_sats == winner_share - built.fee_sats
|
||||
assert built.commission_sats == commission # still untouched by the fee
|
||||
|
||||
|
||||
def test_payout_at_the_participant_cap_stays_well_inside_relay_limits(): # B-52
|
||||
"""MAX_PARTICIPANTS_PER_ROUND is only safe if the payout it implies is still a
|
||||
standard transaction. A full round is one input per bet plus the pool's own
|
||||
change, and relay policy refuses anything past 100 kvB."""
|
||||
inputs_needed = MAX_PARTICIPANTS_PER_ROUND + 1 # + one accumulated pool change UTXO
|
||||
assert inputs_needed <= MAX_PAYOUT_TX_INPUTS # headroom for pool change exists
|
||||
assert estimate_vsize(MAX_PAYOUT_TX_INPUTS, 3) < 100_000
|
||||
|
||||
|
||||
def test_payout_raises_when_winner_share_too_small():
|
||||
pool_key = _key(30)
|
||||
pool_script = script.p2wpkh(pool_key.to_public())
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""B-41: own_address_for is the single place tx/confirmation.py and
|
||||
tx/reconcile.py derive a PendingTransaction's own address from — a payout's
|
||||
address must always be the pool's, everything else the actual user's."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.models import User
|
||||
from app.tx.pending_address import own_address_for
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session_factory(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"xprv_encryption_key",
|
||||
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
|
||||
)
|
||||
from app.wallet import hd
|
||||
|
||||
hd._account_key = None
|
||||
hd.generate_master_key()
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
hd._account_key = None
|
||||
|
||||
|
||||
async def test_payout_uses_the_pool_address_regardless_of_user_id(session_factory):
|
||||
from app.wallet.hd import derive_pool_address
|
||||
|
||||
async with session_factory() as session:
|
||||
address = await own_address_for(session, "payout", None)
|
||||
|
||||
assert address == derive_pool_address()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["bet", "withdrawal"])
|
||||
async def test_bet_and_withdrawal_use_the_users_own_address(session_factory, kind):
|
||||
from app.wallet.hd import derive_user_address
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(username="alice", password_hash="x", derivation_index=3, address=derive_user_address(3))
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
address = await own_address_for(session, kind, user.id)
|
||||
|
||||
assert address == derive_user_address(3)
|
||||
@@ -1,9 +1,12 @@
|
||||
import pytest
|
||||
from embit import script
|
||||
from embit.bip32 import HDKey
|
||||
from embit.transaction import Transaction
|
||||
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import (
|
||||
MAX_PAYOUT_TX_INPUTS,
|
||||
MAX_TX_INPUTS,
|
||||
InsufficientFundsError,
|
||||
Utxo,
|
||||
build_signed_transaction,
|
||||
@@ -35,6 +38,49 @@ def test_select_utxos_raises_when_insufficient():
|
||||
select_utxos(utxos, target_sats=10_000_000)
|
||||
|
||||
|
||||
def test_select_utxos_never_exceeds_the_input_cap(): # B-48
|
||||
# 200 dust-ish UTXOs that together cover the target, but only past the cap.
|
||||
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
|
||||
with pytest.raises(InsufficientFundsError) as excinfo:
|
||||
select_utxos(utxos, target_sats=100_000 * MAX_TX_INPUTS + 1)
|
||||
assert excinfo.value.code == "too_many_inputs"
|
||||
assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS}
|
||||
|
||||
|
||||
def test_select_utxos_allows_exactly_the_input_cap():
|
||||
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
|
||||
selected, total = select_utxos(utxos, target_sats=100_000 * MAX_TX_INPUTS)
|
||||
assert len(selected) == MAX_TX_INPUTS
|
||||
assert total == 100_000 * MAX_TX_INPUTS
|
||||
|
||||
|
||||
def test_select_utxos_honours_a_caller_supplied_cap(): # B-52
|
||||
"""The cap is per-caller: MAX_TX_INPUTS protects a user from a fee eating into
|
||||
their own bet/withdrawal, while the payout needs MAX_PAYOUT_TX_INPUTS to be able
|
||||
to drain a pool holding one UTXO per bet at all."""
|
||||
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(MAX_TX_INPUTS + 10)]
|
||||
target = 100_000 * (MAX_TX_INPUTS + 10)
|
||||
|
||||
with pytest.raises(InsufficientFundsError):
|
||||
select_utxos(utxos, target_sats=target) # default cap: too fragmented
|
||||
|
||||
selected, total = select_utxos(utxos, target_sats=target, max_inputs=MAX_PAYOUT_TX_INPUTS)
|
||||
assert len(selected) == MAX_TX_INPUTS + 10
|
||||
assert total == target
|
||||
|
||||
|
||||
def test_select_utxos_still_caps_at_the_payout_limit(): # B-52
|
||||
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(MAX_PAYOUT_TX_INPUTS + 5)]
|
||||
with pytest.raises(InsufficientFundsError) as excinfo:
|
||||
select_utxos(
|
||||
utxos,
|
||||
target_sats=100_000 * (MAX_PAYOUT_TX_INPUTS + 1),
|
||||
max_inputs=MAX_PAYOUT_TX_INPUTS,
|
||||
)
|
||||
assert excinfo.value.code == "too_many_inputs"
|
||||
assert excinfo.value.params == {"max_inputs": MAX_PAYOUT_TX_INPUTS}
|
||||
|
||||
|
||||
def test_build_signed_transaction_deducts_fee_from_amount_not_change():
|
||||
signer = _key(1)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
@@ -68,14 +114,21 @@ def test_build_signed_transaction_deducts_fee_from_amount_not_change():
|
||||
assert len(parsed.vout) == 2
|
||||
|
||||
|
||||
def test_build_signed_transaction_omits_change_output_when_exact_amount():
|
||||
def test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change(): # B-62
|
||||
"""A single-output transaction is the one shape RBF cannot rescue: bump_fee has
|
||||
no change to shrink, and adding inputs is no answer either since this spends
|
||||
every UTXO the sender has. The bet is a fixed price, so it is refused rather
|
||||
than quietly reduced."""
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
signer = _key(3)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
my_address = from_script.address(network=PLM_MAINNET)
|
||||
to_address = script.p2wpkh(_key(4).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
utxos = [Utxo("22" * 32, 0, 10_000_000)] # exactly amount_sats, zero change
|
||||
built = build_signed_transaction(
|
||||
with pytest.raises(InsufficientFundsError) as excinfo:
|
||||
build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=utxos,
|
||||
@@ -84,12 +137,39 @@ def test_build_signed_transaction_omits_change_output_when_exact_amount():
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
assert built.change_sats == 0
|
||||
|
||||
assert excinfo.value.code == "balance_leaves_no_change"
|
||||
assert excinfo.value.params == {"required_extra_sats": DUST_LIMIT_SATS}
|
||||
|
||||
|
||||
def test_build_signed_transaction_can_reduce_the_amount_to_keep_change(): # B-62
|
||||
"""What "withdraw everything" does instead: move a dust limit less and stay
|
||||
fee-bumpable. The caller records the reduced amount as what was actually sent."""
|
||||
from embit.transaction import Transaction
|
||||
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
signer = _key(3)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
my_address = from_script.address(network=PLM_MAINNET)
|
||||
to_address = script.p2wpkh(_key(4).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=[Utxo("22" * 32, 0, 10_000_000)],
|
||||
to_address=to_address,
|
||||
amount_sats=10_000_000,
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
reduce_amount_to_keep_change=True,
|
||||
)
|
||||
|
||||
assert built.change_sats == DUST_LIMIT_SATS
|
||||
assert built.recipient_sats == 10_000_000 - DUST_LIMIT_SATS - built.fee_sats
|
||||
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
|
||||
assert len(parsed.vout) == 1
|
||||
assert len(parsed.vout) == 2
|
||||
assert built.recipient_sats + built.change_sats + built.fee_sats == 10_000_000
|
||||
|
||||
|
||||
def test_build_signed_transaction_raises_when_amount_smaller_than_fee():
|
||||
@@ -111,3 +191,81 @@ def test_build_signed_transaction_raises_when_amount_smaller_than_fee():
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
|
||||
def test_a_below_dust_change_output_is_never_created():
|
||||
"""B-06: `if change > 0` created change outputs below the dust limit, which makes
|
||||
the whole transaction unrelayable — the bet or withdrawal then failed at broadcast
|
||||
with an opaque error the user could do nothing about. B-62 changed the remedy
|
||||
(the change is topped up to the dust limit by moving slightly less, instead of
|
||||
being folded into the fee and leaving an unbumpable single-output tx) but not
|
||||
this rule: an output below DUST_LIMIT_SATS is never produced."""
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
signer = _key(1)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
|
||||
change_address = script.p2wpkh(signer.to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
amount = 10_000_000
|
||||
dust_change = DUST_LIMIT_SATS - 1
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=[Utxo("33" * 32, 0, amount + dust_change)],
|
||||
to_address=to_address,
|
||||
amount_sats=amount,
|
||||
change_address=change_address,
|
||||
fee_rate_sat_vb=1,
|
||||
reduce_amount_to_keep_change=True,
|
||||
)
|
||||
|
||||
tx = Transaction.parse(bytes.fromhex(built.raw_hex))
|
||||
assert len(tx.vout) == 2
|
||||
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
|
||||
assert built.change_sats == DUST_LIMIT_SATS
|
||||
# Nothing vanishes: inputs still equal outputs + fee, the recipient just gets
|
||||
# the one satoshi that was missing from a relayable change output.
|
||||
assert built.recipient_sats == amount - 1 - built.fee_sats
|
||||
assert built.recipient_sats + built.change_sats + built.fee_sats == amount + dust_change
|
||||
|
||||
|
||||
def test_change_at_the_dust_limit_is_still_paid_back():
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
signer = _key(1)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
|
||||
change_address = script.p2wpkh(signer.to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
amount = 10_000_000
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=[Utxo("44" * 32, 0, amount + DUST_LIMIT_SATS)],
|
||||
to_address=to_address,
|
||||
amount_sats=amount,
|
||||
change_address=change_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
assert built.change_sats == DUST_LIMIT_SATS
|
||||
assert len(Transaction.parse(bytes.fromhex(built.raw_hex)).vout) == 2
|
||||
|
||||
|
||||
def test_dust_sized_recipient_amount_is_refused():
|
||||
signer = _key(1)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
|
||||
change_address = script.p2wpkh(signer.to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
with pytest.raises(InsufficientFundsError):
|
||||
build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=[Utxo("55" * 32, 0, 1_000_000)],
|
||||
to_address=to_address,
|
||||
amount_sats=400, # after the ~160 sat fee this lands under the dust limit
|
||||
change_address=change_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""B-67: /qr/{address} must validate the address for real and must not render
|
||||
QR codes on the event loop for every anonymous request."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.routes import qr
|
||||
|
||||
|
||||
VALID_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
qr._render_png.cache_clear()
|
||||
app = FastAPI()
|
||||
app.include_router(qr.router)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def test_valid_address_renders_a_png(client):
|
||||
resp = await client.get(f"/qr/{VALID_ADDRESS}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
assert resp.content.startswith(b"\x89PNG")
|
||||
assert "max-age" in resp.headers["cache-control"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
"plm1qbogus0000000000000000000000000000000000", # right shape, broken checksum
|
||||
"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", # valid bech32, wrong chain
|
||||
"plm1q", # too short to be anything
|
||||
"P" + "a" * 40, # not bech32 at all
|
||||
"plm1" + "q" * 200, # over the length guard
|
||||
],
|
||||
)
|
||||
async def test_non_addresses_are_rejected_without_rendering(client, address, monkeypatch):
|
||||
def explode(*args, **kwargs): # pragma: no cover - must never run
|
||||
raise AssertionError("rendered a QR for a non-address")
|
||||
|
||||
monkeypatch.setattr(qr.qrcode, "make", explode)
|
||||
|
||||
resp = await client.get(f"/qr/{address}")
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"]["code"] == "invalid_address"
|
||||
|
||||
|
||||
async def test_render_is_memoized_per_address(client):
|
||||
calls = 0
|
||||
original = qr.qrcode.make
|
||||
|
||||
def counting_make(data, *args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return original(data, *args, **kwargs)
|
||||
|
||||
qr.qrcode.make = counting_make
|
||||
try:
|
||||
for _ in range(3):
|
||||
assert (await client.get(f"/qr/{VALID_ADDRESS}")).status_code == 200
|
||||
finally:
|
||||
qr.qrcode.make = original
|
||||
|
||||
assert calls == 1
|
||||
|
||||
|
||||
async def test_render_does_not_block_the_event_loop(client):
|
||||
"""The render runs in a threadpool, so the loop stays responsive while it does."""
|
||||
ticks = 0
|
||||
|
||||
async def ticker():
|
||||
nonlocal ticks
|
||||
while True:
|
||||
ticks += 1
|
||||
await asyncio.sleep(0)
|
||||
|
||||
original = qr.qrcode.make
|
||||
|
||||
def slow_make(data, *args, **kwargs):
|
||||
# Blocking sleep: on the event loop this would freeze the ticker.
|
||||
import time
|
||||
|
||||
time.sleep(0.05)
|
||||
return original(data, *args, **kwargs)
|
||||
|
||||
qr.qrcode.make = slow_make
|
||||
task = asyncio.create_task(ticker())
|
||||
try:
|
||||
assert (await client.get(f"/qr/{VALID_ADDRESS}")).status_code == 200
|
||||
finally:
|
||||
qr.qrcode.make = original
|
||||
task.cancel()
|
||||
|
||||
assert ticks > 1
|
||||
@@ -0,0 +1,140 @@
|
||||
"""B-56: RateLimiter._buckets is keyed by strings the caller chooses — any
|
||||
username, and (before B-54) any IP — and used to only ever grow. decay_seconds
|
||||
aged a bucket's *counter* but never removed the entry, so hammering login with
|
||||
random usernames was an unbounded memory leak. These tests pin both halves of
|
||||
the bound: spent entries are swept, and the dict has a hard cap.
|
||||
|
||||
The throttling behaviour itself is exercised end-to-end in test_auth.py; what
|
||||
matters here is that pruning never hands an attacker a free pass.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from app.auth.rate_limit import RateLimiter, RollingQuota
|
||||
|
||||
|
||||
def _limiter(**kwargs) -> RateLimiter:
|
||||
# sweep_interval_seconds=0 makes every record_failure sweep, so the tests are
|
||||
# deterministic instead of depending on wall-clock timing.
|
||||
kwargs.setdefault("sweep_interval_seconds", 0.0)
|
||||
return RateLimiter(**kwargs)
|
||||
|
||||
|
||||
def test_spent_buckets_are_swept_on_the_next_failure():
|
||||
limiter = _limiter(decay_seconds=0.05)
|
||||
for i in range(50):
|
||||
limiter.record_failure(f"user:{i}")
|
||||
assert len(limiter._buckets) == 50
|
||||
|
||||
time.sleep(0.06) # every bucket is now past decay_seconds and unlocked
|
||||
limiter.record_failure("user:fresh")
|
||||
|
||||
assert list(limiter._buckets) == ["user:fresh"]
|
||||
|
||||
|
||||
def test_a_bucket_still_locking_someone_out_is_never_swept():
|
||||
"""The whole point of the entry: evicting it would reset the backoff and let the
|
||||
attacker start over from a free attempt."""
|
||||
limiter = _limiter(threshold=1, base_delay=300.0, decay_seconds=0.05)
|
||||
limiter.record_failure("user:victim")
|
||||
assert limiter.retry_after("user:victim") > 0
|
||||
|
||||
time.sleep(0.06) # past decay_seconds, but the lockout is still running
|
||||
limiter.record_failure("user:someone-else")
|
||||
|
||||
assert "user:victim" in limiter._buckets
|
||||
assert limiter.retry_after("user:victim") > 0
|
||||
|
||||
|
||||
def test_the_dict_is_capped_even_within_one_sweep_interval():
|
||||
"""The cap is the backstop for a burst faster than the sweep interval, where
|
||||
nothing has had time to expire yet."""
|
||||
limiter = _limiter(max_buckets=10, sweep_interval_seconds=3600.0, decay_seconds=3600.0)
|
||||
for i in range(200):
|
||||
limiter.record_failure(f"ip:{i}")
|
||||
|
||||
assert len(limiter._buckets) <= 11 # the cap, plus the entry recorded after the last prune
|
||||
|
||||
|
||||
def test_the_cap_evicts_the_entries_closest_to_expiry_first():
|
||||
"""What gets dropped under pressure must buy an attacker the least. The deepest
|
||||
lockout — the one built up over the most failures, and so the one actually
|
||||
holding an attack back — has to be the last thing evicted, not collateral of a
|
||||
flood of one-failure keys."""
|
||||
limiter = _limiter(max_buckets=3, threshold=1, base_delay=1.0, max_delay=600.0)
|
||||
for _ in range(10):
|
||||
limiter.record_failure("ip:persistent") # backoff doubles: locked for ~512s
|
||||
for i in range(20):
|
||||
limiter.record_failure(f"ip:filler{i}") # one failure each: locked for ~1s
|
||||
|
||||
assert "ip:persistent" in limiter._buckets
|
||||
assert limiter.retry_after("ip:persistent") > 100
|
||||
|
||||
|
||||
def test_retry_after_drops_a_spent_bucket_it_looks_at():
|
||||
limiter = _limiter(decay_seconds=0.05)
|
||||
limiter.record_failure("user:probe")
|
||||
|
||||
time.sleep(0.06)
|
||||
assert limiter.retry_after("user:probe") == 0.0
|
||||
assert limiter._buckets == {}
|
||||
|
||||
|
||||
def test_pruning_does_not_reset_a_live_failure_count():
|
||||
"""A bucket below the lockout threshold still carries state worth keeping: the
|
||||
next failure must count as the second, not the first."""
|
||||
limiter = _limiter(threshold=2, base_delay=300.0, decay_seconds=3600.0)
|
||||
limiter.record_failure("user:a")
|
||||
limiter.record_failure("user:b") # triggers a sweep
|
||||
|
||||
limiter.record_failure("user:a")
|
||||
assert limiter.retry_after("user:a") > 0
|
||||
|
||||
|
||||
# --- B-58: registration is a quota, not failure backoff -------------------------
|
||||
|
||||
|
||||
def test_quota_allows_up_to_the_limit_then_asks_for_a_wait():
|
||||
quota = RollingQuota(limit=3, window_seconds=60.0)
|
||||
for _ in range(3):
|
||||
assert quota.retry_after("ip:1") == 0.0
|
||||
quota.record("ip:1")
|
||||
|
||||
wait = quota.retry_after("ip:1")
|
||||
assert 0 < wait <= 60.0
|
||||
|
||||
|
||||
def test_quota_is_per_key():
|
||||
quota = RollingQuota(limit=1, window_seconds=60.0)
|
||||
quota.record("ip:1")
|
||||
|
||||
assert quota.retry_after("ip:1") > 0
|
||||
assert quota.retry_after("ip:2") == 0.0
|
||||
|
||||
|
||||
def test_quota_frees_a_slot_once_the_oldest_event_leaves_the_window():
|
||||
"""The point of a rolling window over failure backoff: the caller waits exactly
|
||||
until there's room again, and waiting doesn't make the next wait longer."""
|
||||
quota = RollingQuota(limit=2, window_seconds=0.05)
|
||||
quota.record("ip:1")
|
||||
quota.record("ip:1")
|
||||
assert quota.retry_after("ip:1") > 0
|
||||
|
||||
time.sleep(0.06)
|
||||
assert quota.retry_after("ip:1") == 0.0
|
||||
|
||||
|
||||
def test_quota_prunes_spent_keys_and_caps_its_dict():
|
||||
"""Same bound as the failure limiter (B-56): caller-chosen keys, so both a sweep
|
||||
and a hard cap."""
|
||||
quota = RollingQuota(limit=1, window_seconds=0.05, sweep_interval_seconds=0.0)
|
||||
for i in range(50):
|
||||
quota.record(f"ip:{i}")
|
||||
time.sleep(0.06)
|
||||
quota.record("ip:fresh")
|
||||
assert list(quota._events) == ["ip:fresh"]
|
||||
|
||||
capped = RollingQuota(limit=1, window_seconds=3600.0, max_keys=10, sweep_interval_seconds=0.0)
|
||||
for i in range(200):
|
||||
capped.record(f"ip:{i}")
|
||||
assert len(capped._events) <= 11
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Regression tests for B-04 (and the "building" half of B-08): a transaction that
|
||||
never made it onto the chain must give the coins back instead of freezing them.
|
||||
|
||||
Also covers B-41: existence/reconciliation checks go through
|
||||
blockchain.scripthash.get_history rather than a verbose blockchain.transaction.get
|
||||
reply, so the fake clients below implement get_history.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from embit import script
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.models import AuditLog, PendingTransaction, RoundParticipant, User, UtxoEvent, Withdrawal
|
||||
from app.tx.reconcile import reconcile_once
|
||||
|
||||
|
||||
class UnknownTxClient:
|
||||
"""A server whose history for any address never includes our txid."""
|
||||
|
||||
async def get_history(self, scripthash: str) -> list[dict]:
|
||||
return []
|
||||
|
||||
|
||||
class KnownTxClient:
|
||||
"""A server whose history for the address includes our txid — mined or
|
||||
still in the mempool doesn't matter for existence, only for confirmation
|
||||
(which is tx/confirmation.py's concern, not reconcile.py's)."""
|
||||
|
||||
def __init__(self, txid: str = "betxid"):
|
||||
self._txid = txid
|
||||
|
||||
async def get_history(self, scripthash: str) -> list[dict]:
|
||||
return [{"tx_hash": self._txid, "height": 100}]
|
||||
|
||||
|
||||
class BrokenClient:
|
||||
"""A transport failure — says nothing about whether the tx exists."""
|
||||
|
||||
async def get_history(self, scripthash: str) -> list[dict]:
|
||||
raise ConnectionResetError("connection reset")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session_factory(tmp_path, monkeypatch):
|
||||
# own_address_for (B-41) derives each row's address via the HD wallet rather
|
||||
# than trusting the DB's address column, so reconcile_once now needs a real
|
||||
# master key set up — same bootstrap test_broadcast.py uses.
|
||||
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"xprv_encryption_key",
|
||||
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
|
||||
)
|
||||
from app.wallet import hd
|
||||
|
||||
hd._account_key = None
|
||||
hd.generate_master_key()
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
hd._account_key = None
|
||||
|
||||
|
||||
# A real (unsigned) transaction spending one input, built rather than hand-written
|
||||
# so it round-trips through Transaction.parse — that parse is how the reconciler
|
||||
# discovers which UTXOs to release, so a fixture the parser rejects would test
|
||||
# nothing.
|
||||
_TX_INPUT_TXID = "11" * 32
|
||||
_RAW_TX = (
|
||||
Transaction(
|
||||
vin=[TransactionInput(bytes.fromhex(_TX_INPUT_TXID), 0)],
|
||||
vout=[
|
||||
TransactionOutput(
|
||||
999_000_000, script.Script.from_address("plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd")
|
||||
)
|
||||
],
|
||||
)
|
||||
.serialize()
|
||||
.hex()
|
||||
)
|
||||
|
||||
|
||||
async def _seed_bet(
|
||||
session_factory,
|
||||
*,
|
||||
pending_status: str,
|
||||
participant_status: str,
|
||||
age_seconds: int,
|
||||
last_broadcast_age_seconds: int | None = None,
|
||||
derivation_index: int = 0,
|
||||
):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.wallet.hd import derive_user_address
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(
|
||||
username="u",
|
||||
password_hash="x",
|
||||
derivation_index=derivation_index,
|
||||
address=derive_user_address(derivation_index),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
session.add(
|
||||
UtxoEvent(
|
||||
user_id=user.id,
|
||||
txid=_TX_INPUT_TXID,
|
||||
vout=0,
|
||||
amount_sats=1_000_000_000,
|
||||
confirmed_height=10,
|
||||
spent_txid="betxid",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=1,
|
||||
user_id=user.id,
|
||||
bet_amount_sats=999_000_000,
|
||||
bet_txid="betxid",
|
||||
status=participant_status,
|
||||
)
|
||||
)
|
||||
# last_broadcast_age_seconds defaults to age_seconds (never bumped): the two
|
||||
# timestamps only diverge in the B-27 regression test below, which simulates
|
||||
# a tx that's been bumped recently but first appeared long ago.
|
||||
last_age = age_seconds if last_broadcast_age_seconds is None else last_broadcast_age_seconds
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="bet",
|
||||
round_id=1,
|
||||
user_id=user.id,
|
||||
current_txid="betxid",
|
||||
fee_rate_sat_vb=1,
|
||||
raw_tx_hex=_RAW_TX,
|
||||
status=pending_status,
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=age_seconds),
|
||||
last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=last_age),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return user.id
|
||||
|
||||
|
||||
async def test_abandons_a_building_bet_and_gives_the_coins_back(session_factory):
|
||||
"""The crash-mid-broadcast case: the tx isn't on the chain, so the UTXO must be
|
||||
released, the participant removed (they never entered the round) and the balance
|
||||
restored. Before this existed, spent_txid stayed set forever and the user simply
|
||||
lost the coins."""
|
||||
user_id = await _seed_bet(
|
||||
session_factory, pending_status="building", participant_status="building", age_seconds=300
|
||||
)
|
||||
|
||||
resolved = await reconcile_once(session_factory, UnknownTxClient())
|
||||
assert resolved == 1
|
||||
|
||||
async with session_factory() as session:
|
||||
utxo = (await session.scalars(select(UtxoEvent))).one()
|
||||
assert utxo.spent_txid is None # spendable again
|
||||
assert (await session.scalars(select(RoundParticipant))).all() == []
|
||||
row = (await session.scalars(select(PendingTransaction))).one()
|
||||
assert row.status == "failed"
|
||||
assert row.failure_reason
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 1_000_000_000
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert "pending_tx_abandoned" in events
|
||||
|
||||
|
||||
async def test_promotes_a_building_row_whose_tx_did_reach_the_chain(session_factory):
|
||||
"""We died after the broadcast, not before: the tx is real, so the rows must be
|
||||
finished rather than rolled back."""
|
||||
await _seed_bet(
|
||||
session_factory, pending_status="building", participant_status="building", age_seconds=300
|
||||
)
|
||||
|
||||
resolved = await reconcile_once(session_factory, KnownTxClient("betxid"))
|
||||
assert resolved == 1
|
||||
|
||||
async with session_factory() as session:
|
||||
row = (await session.scalars(select(PendingTransaction))).one()
|
||||
assert row.status == "pending"
|
||||
participant = (await session.scalars(select(RoundParticipant))).one()
|
||||
assert participant.status == "broadcast"
|
||||
utxo = (await session.scalars(select(UtxoEvent))).one()
|
||||
assert utxo.spent_txid == "betxid" # still legitimately spent
|
||||
|
||||
|
||||
async def test_leaves_a_young_building_row_alone(session_factory):
|
||||
"""A row written seconds ago may just be a broadcast still in flight."""
|
||||
await _seed_bet(
|
||||
session_factory, pending_status="building", participant_status="building", age_seconds=5
|
||||
)
|
||||
|
||||
assert await reconcile_once(session_factory, UnknownTxClient()) == 0
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (await session.scalars(select(PendingTransaction))).one().status == "building"
|
||||
|
||||
|
||||
async def test_leaves_a_recently_broadcast_pending_row_alone(session_factory):
|
||||
"""A broadcast tx gets a wide grace window — absence from one server's mempool
|
||||
is not proof of death, and the RBF bumper should get its attempts first."""
|
||||
await _seed_bet(
|
||||
session_factory, pending_status="pending", participant_status="broadcast", age_seconds=3600
|
||||
)
|
||||
|
||||
assert await reconcile_once(session_factory, UnknownTxClient()) == 0
|
||||
|
||||
|
||||
async def test_abandons_a_repeatedly_bumped_tx_despite_a_recent_last_broadcast(session_factory):
|
||||
"""B-27 regression: before last_broadcast_at existed, bump_fee overwrote
|
||||
broadcast_at on every bump, which is the same field the abandon grace period is
|
||||
measured from — so a tx first seen long ago but bumped minutes ago (exactly what
|
||||
a stuck-but-repeatedly-bumped tx looks like) reset its own clock forever and was
|
||||
never abandoned. The reconciler must still abandon it based on when it *first*
|
||||
appeared, ignoring how recently it was last bumped."""
|
||||
await _seed_bet(
|
||||
session_factory,
|
||||
pending_status="pending",
|
||||
participant_status="broadcast",
|
||||
age_seconds=7 * 3600, # first broadcast 7h ago — past the 6h abandon window
|
||||
last_broadcast_age_seconds=60, # bumped a minute ago
|
||||
)
|
||||
|
||||
assert await reconcile_once(session_factory, UnknownTxClient()) == 1
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (await session.scalars(select(PendingTransaction))).one().status == "failed"
|
||||
|
||||
|
||||
async def test_transport_failure_never_abandons_anything(session_factory):
|
||||
"""A dead connection says nothing about the transaction. Treating it as "gone"
|
||||
would release coins for transactions that are perfectly alive."""
|
||||
await _seed_bet(
|
||||
session_factory, pending_status="building", participant_status="building", age_seconds=300
|
||||
)
|
||||
|
||||
assert await reconcile_once(session_factory, BrokenClient()) == 0
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (await session.scalars(select(PendingTransaction))).one().status == "building"
|
||||
assert (await session.scalars(select(UtxoEvent))).one().spent_txid == "betxid"
|
||||
|
||||
|
||||
async def test_abandoned_withdrawal_is_marked_failed_and_kept(session_factory):
|
||||
"""Unlike a bet, a withdrawal is an instruction the user gave: the row stays so
|
||||
they can see it didn't go through."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.wallet.hd import derive_user_address
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(username="w", password_hash="x", derivation_index=1, address=derive_user_address(1))
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
session.add(
|
||||
UtxoEvent(
|
||||
user_id=user.id,
|
||||
txid=_TX_INPUT_TXID,
|
||||
vout=0,
|
||||
amount_sats=500_000_000,
|
||||
confirmed_height=10,
|
||||
spent_txid="wdtxid",
|
||||
)
|
||||
)
|
||||
withdrawal = Withdrawal(
|
||||
user_id=user.id,
|
||||
external_address="plm1qexternal",
|
||||
amount_requested_sats=400_000_000,
|
||||
amount_sent_sats=399_000_000,
|
||||
txid="wdtxid",
|
||||
status="broadcast",
|
||||
)
|
||||
session.add(withdrawal)
|
||||
await session.flush()
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="withdrawal",
|
||||
withdrawal_id=withdrawal.id,
|
||||
user_id=user.id,
|
||||
current_txid="wdtxid",
|
||||
fee_rate_sat_vb=1,
|
||||
raw_tx_hex=_RAW_TX,
|
||||
status="pending",
|
||||
broadcast_at=datetime.now(timezone.utc) - timedelta(days=1),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert await reconcile_once(session_factory, UnknownTxClient()) == 1
|
||||
|
||||
async with session_factory() as session:
|
||||
withdrawal = (await session.scalars(select(Withdrawal))).one()
|
||||
assert withdrawal.status == "failed"
|
||||
assert withdrawal.txid is None
|
||||
assert (await session.scalars(select(UtxoEvent))).one().spent_txid is None
|
||||
|
||||
|
||||
# --- B-41: existence checks now use get_history and share it across candidates
|
||||
# sharing the same address, instead of a per-tx verbose blockchain.transaction.get. --
|
||||
|
||||
|
||||
async def test_reconcile_once_caches_history_per_scripthash(session_factory):
|
||||
"""Two payout PendingTransaction rows always share the same pool address —
|
||||
fetching its history twice in one pass would be wasteful and, at scale
|
||||
across many candidates on one address, needlessly slow the whole tick."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
async with session_factory() as session:
|
||||
old = datetime.now(timezone.utc) - timedelta(hours=7)
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="payout", round_id=1, current_txid="payout-a", fee_rate_sat_vb=1,
|
||||
raw_tx_hex=_RAW_TX, status="pending", broadcast_at=old, last_broadcast_at=old,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="payout", round_id=2, current_txid="payout-b", fee_rate_sat_vb=1,
|
||||
raw_tx_hex=_RAW_TX, status="pending", broadcast_at=old, last_broadcast_at=old,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
class CountingClient:
|
||||
async def get_history(self, scripthash: str) -> list[dict]:
|
||||
call_count["n"] += 1
|
||||
return [{"tx_hash": "payout-a", "height": 100}, {"tx_hash": "payout-b", "height": 100}]
|
||||
|
||||
resolved = await reconcile_once(session_factory, CountingClient())
|
||||
|
||||
assert resolved == 0 # both exist — nothing to abandon or promote (already "pending")
|
||||
assert call_count["n"] == 1 # one call covered both rows sharing the pool address
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.rounds.events import RoundEventBroadcaster, RoundEventCapacityError
|
||||
from app.rounds.events import EVICTED, RoundEventBroadcaster, RoundEventCapacityError
|
||||
|
||||
|
||||
async def test_publish_wakes_up_subscriber():
|
||||
@@ -60,3 +60,71 @@ async def test_unsubscribe_frees_a_capacity_slot():
|
||||
|
||||
broadcaster.unsubscribe(queue)
|
||||
broadcaster.subscribe() # no longer at capacity
|
||||
|
||||
|
||||
# --- B-38: a single IP must not be able to exhaust the global cap and degrade
|
||||
# every other user to polling. ----------------------------------------------------
|
||||
|
||||
|
||||
async def test_subscribe_evicts_the_same_ips_oldest_connection_past_its_cap():
|
||||
"""Past MAX_SUBSCRIBERS_PER_IP, one more stream from the *same* IP evicts
|
||||
that IP's own oldest connection rather than being refused — bounds one
|
||||
source's footprint without an outright block."""
|
||||
broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=2)
|
||||
first = broadcaster.subscribe("1.2.3.4")
|
||||
second = broadcaster.subscribe("1.2.3.4")
|
||||
|
||||
third = broadcaster.subscribe("1.2.3.4") # past the per-IP cap of 2
|
||||
|
||||
assert await asyncio.wait_for(first.get(), timeout=1) is EVICTED
|
||||
assert second.empty() # untouched — only the oldest was evicted
|
||||
assert third is not None
|
||||
|
||||
|
||||
async def test_subscribe_does_not_evict_across_different_ips():
|
||||
"""A different IP hitting its own cap must never evict an unrelated IP's
|
||||
connection — that would let one abusive source crowd out real users, which
|
||||
is exactly what the global-only cap used to allow."""
|
||||
broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=1)
|
||||
other_ip_queue = broadcaster.subscribe("9.9.9.9")
|
||||
|
||||
broadcaster.subscribe("1.2.3.4")
|
||||
broadcaster.subscribe("1.2.3.4") # evicts 1.2.3.4's own oldest, not 9.9.9.9's
|
||||
|
||||
assert other_ip_queue.empty()
|
||||
|
||||
|
||||
async def test_subscribe_still_enforces_the_global_cap_across_many_ips():
|
||||
"""The per-IP cap doesn't replace the global backstop — spreading across
|
||||
enough distinct IPs must still eventually hit MAX_SUBSCRIBERS."""
|
||||
broadcaster = RoundEventBroadcaster(max_subscribers=3, max_per_ip=1)
|
||||
broadcaster.subscribe("1.1.1.1")
|
||||
broadcaster.subscribe("2.2.2.2")
|
||||
broadcaster.subscribe("3.3.3.3")
|
||||
|
||||
with pytest.raises(RoundEventCapacityError):
|
||||
broadcaster.subscribe("4.4.4.4")
|
||||
|
||||
|
||||
async def test_unsubscribe_clears_the_per_ip_tracking_too():
|
||||
"""Regression guard: unsubscribe must forget the queue's IP association, or
|
||||
a churned-through connection would keep counting against that IP's cap
|
||||
forever."""
|
||||
broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=1)
|
||||
queue = broadcaster.subscribe("1.2.3.4")
|
||||
broadcaster.unsubscribe(queue)
|
||||
|
||||
broadcaster.subscribe("1.2.3.4") # must not evict anything — nothing left to evict
|
||||
assert queue.empty()
|
||||
|
||||
|
||||
async def test_subscribe_defaults_to_a_shared_ip_when_none_given():
|
||||
"""Existing callers (and most tests) that don't care about IP isolation
|
||||
still share one implicit bucket rather than needing every call updated."""
|
||||
broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=2)
|
||||
first = broadcaster.subscribe()
|
||||
broadcaster.subscribe()
|
||||
|
||||
broadcaster.subscribe() # past the default bucket's cap of 2 — evicts, doesn't raise
|
||||
|
||||
assert await asyncio.wait_for(first.get(), timeout=1) is EVICTED
|
||||
|
||||
@@ -42,7 +42,7 @@ async def client(monkeypatch, tmp_path):
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router)
|
||||
app.include_router(rounds_router)
|
||||
app.state.electrum_listener = ElectrumListener(lambda: None, db_base.AsyncSessionLocal)
|
||||
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
@@ -94,3 +94,186 @@ async def test_user_played_true_only_for_participants(client):
|
||||
resp = await ac.get("/rounds/current") # no auth at all — logged-out chain-only view
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["user_played"] is False
|
||||
|
||||
|
||||
async def test_jackpot_comes_from_the_participants_actual_bets(client):
|
||||
"""B-11: the jackpot was participant_count * the *current* bet_amount_sats, which
|
||||
overstated it (each stored bet is already net of that bet's network fee) and
|
||||
silently rewrote the advertised jackpot of a round in progress whenever an
|
||||
operator edited the bet amount."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Round, RoundConfig, RoundParticipant
|
||||
|
||||
ac, session_factory = client
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
|
||||
session.add(Round(id=50, status="open"))
|
||||
await session.flush()
|
||||
# Two bets that actually paid 999_800_000 each (fee deducted), not 1_000_000_000.
|
||||
session.add(
|
||||
RoundParticipant(round_id=50, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
|
||||
)
|
||||
session.add(
|
||||
RoundParticipant(round_id=50, user_id=2, bet_amount_sats=999_800_000, bet_txid="b", status="confirmed")
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
body = (await ac.get("/rounds/current")).json()
|
||||
assert body["participant_count"] == 2
|
||||
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
|
||||
|
||||
# Changing the configured bet amount must not move a running round's jackpot.
|
||||
async with session_factory() as session:
|
||||
config = (await session.scalars(select(RoundConfig))).one()
|
||||
config.bet_amount_sats = 5_000_000_000
|
||||
await session.commit()
|
||||
|
||||
body = (await ac.get("/rounds/current")).json()
|
||||
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
|
||||
|
||||
|
||||
async def test_advertised_jackpot_covers_only_the_bets_that_will_be_paid(client): # B-65
|
||||
"""The draw picks from confirmed participants and the payout spends only their
|
||||
sats, so counting every participant row advertised a jackpot bigger than the one
|
||||
that would actually be paid — and let a player appear in the count and then
|
||||
vanish again if their bet was abandoned. The confirmed figures are the headline
|
||||
ones; what's in flight is reported alongside, never folded in."""
|
||||
from app.db.models import Round, RoundConfig, RoundParticipant
|
||||
|
||||
ac, session_factory = client
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
|
||||
session.add(Round(id=60, status="open"))
|
||||
await session.flush()
|
||||
session.add(
|
||||
RoundParticipant(round_id=60, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
|
||||
)
|
||||
# One mid-broadcast and one written but not yet broadcast: both in flight,
|
||||
# neither drawn from nor spent by the payout as things stand.
|
||||
session.add(
|
||||
RoundParticipant(round_id=60, user_id=2, bet_amount_sats=999_800_000, bet_txid="b", status="broadcast")
|
||||
)
|
||||
session.add(
|
||||
RoundParticipant(round_id=60, user_id=3, bet_amount_sats=999_800_000, bet_txid="c", status="building")
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
body = (await ac.get("/rounds/current")).json()
|
||||
|
||||
assert body["participant_count"] == 1
|
||||
assert body["jackpot_sats"] == 999_800_000 * 70 // 100
|
||||
# Inclusive, like pending_balance_sats — not a delta.
|
||||
assert body["pending_participant_count"] == 3
|
||||
assert body["pending_jackpot_sats"] == (999_800_000 * 3) * 70 // 100
|
||||
assert body["has_pending_bets"] is True
|
||||
|
||||
|
||||
async def test_no_pending_bets_reported_once_every_bet_has_confirmed(client): # B-65
|
||||
from app.db.models import Round, RoundConfig, RoundParticipant
|
||||
|
||||
ac, session_factory = client
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
|
||||
session.add(Round(id=61, status="open"))
|
||||
await session.flush()
|
||||
session.add(
|
||||
RoundParticipant(round_id=61, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
body = (await ac.get("/rounds/current")).json()
|
||||
|
||||
assert body["has_pending_bets"] is False
|
||||
assert body["pending_participant_count"] == body["participant_count"] == 1
|
||||
assert body["pending_jackpot_sats"] == body["jackpot_sats"]
|
||||
|
||||
|
||||
async def test_lottery_configured_flags_a_missing_fee_address(client): # B-66
|
||||
"""The frontend has to tell "the next round is coming" apart from "nothing is
|
||||
coming until the operator finishes setting this up" — the banner says different
|
||||
things, and only one of them is worth waiting for."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import RoundConfig
|
||||
|
||||
ac, session_factory = client
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address=""))
|
||||
await session.commit()
|
||||
|
||||
body = (await ac.get("/rounds/current")).json()
|
||||
assert body["lottery_configured"] is False
|
||||
assert body["lottery_paused"] is False # not a pause: a prerequisite that isn't met
|
||||
assert body["round_id"] is None # and indeed no round was opened
|
||||
|
||||
async with session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().fee_address = (
|
||||
"plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert (await ac.get("/rounds/current")).json()["lottery_configured"] is True
|
||||
|
||||
|
||||
async def test_draw_waiting_since_is_exposed_only_while_drawing(client):
|
||||
"""B-36: the "drawing" wait on a future block has no timeout, so the frontend
|
||||
needs draw_waiting_since to show "still waiting" instead of implying a bounded
|
||||
countdown. It must not leak for any other status, where it's meaningless."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.db.models import Round, RoundConfig
|
||||
|
||||
ac, session_factory = client
|
||||
|
||||
started_at = datetime(2026, 7, 27, 10, 0, 0)
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address=""))
|
||||
session.add(Round(id=60, status="drawing", drawing_started_at=started_at))
|
||||
await session.commit()
|
||||
|
||||
body = (await ac.get("/rounds/current")).json()
|
||||
assert body["draw_waiting_since"] == "2026-07-27T10:00:00+00:00"
|
||||
|
||||
async with session_factory() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
round_ = (await session.scalars(select(Round).where(Round.id == 60))).one()
|
||||
round_.status = "paying_out"
|
||||
await session.commit()
|
||||
|
||||
body = (await ac.get("/rounds/current")).json()
|
||||
assert body["draw_waiting_since"] is None
|
||||
|
||||
|
||||
async def test_unhandled_errors_use_the_structured_detail_shape(client):
|
||||
"""B-24: the catch-all handler answered with a bare-string `detail`, while
|
||||
app/api/errors.py documents detail as {"code", "message", "params"}. Clients then
|
||||
had to special-case exactly the responses they understand least."""
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import log_unhandled_exception
|
||||
|
||||
app = FastAPI()
|
||||
app.add_exception_handler(Exception, log_unhandled_exception)
|
||||
|
||||
@app.get("/boom")
|
||||
async def boom():
|
||||
raise RuntimeError("secret internal detail")
|
||||
|
||||
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
resp = await ac.get("/boom")
|
||||
|
||||
assert resp.status_code == 500
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["code"] == "internal_error"
|
||||
assert detail["message"] == "internal server error"
|
||||
assert detail["params"] == {}
|
||||
# The exception text belongs in logs/app.log, never in the response body.
|
||||
assert "secret internal detail" not in resp.text
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -8,6 +9,7 @@ from app.db.models import Round, RoundConfig
|
||||
from app.rounds.service import get_active_round, open_new_round_if_needed
|
||||
|
||||
ROUND_COOLDOWN_SECONDS = 30 # matches RoundConfig.round_cooldown_seconds' column default
|
||||
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -15,6 +17,14 @@ async def session_factory():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# B-66: no fee address means no round may open at all, which would make most of
|
||||
# the assertions below pass for the wrong reason. Seeded once here so every test
|
||||
# in this file runs against an instance that could actually pay a winner, and the
|
||||
# ones that care about other config values edit this same single row.
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||
session.add(RoundConfig(fee_address=_FEE_ADDRESS))
|
||||
await session.commit()
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
|
||||
@@ -87,7 +97,7 @@ async def test_withholds_new_round_while_paused(session_factory):
|
||||
stale_close = datetime.now(timezone.utc) - timedelta(seconds=ROUND_COOLDOWN_SECONDS + 1)
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="closed", closed_at=stale_close))
|
||||
session.add(RoundConfig(fee_address="", paused=True))
|
||||
(await session.scalars(select(RoundConfig))).one().paused = True
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
@@ -98,10 +108,183 @@ async def test_withholds_new_round_while_paused(session_factory):
|
||||
async def test_pause_does_not_interrupt_a_round_in_progress(session_factory):
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="drawing"))
|
||||
session.add(RoundConfig(fee_address="", paused=True))
|
||||
(await session.scalars(select(RoundConfig))).one().paused = True
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
returned = await open_new_round_if_needed(session)
|
||||
assert returned is not None
|
||||
assert returned.status == "drawing"
|
||||
|
||||
|
||||
async def test_losing_the_open_race_reuses_the_winning_round(session_factory, monkeypatch):
|
||||
"""B-09: open_new_round_if_needed was a read-then-insert with no lock, called from
|
||||
both the scheduler and every place_bet, so two callers could both see "no active
|
||||
round" and insert one — and a second stuck "open" row blocks every future round,
|
||||
since get_active_round matches on status.
|
||||
|
||||
The race is forced deterministically: the round already exists and is committed,
|
||||
but this caller's first look is made to miss it (exactly what the loser of the
|
||||
race sees). The insert then hits ix_rounds_single_active, and the caller must
|
||||
recover by using the winner's round instead of raising at its caller — a bet must
|
||||
not fail because a scheduler tick beat it by a millisecond.
|
||||
"""
|
||||
from app.rounds import service as service_module
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="open"))
|
||||
await session.commit()
|
||||
|
||||
real_get_active_round = service_module.get_active_round
|
||||
calls = {"n": 0}
|
||||
|
||||
async def blind_first_look(session):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return None # what the loser of the race sees
|
||||
return await real_get_active_round(session)
|
||||
|
||||
monkeypatch.setattr(service_module, "get_active_round", blind_first_look)
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await service_module.open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
|
||||
assert round_ is not None # recovered, didn't raise
|
||||
async with session_factory() as session:
|
||||
rounds = (await session.scalars(select(Round))).all()
|
||||
assert len(rounds) == 1, f"expected one round, got {[(r.id, r.status) for r in rounds]}"
|
||||
assert round_.id == rounds[0].id # the winner's round, not a second one
|
||||
|
||||
|
||||
async def test_the_database_refuses_a_second_active_round(session_factory):
|
||||
"""The guarantee itself, independent of the application code path."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="open"))
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="drawing"))
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_closed_rounds_can_coexist_with_an_active_one(session_factory):
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="closed"))
|
||||
session.add(Round(status="closed"))
|
||||
session.add(Round(status="open"))
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
assert len((await session.scalars(select(Round))).all()) == 3
|
||||
|
||||
|
||||
# --- B-66: no round opens on an instance that could not pay its winner ------------
|
||||
|
||||
|
||||
async def test_withholds_new_round_while_no_fee_address_is_configured(session_factory): # B-66
|
||||
"""A fresh instance starts with no fee_address, and the payout pays the 30%
|
||||
commission to it — so a round opened without one takes bets, confirms them, and
|
||||
only then discovers it cannot be paid, wedging in "paying_out" with money already
|
||||
in the pool and needing manual recovery. Every round, until an operator notices."""
|
||||
async with session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().fee_address = ""
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
assert await open_new_round_if_needed(session) is None
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (await session.scalars(select(Round))).all() == [] # nothing opened at all
|
||||
|
||||
|
||||
async def test_opens_a_round_as_soon_as_a_fee_address_is_set(session_factory): # B-66
|
||||
async with session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().fee_address = ""
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
assert await open_new_round_if_needed(session) is None
|
||||
|
||||
async with session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().fee_address = _FEE_ADDRESS
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
assert round_ is not None and round_.status == "open"
|
||||
|
||||
|
||||
async def test_a_round_in_progress_survives_the_fee_address_being_cleared(session_factory): # B-66
|
||||
"""Same rule as pausing: an unmet prerequisite only stops the *next* round. The
|
||||
one in progress keeps its participants and still has to be drawn and paid — and
|
||||
clearing the address is exactly the mistake an operator might make mid-round."""
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="open"))
|
||||
(await session.scalars(select(RoundConfig))).one().fee_address = ""
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
returned = await open_new_round_if_needed(session)
|
||||
assert returned is not None and returned.status == "open"
|
||||
|
||||
|
||||
def test_rounds_can_open_ignores_a_whitespace_only_fee_address(): # B-66
|
||||
from app.rounds.service import rounds_can_open
|
||||
|
||||
assert rounds_can_open(RoundConfig(fee_address=_FEE_ADDRESS)) is True
|
||||
assert rounds_can_open(RoundConfig(fee_address="")) is False
|
||||
assert rounds_can_open(RoundConfig(fee_address=" ")) is False
|
||||
|
||||
|
||||
# --- B-61: a round runs by the timing it opened with, not by the live config ------
|
||||
|
||||
|
||||
async def test_a_new_round_snapshots_the_current_config_timing(session_factory):
|
||||
async with session_factory() as session:
|
||||
config = (await session.scalars(select(RoundConfig))).one()
|
||||
config.round_duration_seconds = 120
|
||||
config.round_cooldown_seconds = 45
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
|
||||
assert round_.duration_seconds == 120
|
||||
assert round_.cooldown_seconds == 45
|
||||
|
||||
|
||||
async def test_round_accepts_bets_uses_the_rounds_own_duration(session_factory):
|
||||
from app.rounds.service import round_accepts_bets
|
||||
|
||||
opened_at = datetime.now(timezone.utc) - timedelta(seconds=100)
|
||||
still_open = Round(status="open", opened_at=opened_at, duration_seconds=600)
|
||||
expired = Round(status="open", opened_at=opened_at, duration_seconds=60)
|
||||
|
||||
assert round_accepts_bets(still_open) is True
|
||||
assert round_accepts_bets(expired) is False
|
||||
|
||||
|
||||
async def test_cooldown_comes_from_the_round_that_closed(session_factory):
|
||||
"""The gap a closing round announced is the gap that's honoured: shortening
|
||||
round_cooldown_seconds afterwards must not open the next round early, nor
|
||||
lengthening it hold the lottery shut."""
|
||||
async with session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().round_cooldown_seconds = 0 # just lowered
|
||||
session.add(
|
||||
Round(
|
||||
status="closed",
|
||||
opened_at=datetime.now(timezone.utc) - timedelta(seconds=200),
|
||||
closed_at=datetime.now(timezone.utc) - timedelta(seconds=10),
|
||||
cooldown_seconds=300, # what that round ran with
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
assert await open_new_round_if_needed(session) is None # still cooling down
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.models import Round, RoundConfig
|
||||
from app.rounds.scheduler import RoundScheduler
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User
|
||||
from app.rounds.scheduler import RoundScheduler, _reserved_payout_outpoints
|
||||
from app.wallet.psbt_builder import MAX_TX_INPUTS
|
||||
|
||||
|
||||
class FakeListener:
|
||||
@@ -43,7 +46,8 @@ async def test_tick_closes_round_with_no_participants_once_due(session_factory,
|
||||
past = datetime.now(timezone.utc) - timedelta(seconds=10)
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address="", round_duration_seconds=1))
|
||||
session.add(Round(status="open", opened_at=past))
|
||||
# B-61: the deadline comes from the round's own snapshot, not from the config.
|
||||
session.add(Round(status="open", opened_at=past, duration_seconds=1))
|
||||
await session.commit()
|
||||
|
||||
scheduler = RoundScheduler(session_factory, FakeListener())
|
||||
@@ -52,3 +56,556 @@ async def test_tick_closes_round_with_no_participants_once_due(session_factory,
|
||||
async with session_factory() as session:
|
||||
round_ = (await session.scalars(select(Round))).one()
|
||||
assert round_.status == "closed"
|
||||
|
||||
|
||||
# --- B-25: the payout must be persisted before it is broadcast, like bets/withdrawals ---
|
||||
|
||||
# A real, reusable PLM bech32 address so build_payout_transaction's
|
||||
# script.Script.from_address(...) succeeds — this is not a value the scheduler
|
||||
# validates itself (that's the admin panel's job for fee_address), it just needs to
|
||||
# actually decode.
|
||||
_WINNER_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
|
||||
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
||||
|
||||
_POOL_AMOUNT_SATS = 10_000_000_000 # 100 PLM
|
||||
|
||||
|
||||
class FakePayoutClient:
|
||||
def __init__(self, entries, *, fail_broadcast=False):
|
||||
self._entries = entries
|
||||
self._fail_broadcast = fail_broadcast
|
||||
self.broadcasted: list[str] = []
|
||||
|
||||
async def listunspent(self, scripthash):
|
||||
return self._entries
|
||||
|
||||
async def broadcast(self, raw_tx_hex):
|
||||
if self._fail_broadcast:
|
||||
raise RuntimeError("node rejected the transaction")
|
||||
self.broadcasted.append(raw_tx_hex)
|
||||
return "network-txid"
|
||||
|
||||
|
||||
class FakePayoutListener:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def payout_session_factory(tmp_path, monkeypatch):
|
||||
"""Same master-key bootstrap as test_broadcast.py's fixture: _trigger_payout
|
||||
needs a real pool key to sign with."""
|
||||
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"xprv_encryption_key",
|
||||
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
|
||||
)
|
||||
from app.wallet import hd
|
||||
|
||||
hd._account_key = None
|
||||
hd.generate_master_key()
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
hd._account_key = None
|
||||
|
||||
|
||||
async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int:
|
||||
async with session_factory() as session:
|
||||
winner = User(username="winner", password_hash="x", derivation_index=0, address=_WINNER_ADDRESS)
|
||||
session.add(winner)
|
||||
await session.flush()
|
||||
session.add(RoundConfig(fee_address=_FEE_ADDRESS, fee_rate_sat_vb=1))
|
||||
session.add(
|
||||
Round(
|
||||
id=round_id,
|
||||
status="paying_out",
|
||||
pool_amount_sats=_POOL_AMOUNT_SATS,
|
||||
winner_user_id=winner.id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return winner.id
|
||||
|
||||
|
||||
async def test_trigger_payout_persists_before_broadcasting(payout_session_factory):
|
||||
"""The happy path: payout_txid and a PendingTransaction must exist once the
|
||||
broadcast succeeds, promoted from "building" to "pending" — the two-phase write
|
||||
that used to be missing entirely (B-25)."""
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
entries = [{"tx_hash": "33" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
|
||||
client = FakePayoutClient(entries)
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
assert client.broadcasted
|
||||
async with payout_session_factory() as session:
|
||||
round_ = await session.get(Round, 1)
|
||||
assert round_.payout_txid is not None
|
||||
assert round_.winner_amount_sats and round_.fee_amount_sats
|
||||
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
assert pending.kind == "payout"
|
||||
assert pending.status == "pending"
|
||||
assert pending.current_txid == round_.payout_txid
|
||||
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert "payout_sent" in events
|
||||
|
||||
|
||||
async def test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_inputs(
|
||||
payout_session_factory,
|
||||
): # B-52
|
||||
"""End-to-end shape of the deadlock this fixes: the pool holds one UTXO per bet,
|
||||
so a round past MAX_TX_INPUTS participants could not be paid at all — the build
|
||||
failed with too_many_inputs, the round stayed "paying_out" retrying every 60s,
|
||||
and no new round could ever open behind it. It must now broadcast normally."""
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
|
||||
participants = MAX_TX_INPUTS + 1
|
||||
bet_sats = _POOL_AMOUNT_SATS // participants
|
||||
entries = [
|
||||
{"tx_hash": f"{i:064x}", "tx_pos": 0, "height": 10, "value": bet_sats}
|
||||
for i in range(participants)
|
||||
]
|
||||
# The pool's total must cover the round's recorded pool_amount_sats, exactly as
|
||||
# on-chain: integer division above leaves a remainder, so top the last one up.
|
||||
entries[-1]["value"] += _POOL_AMOUNT_SATS - bet_sats * participants
|
||||
client = FakePayoutClient(entries)
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
assert client.broadcasted
|
||||
async with payout_session_factory() as session:
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
assert pending.status == "pending"
|
||||
assert len(Transaction.parse(bytes.fromhex(pending.raw_tx_hex)).vin) == participants
|
||||
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert "payout_sent" in events
|
||||
assert "payout_failed" not in events
|
||||
|
||||
|
||||
async def test_trigger_payout_broadcast_failure_leaves_a_recoverable_row(payout_session_factory):
|
||||
"""Before B-25, a broadcast rejection here left nothing behind — no payout_txid,
|
||||
no PendingTransaction — because everything was persisted only after the
|
||||
broadcast. Now the intent is already durable, so the reconciler has something to
|
||||
resolve instead of the round being stuck with zero trace of what was attempted."""
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
entries = [{"tx_hash": "44" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
|
||||
client = FakePayoutClient(entries, fail_broadcast=True)
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
assert not client.broadcasted
|
||||
async with payout_session_factory() as session:
|
||||
round_ = await session.get(Round, 1)
|
||||
assert round_.payout_txid is not None # durable, even though the broadcast failed
|
||||
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
assert pending.kind == "payout"
|
||||
assert pending.status == "building" # not lost — the reconciler resolves this
|
||||
assert pending.current_txid == round_.payout_txid
|
||||
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert "payout_failed" in events
|
||||
|
||||
|
||||
async def test_trigger_payout_skips_when_already_in_flight(payout_session_factory):
|
||||
"""A second call for a round that already has a non-terminal payout
|
||||
PendingTransaction must not build (and broadcast) another one — that would pay
|
||||
the winner twice."""
|
||||
winner_id = await _seed_paying_out_round(payout_session_factory)
|
||||
async with payout_session_factory() as session:
|
||||
round_ = await session.get(Round, 1)
|
||||
round_.payout_txid = "already-sent-txid"
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="payout",
|
||||
round_id=1,
|
||||
current_txid="already-sent-txid",
|
||||
fee_rate_sat_vb=1,
|
||||
raw_tx_hex="00",
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
entries = [{"tx_hash": "55" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
|
||||
client = FakePayoutClient(entries)
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
assert not client.broadcasted
|
||||
async with payout_session_factory() as session:
|
||||
assert (await session.scalars(select(PendingTransaction))).all() # still just the one seeded
|
||||
rows = (await session.scalars(select(PendingTransaction))).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].current_txid == "already-sent-txid"
|
||||
|
||||
|
||||
async def test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payout(payout_session_factory):
|
||||
"""A payout still "building"/"pending" for some round — most plausibly a stale
|
||||
one the reconciler hasn't abandoned yet — must keep its inputs off the table for
|
||||
a fresh payout attempt, or the same pool coins could be spent twice."""
|
||||
from embit import script
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
|
||||
reserved_txid = "66" * 32
|
||||
raw_tx = (
|
||||
Transaction(
|
||||
vin=[TransactionInput(bytes.fromhex(reserved_txid), 2)],
|
||||
vout=[TransactionOutput(1_000_000, script.Script.from_address(_WINNER_ADDRESS))],
|
||||
)
|
||||
.serialize()
|
||||
.hex()
|
||||
)
|
||||
async with payout_session_factory() as session:
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="payout",
|
||||
round_id=99,
|
||||
current_txid="stale-payout-txid",
|
||||
fee_rate_sat_vb=1,
|
||||
raw_tx_hex=raw_tx,
|
||||
status="building",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
reserved = await _reserved_payout_outpoints(session)
|
||||
|
||||
assert reserved == {(reserved_txid, 2)}
|
||||
|
||||
|
||||
# --- B-26: a "paying_out" round must retry its payout automatically ---------------
|
||||
|
||||
|
||||
async def test_trigger_payout_logs_a_failure_when_not_connected(payout_session_factory):
|
||||
"""Before B-26, this early return logged nothing beyond a log line — invisible
|
||||
in /admin and unusable as a signal for an automatic retry."""
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client=None))
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
async with payout_session_factory() as session:
|
||||
entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "payout_failed"))).all()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].payload_json.count("electrum client not connected") == 1
|
||||
|
||||
|
||||
async def test_trigger_payout_logs_a_failure_when_fee_address_missing(payout_session_factory):
|
||||
winner_id = await _seed_paying_out_round(payout_session_factory)
|
||||
async with payout_session_factory() as session:
|
||||
config = (await session.scalars(select(RoundConfig))).one()
|
||||
config.fee_address = ""
|
||||
await session.commit()
|
||||
|
||||
entries = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(FakePayoutClient(entries)))
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
async with payout_session_factory() as session:
|
||||
entry = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "payout_failed"))).one()
|
||||
assert "no fee_address configured" in entry.payload_json
|
||||
assert entry.user_id == winner_id
|
||||
|
||||
|
||||
async def test_tick_retries_a_stuck_paying_out_round_with_no_recent_failure(payout_session_factory):
|
||||
"""The scenario B-26 exists for: a round stuck in "paying_out" (a prior failure,
|
||||
or a process restart mid-payout) with no non-terminal PendingTransaction. A
|
||||
fresh tick must retry rather than leaving it wedged forever."""
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
entries = [{"tx_hash": "88" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
|
||||
client = FakePayoutClient(entries)
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
|
||||
|
||||
await scheduler._tick()
|
||||
|
||||
assert client.broadcasted
|
||||
async with payout_session_factory() as session:
|
||||
round_ = await session.get(Round, 1)
|
||||
assert round_.payout_txid is not None
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
assert pending.status == "pending"
|
||||
|
||||
|
||||
async def test_tick_throttles_retry_after_a_recent_payout_failure(payout_session_factory):
|
||||
"""A payout that just failed must not be retried on the very next tick, or a
|
||||
persistently-broken payout (e.g. no fee_address) would spam a retry — and a
|
||||
fresh payout_failed audit entry — every _TICK_INTERVAL_SECONDS."""
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
async with payout_session_factory() as session:
|
||||
session.add(
|
||||
AuditLog(
|
||||
event_type="payout_failed",
|
||||
payload_json='{"round_id": 1, "reason": "insufficient pool UTXOs"}',
|
||||
round_id=1,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
entries = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
|
||||
client = FakePayoutClient(entries)
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
|
||||
|
||||
await scheduler._tick()
|
||||
|
||||
assert not client.broadcasted
|
||||
async with payout_session_factory() as session:
|
||||
assert (await session.scalars(select(PendingTransaction))).all() == []
|
||||
|
||||
|
||||
async def test_tick_retries_once_the_throttle_window_has_elapsed(payout_session_factory):
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
async with payout_session_factory() as session:
|
||||
session.add(
|
||||
AuditLog(
|
||||
event_type="payout_failed",
|
||||
payload_json='{"round_id": 1, "reason": "insufficient pool UTXOs"}',
|
||||
round_id=1,
|
||||
created_at=datetime.now(timezone.utc) - timedelta(seconds=120),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
entries = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
|
||||
client = FakePayoutClient(entries)
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
|
||||
|
||||
await scheduler._tick()
|
||||
|
||||
assert client.broadcasted
|
||||
async with payout_session_factory() as session:
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
assert pending.status == "pending"
|
||||
|
||||
|
||||
# --- B-28: the draw must not seed itself from an uncorroborated header -----------
|
||||
|
||||
|
||||
class CorroboratingListener:
|
||||
"""A fake listener whose tip advances the moment a corroboration attempt
|
||||
fails, simulating a further block arriving — lets tests drive
|
||||
_wait_for_next_block's retry loop deterministically without real sleeps."""
|
||||
|
||||
def __init__(self, *, responses: dict[int, bool], advance_to: dict[int, tuple[int, str]] | None = None):
|
||||
self.tip_height, self.tip_header_hex = next(iter(responses)), "aa"
|
||||
self._responses = dict(responses)
|
||||
self._advance_to = advance_to or {}
|
||||
self.corroboration_calls: list[int] = []
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
self.corroboration_calls.append(height)
|
||||
result = self._responses[height]
|
||||
if not result and height in self._advance_to:
|
||||
self.tip_height, self.tip_header_hex = self._advance_to[height]
|
||||
return result
|
||||
|
||||
|
||||
async def test_wait_for_next_block_accepts_an_immediately_corroborated_block(session_factory):
|
||||
listener = CorroboratingListener(responses={101: True})
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
|
||||
height, block_hash = await scheduler._wait_for_next_block(
|
||||
round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
assert height == 101
|
||||
assert listener.corroboration_calls == [101]
|
||||
|
||||
|
||||
async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_factory):
|
||||
"""B-28: an uncorroborated header must never be used — the wait keeps going
|
||||
until a later block's header *is* corroborated, logging why each time."""
|
||||
listener = CorroboratingListener(
|
||||
responses={101: False, 102: True}, advance_to={101: (102, "bb")}
|
||||
)
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
|
||||
height, block_hash = await scheduler._wait_for_next_block(
|
||||
round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
assert height == 102
|
||||
assert listener.corroboration_calls == [101, 102]
|
||||
|
||||
async with session_factory() as session:
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert events == ["draw_header_corroboration_failed"]
|
||||
|
||||
|
||||
# --- B-63: an unknown tip at closing time must not become the draw's seed --------
|
||||
|
||||
|
||||
class LateTipListener:
|
||||
"""A listener that doesn't know the tip yet and learns it only once asked —
|
||||
the state the old code could observe while `client` already looked alive."""
|
||||
|
||||
def __init__(self, *, learns: tuple[int, str], then_advances_to: tuple[int, str]):
|
||||
self.tip_height = 0
|
||||
self.tip_header_hex = None
|
||||
self._learns = learns
|
||||
self._then_advances_to = then_advances_to
|
||||
self.corroboration_calls: list[int] = []
|
||||
|
||||
def learn_tip(self) -> None:
|
||||
self.tip_height, self.tip_header_hex = self._learns
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
self.corroboration_calls.append(height)
|
||||
return True
|
||||
|
||||
|
||||
async def test_wait_for_next_block_never_seeds_the_draw_from_a_pre_close_block(
|
||||
session_factory, monkeypatch
|
||||
): # B-63
|
||||
"""A tip_at_close of 0 means the tip was *unknown* when the round closed, not
|
||||
that the chain was at height zero. The first header we then learn describes a
|
||||
block that may well predate the close — whose hash was public while bets were
|
||||
still open — so it must become the baseline, never the seed: the draw waits for a
|
||||
block strictly after it."""
|
||||
import app.rounds.scheduler as scheduler_module
|
||||
|
||||
listener = LateTipListener(learns=(500, "aa"), then_advances_to=(501, "bb"))
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
|
||||
async def fake_sleep(_seconds):
|
||||
# First sleep: the tip becomes known (height 500, the pre-close block).
|
||||
# Second: a genuinely new block arrives on top of it.
|
||||
if listener.tip_height == 0:
|
||||
listener.learn_tip()
|
||||
else:
|
||||
listener.tip_height, listener.tip_header_hex = listener._then_advances_to
|
||||
|
||||
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
|
||||
|
||||
height, _block_hash = await scheduler._wait_for_next_block(
|
||||
round_id=1, tip_at_close=0, waiting_since=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
assert height == 501 # the block *after* the one we first learned about
|
||||
assert listener.corroboration_calls == [501] # 500 was never even a candidate
|
||||
|
||||
async with session_factory() as session:
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert events == ["draw_baseline_tip_unknown"] # explainable from /admin
|
||||
|
||||
|
||||
# --- B-36: a stalled draw must be visible, not a silent frozen wait --------------
|
||||
|
||||
|
||||
class StallingListener:
|
||||
"""A tip that never advances until the test decides it should — used to drive
|
||||
_wait_for_next_block's stall-detection past _DRAW_STALL_THRESHOLD_SECONDS
|
||||
without a real 6-minute wait."""
|
||||
|
||||
def __init__(self):
|
||||
self.tip_height = 100
|
||||
self.tip_header_hex = None
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold(session_factory, monkeypatch):
|
||||
import app.rounds.scheduler as scheduler_module
|
||||
|
||||
listener = StallingListener()
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
start = datetime.now(timezone.utc)
|
||||
|
||||
class _FakeClock:
|
||||
now = start
|
||||
|
||||
def fake_now(tz=None):
|
||||
return _FakeClock.now
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
_FakeClock.now += timedelta(seconds=seconds)
|
||||
# Past the stall threshold, but before it would repeat: unblock the wait
|
||||
# by making a (corroborated) block appear, so the test terminates.
|
||||
if _FakeClock.now >= start + timedelta(seconds=scheduler_module._DRAW_STALL_THRESHOLD_SECONDS + 30):
|
||||
listener.tip_height = 101
|
||||
listener.tip_header_hex = "aa"
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "datetime", type("_D", (), {"now": staticmethod(fake_now)}))
|
||||
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
|
||||
|
||||
height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100, waiting_since=start)
|
||||
|
||||
assert height == 101
|
||||
|
||||
async with session_factory() as session:
|
||||
entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].round_id == 1
|
||||
|
||||
|
||||
async def test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check(session_factory): # B-53
|
||||
"""_tick counts in-flight bets in a session of its own, so a "building" row that
|
||||
commits between that count and the participant snapshot used to be invisible to
|
||||
both: the round drew and paid out without the bet, while its sats still landed in
|
||||
the pool. _close_and_draw re-checks in the same session it snapshots from, and
|
||||
must leave the round in "closing" for the next tick rather than draw."""
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address=""))
|
||||
session.add(Round(status="closing", opened_at=datetime.now(timezone.utc)))
|
||||
await session.commit()
|
||||
round_ = (await session.scalars(select(Round))).one()
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=round_.id,
|
||||
user_id=1,
|
||||
bet_amount_sats=1_000_000_000,
|
||||
bet_txid="ab" * 32,
|
||||
status="building", # committed a moment after _tick counted zero
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
round_id = round_.id
|
||||
|
||||
scheduler = RoundScheduler(session_factory, FakeListener())
|
||||
await scheduler._close_and_draw(round_id)
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
assert round_.status == "closing" # not drawn, and not closed as participant-less
|
||||
assert round_.winner_user_id is None
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert "round_closed" not in events
|
||||
assert "winner_drawn" not in events
|
||||
|
||||
|
||||
async def test_tick_ignores_a_config_duration_edited_mid_round(session_factory): # B-61
|
||||
"""Lowering round_duration_seconds from 600 to 30 while a round is 300s in used
|
||||
to close that round on the spot, because the deadline was recomputed live from
|
||||
the config on every tick. The edit applies to the *next* round."""
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address="", round_duration_seconds=30)) # just lowered
|
||||
session.add(
|
||||
Round(
|
||||
status="open",
|
||||
opened_at=datetime.now(timezone.utc) - timedelta(seconds=300),
|
||||
duration_seconds=600, # what this round opened with
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
scheduler = RoundScheduler(session_factory, FakeListener())
|
||||
await scheduler._tick()
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = (await session.scalars(select(Round))).one()
|
||||
assert round_.status == "open" # still 300s to go, by its own clock
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
|
||||
from app.auth import security
|
||||
|
||||
|
||||
@@ -9,5 +11,92 @@ def test_password_hash_roundtrip():
|
||||
|
||||
def test_jwt_roundtrip(monkeypatch):
|
||||
monkeypatch.setattr(security.settings, "jwt_secret", "test-secret")
|
||||
token = security.create_access_token(user_id=42)
|
||||
assert security.decode_access_token(token) == 42
|
||||
token = security.create_access_token(user_id=42, token_version=3)
|
||||
assert security.decode_access_token(token) == (42, 3)
|
||||
|
||||
|
||||
def test_jwt_decode_defaults_token_version_for_tokens_issued_before_it_existed(monkeypatch):
|
||||
"""B-34: a token minted before the "tv" claim existed has no such key at
|
||||
all. It must still decode — as token_version 0, matching a freshly
|
||||
migrated user's starting value — rather than raising or being treated as
|
||||
permanently stale."""
|
||||
import jwt as pyjwt
|
||||
|
||||
monkeypatch.setattr(security.settings, "jwt_secret", "test-secret")
|
||||
payload = {"sub": "42"}
|
||||
token = pyjwt.encode(payload, "test-secret", algorithm=security.settings.jwt_algorithm)
|
||||
assert security.decode_access_token(token) == (42, 0)
|
||||
|
||||
|
||||
def test_verify_password_returns_false_for_an_unparseable_hash():
|
||||
"""B-13: only VerifyMismatchError was caught, so a corrupted stored hash raised
|
||||
InvalidHashError and became an unhandled 500 on the login endpoint instead of a
|
||||
plain "wrong credentials" 401."""
|
||||
from app.auth.security import verify_password
|
||||
|
||||
assert verify_password("whatever", "not-an-argon2-hash") is False
|
||||
assert verify_password("whatever", "") is False
|
||||
|
||||
|
||||
def test_verify_password_still_rejects_a_wrong_password():
|
||||
from app.auth.security import hash_password, verify_password
|
||||
|
||||
stored = hash_password("correct-horse-battery")
|
||||
assert verify_password("correct-horse-battery", stored) is True
|
||||
assert verify_password("wrong", stored) is False
|
||||
|
||||
|
||||
# --- B-55: Argon2 must not run on the event loop --------------------------------
|
||||
|
||||
|
||||
async def _count_loop_ticks_during(coro) -> tuple[object, int]:
|
||||
"""Runs `coro` while a heartbeat task tries to run as often as the event loop
|
||||
lets it. A blocking call starves the heartbeat completely; a threadpooled one
|
||||
leaves the loop free the whole time."""
|
||||
ticks = 0
|
||||
|
||||
async def heartbeat() -> None:
|
||||
nonlocal ticks
|
||||
while True:
|
||||
ticks += 1
|
||||
await asyncio.sleep(0)
|
||||
|
||||
task = asyncio.create_task(heartbeat())
|
||||
await asyncio.sleep(0) # let the heartbeat reach its loop before timing starts
|
||||
try:
|
||||
result = await coro
|
||||
finally:
|
||||
task.cancel()
|
||||
return result, ticks
|
||||
|
||||
|
||||
async def test_hash_password_async_keeps_the_event_loop_free():
|
||||
"""Argon2 costs tens of milliseconds of CPU by design. Run inline from an async
|
||||
handler it froze the whole process for that long — every other request plus all
|
||||
six background tasks (scheduler, confirmation poller, RBF bumper, listener, both
|
||||
reconcilers) — which made a burst of unauthenticated login attempts a cheap way
|
||||
to delay draws and confirmations."""
|
||||
hashed, ticks = await _count_loop_ticks_during(security.hash_password_async("s3cret-passphrase"))
|
||||
|
||||
assert security.verify_password("s3cret-passphrase", hashed)
|
||||
assert ticks > 1 # the loop kept running while the hashing happened
|
||||
|
||||
|
||||
async def test_verify_password_async_keeps_the_event_loop_free():
|
||||
stored = security.hash_password("correct-horse-battery")
|
||||
|
||||
ok, ticks = await _count_loop_ticks_during(
|
||||
security.verify_password_async("correct-horse-battery", stored)
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert ticks > 1
|
||||
|
||||
|
||||
async def test_verify_password_async_rejects_a_wrong_password():
|
||||
"""Same answers as the synchronous function it wraps — including the B-13
|
||||
unparseable-hash case, which must read as "wrong password", not as an error."""
|
||||
stored = security.hash_password("correct-horse-battery")
|
||||
|
||||
assert await security.verify_password_async("wrong", stored) is False
|
||||
assert await security.verify_password_async("whatever", "not-an-argon2-hash") is False
|
||||
|
||||
@@ -45,7 +45,7 @@ async def client(monkeypatch, tmp_path):
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router)
|
||||
app.include_router(users_router)
|
||||
app.state.electrum_listener = ElectrumListener(lambda: None, db_base.AsyncSessionLocal)
|
||||
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
@@ -81,7 +81,8 @@ async def test_change_password_updates_login(client):
|
||||
headers=headers,
|
||||
json={"current_password": "original-password", "new_password": "brand-new-password"},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["access_token"]
|
||||
|
||||
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
|
||||
assert resp.status_code == 401
|
||||
@@ -90,6 +91,36 @@ async def test_change_password_updates_login(client):
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
async def test_change_password_invalidates_the_old_token_but_not_the_new_one(client):
|
||||
"""B-34: neither self-service change-password nor the admin reset used to
|
||||
invalidate already-issued JWTs, so a stolen token (or an attacker who
|
||||
already had the old password) stayed logged in until the token's natural
|
||||
24h expiry — even past a password change meant to lock them out."""
|
||||
old_token = await _register(client)
|
||||
old_headers = {"Authorization": f"Bearer {old_token}"}
|
||||
|
||||
resp = await client.post(
|
||||
"/users/me/change-password",
|
||||
headers=old_headers,
|
||||
json={"current_password": "original-password", "new_password": "brand-new-password"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
new_token = resp.json()["access_token"]
|
||||
assert new_token != old_token
|
||||
|
||||
# The old token (what an attacker holding the old password would still
|
||||
# have) is now rejected...
|
||||
resp = await client.get("/users/me", headers=old_headers)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["detail"]["code"] == "session_expired"
|
||||
|
||||
# ...but the freshly issued one keeps this same session working, so the
|
||||
# user who just changed their own password isn't logged out too.
|
||||
new_headers = {"Authorization": f"Bearer {new_token}"}
|
||||
resp = await client.get("/users/me", headers=new_headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
async def test_change_password_rejects_too_short(client):
|
||||
token = await _register(client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
@@ -108,3 +139,40 @@ async def test_change_password_requires_auth(client):
|
||||
json={"current_password": "x", "new_password": "brand-new-password"},
|
||||
)
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"username": "", "password": "longenough1"},
|
||||
{"username": "ab", "password": "longenough1"}, # under 3 chars
|
||||
{"username": "bad user!", "password": "longenough1"}, # disallowed characters
|
||||
{"username": "validname", "password": "short"}, # under MIN_PASSWORD_LENGTH
|
||||
{"username": "validname", "password": ""},
|
||||
],
|
||||
)
|
||||
async def test_register_rejects_weak_credentials(client, payload):
|
||||
"""B-12: registration accepted an empty username and a one-character password,
|
||||
while /users/me/change-password demanded 8 — an odd place to be lenient on a
|
||||
custodial system holding real funds."""
|
||||
resp = await client.post("/auth/register", json=payload)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_register_accepts_valid_credentials(client):
|
||||
resp = await client.post("/auth/register", json={"username": "goodname", "password": "longenough1"})
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
async def test_me_created_at_is_utc_stamped(client):
|
||||
"""B-35: SQLite/aiosqlite returns DateTime columns as naive, even though every
|
||||
value written is UTC (app.db.models.utcnow). A bare .isoformat() on that naive
|
||||
value has no "Z"/offset, and JavaScript's `new Date()` then parses it as local
|
||||
time instead of UTC."""
|
||||
token = await _register(client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
resp = await client.get("/users/me", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
created_at = resp.json()["created_at"]
|
||||
assert created_at.endswith("+00:00") or created_at.endswith("Z")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user