Compare commits
44
Commits
d4e0974881
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
|
||||
@@ -8,168 +8,240 @@ 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 — 253 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
|
||||
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
|
||||
source .venv/bin/activate # venv already created at .venv/
|
||||
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 253 tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
|
||||
```
|
||||
|
||||
`.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`. `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, **no revocation** — B-34).
|
||||
- Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or `XPRV_ENCRYPTION_KEY` is empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
|
||||
- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway.
|
||||
|
||||
## PLM network parameters
|
||||
## 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` |
|
||||
| Draw animation | 20s (cosmetic frontend minimum only) | `RoundConfig` |
|
||||
| Fee rate / RBF timeout | 1 sat/vB / 900s | `RoundConfig` |
|
||||
| Min withdrawal | = current `bet_amount_sats` (no separate field) | `withdrawals/service.py` |
|
||||
| Min deposit | none | — |
|
||||
| Min password length | 8 | `auth/security.py:MIN_PASSWORD_LENGTH` |
|
||||
| Confirmations, every tx kind | **1** | hardcoded in `tx/confirmation.py` |
|
||||
| Max inputs per tx | 50 (`MAX_TX_INPUTS`, B-48) — over it the build fails with `too_many_inputs`, it never spends more | hardcoded in `wallet/psbt_builder.py` |
|
||||
|
||||
`GET /rounds/current`'s `jackpot_sats` is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual `bet_amount_sats` (each already net of its own bet fee) rather than `count × current bet amount` — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11).
|
||||
|
||||
**Round cooldown** (`round_cooldown_seconds`, not in the original flowchart): gap after a round closes before the next opens, so players can see the outcome.
|
||||
|
||||
**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner.
|
||||
|
||||
## Code map
|
||||
|
||||
| Package | Contents |
|
||||
|---|---|
|
||||
| `app/main.py` | entry point: lifespan starts the six background tasks, mounts the routers and `app/static/` |
|
||||
| `app/api/routes/` | `admin`, `bets`, `withdrawals`, `rounds` (incl. SSE), `users`, `qr`; `app/api/errors.py` holds the error contract |
|
||||
| `app/auth/` | routes (register/login), Argon2 + JWT (`security.py`), `get_current_user`/`get_optional_user` |
|
||||
| `app/db/` | `models.py` (all tables + the active-round index), engine/session factories |
|
||||
| `app/wallet/` | HD derivation + WIF export (`hd.py`), PLM network constants, address/scripthash, balance math, `psbt_builder.py` (build/sign bet, withdrawal, payout; `select_utxos`) |
|
||||
| `app/electrum/` | `client.py` (JSON-RPC, endpoint parsing, timeouts), `listener.py` (the one connection: rotation, keepalive, header validation, corroboration, deposit crediting) |
|
||||
| `app/deposits/` | crediting / external-spend detection / reinstatement (`service.py`), periodic sweep (`reconcile.py`) |
|
||||
| `app/bets/`, `app/withdrawals/` | build+broadcast services and their confirmation handlers |
|
||||
| `app/rounds/` | `scheduler.py` (close/draw/payout), `service.py` (open/active-round rules), `draw.py` (header math + winner pick), `config.py`, `events.py` (SSE pub/sub) |
|
||||
| `app/tx/` | `broadcast.py` (RBF bumper), `confirmation.py` (poller + handler registry), `reconcile.py`, `locks.py` (per-user locks) |
|
||||
| `app/static/` | the two SPAs (`index.html`/`app.js`/`style.css`, `admin.html`/`admin.js`/`admin.css`) + `i18n.js` |
|
||||
|
||||
## Background tasks
|
||||
|
||||
`app/main.py`'s lifespan starts six long-lived asyncio tasks and cancels them on shutdown. Their cadences determine how fast anything self-heals.
|
||||
|
||||
| Task | File | Cadence | Role |
|
||||
| --- | --- | --- | --- |
|
||||
| `ElectrumListener` | `electrum/listener.py` | reconnect loop, 60s keepalive | the single connection; subscribes headers + every user's scripthash, credits deposits |
|
||||
| `RoundScheduler` | `rounds/scheduler.py` | 5s | opens/closes rounds, draws, triggers and retries payouts |
|
||||
| `ConfirmationPoller` | `tx/confirmation.py` | 10s | `pending` → `confirmed` via per-kind handlers registered by `app/{bets,rounds,withdrawals}/confirmation.py` — imported for that side effect in `main.py`, **don't "clean up" those imports** |
|
||||
| `RbfBumper` | `tx/broadcast.py` | 30s | fee-bumps anything past `rbf_timeout_seconds` |
|
||||
| `PendingTransactionReconciler` | `tx/reconcile.py` | at startup, then 120s | resolves `building`/`pending` rows against the chain |
|
||||
| `DepositReconciler` | `deposits/reconcile.py` | 300s (sleeps first) | re-`refresh_user`s every address, catching a silently-lost subscription (B-30) |
|
||||
|
||||
Chain access goes through `listener.client`, passed as `lambda: listener.client` so a reconnect swaps the client under its consumers; a task finding it `None` skips that cycle instead of failing. `DepositReconciler` takes the whole listener instead, reusing `refresh_user` so the periodic and notification-driven paths can't diverge.
|
||||
|
||||
## Electrum connection
|
||||
|
||||
One connection serves everything — deposit credits, broadcasts, confirmations, the tip the draw waits on — so it's both the biggest single point of failure and, with a hostile server on the other end, the biggest integrity risk. Five defences:
|
||||
|
||||
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
|
||||
- **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets` *while holding the per-user lock*, and could stall the confirmation poller permanently.
|
||||
- **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
|
||||
- **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner.
|
||||
- **A quorum corroborates the two money-moving decisions** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29). No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing.
|
||||
|
||||
On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep.
|
||||
|
||||
## Architecture — the 5 phases
|
||||
|
||||
Diagrams: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifecycle.mmd](flowchart/round-lifecycle.mmd).
|
||||
|
||||
**REG** — on signup the server derives a P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from the encrypted master xprv. Permanent, and doubles as deposit address, winnings address and withdrawal change address.
|
||||
|
||||
**DEP** — the listener subscribes to the user's scripthash; balance is credited after **1 confirmation**, with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
|
||||
|
||||
**PLAY** — fixed cost, **at most one active bet per user**. PSBT user-address → pool-address, always with a **change output back to the same user address** (a user's balance must never exactly equal the bet). Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
|
||||
|
||||
**DRAW** — configurable timer (default 600s):
|
||||
- *Bet cutoff is the round's own deadline* (`opened_at + round_duration_seconds`), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`.
|
||||
- *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
|
||||
- *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount.
|
||||
- *Payout* is signed with the pool key; its **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
|
||||
- *UI, two independent layers.* A generic phase box ("Pagamento al vincitore in corso…") shows to **every** viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by `status`. **Additively**, a personalized "Hai vinto!/Non hai vinto" box appears only where `user_played` is true (computed via `get_optional_user`, since the endpoint is reachable logged-out) — nobody else has anything to reveal.
|
||||
- *Reveal timing.* Delayed by at least `draw_animation_seconds`, anchored to the server's `closes_at` so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for `winner_user_id`. Once revealed it's persisted in `localStorage.plm_persisted_result`, surviving the move to `closed` — at which point `get_active_round` stops returning the round and `winner_user_id` disappears from `GET /rounds/current`. `GET /users/me/last-round-result` is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: `refreshRound`/`checkLastRoundResult` in `app/static/app.js`.
|
||||
|
||||
**WITHDRAW** — the only way out to an external address: PSBT user-address → external + change back to the user, fee deducted from the withdrawn amount, same RBF pattern.
|
||||
|
||||
PLAY and WITHDRAW share a **per-user lock** (`tx/locks.py`): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set.
|
||||
|
||||
**Three separate on-chain confirmations sit between the timer hitting zero and the payout landing** — a common point of confusion:
|
||||
1. **Last bet's confirmation** — the round doesn't even flip to `"closing"` until every broadcast bet has 1 conf (`_tick`'s `pending_count` check). May already have happened before the deadline.
|
||||
2. **The draw block** — `_wait_for_next_block` waits for `tip_height > tip_at_close`, recorded only once step 1 is done, so this is necessarily a later block.
|
||||
3. **Payout confirmation** — built only after step 2's winner is known, so it needs yet another block; the generic `ConfirmationPoller` tracks it.
|
||||
|
||||
At 120s blocks that's ~4–6 min worst case (last bet confirms right at the deadline), ~2–4 min best case — independent of `draw_animation_seconds`.
|
||||
|
||||
## Balance display
|
||||
|
||||
`place_bet`/`request_withdrawal` (`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 five sections each backed by its own `/admin/*` endpoint: Parametri (`RoundConfig` + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log; plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction.
|
||||
|
||||
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`.
|
||||
|
||||
## Internationalization (user-facing page only)
|
||||
## Internationalization (`/` only)
|
||||
|
||||
`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is available everywhere. Language comes from `localStorage.plm_lang`, falling back to `navigator.language`, falling back to `en`; the switcher lives in the **chain-bar, not the navbar**, deliberately — the navbar is hidden until login, which would leave the landing page and the login form untranslatable for exactly the users who need the switch.
|
||||
`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is always available. Language: `localStorage.plm_lang` → `navigator.language` → `en`. 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.
|
||||
|
||||
- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`), applied by `applyStaticTranslations(root?)` on `DOMContentLoaded` and on every switch. Anything rendered from server data is built with `t()` in `app.js` instead, and re-rendered by `onLanguageChange()` — an element must be in one camp or the other, never both, or the two mechanisms overwrite each other (this is why `#bet-btn` has no `data-i18n`: its label carries the admin-configurable bet amount, so `renderBetButton()` owns it).
|
||||
- **Every language must have exactly the same key set.** There is no fallback beyond `en`, and a missing key renders as the raw key string.
|
||||
- `/admin` is intentionally **not** translated (operator-facing, Italian only), and neither is `/guida` (serves `docs/guida-utente.md`).
|
||||
- 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()` in `app.js` and is re-rendered by `onLanguageChange()`. 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.
|
||||
- `/admin` is intentionally untranslated (operator-facing, Italian), as is `/guida`.
|
||||
|
||||
**API error contract** (`app/api/errors.py`): the API is single-language by design. User-facing failures answer with a structured `detail` — `{"code", "message", "params"}` — where `message` is English for non-dashboard consumers and `code` is what the frontend maps onto `error.<code>` in `i18n.js` (falling back to `message` for an unknown code). Domain exceptions (`BetError`, `WithdrawalError`) subclass `ApiError` and carry the code from where the failure actually happens; `str(exc)` is still the English message. When adding a user-facing error: give it a code, add `error.<code>` to all 7 languages, and pass interpolated values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling automatically) rather than baking them into the English text.
|
||||
|
||||
## Admin dashboard and test UI
|
||||
|
||||
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/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.
|
||||
|
||||
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.
|
||||
- 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 — an exact-amount tx or too-small change raises `RbfError`. Not permanent, though: an unbumpable tx that never confirms is eventually abandoned and its UTXOs released.
|
||||
- **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network).
|
||||
- **No user-facing history.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
|
||||
- **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
|
||||
- **No rate limiting anywhere** (register, bet, withdrawal, admin, SSE). For login this is a blocker, not a gap — tracked as B-33.
|
||||
- **`/guida` and `/report-bug` are placeholders** (`app/static/guida.html`, `report-bug.html`) — links work, content is "coming soon".
|
||||
- **No integration tests against a live Electrum connection.** `tests/integration/` is empty; live verification has all been manual (`scripts/electrum_smoke_test.py`, ad hoc scripts, real mainnet txs).
|
||||
- **Single-process assumptions**: the SSE broadcaster and the per-user locks are in-process only. A multi-worker deployment needs a shared channel and a DB/Redis lock. The round-uniqueness invariant is *not* in this category — it's a DB index.
|
||||
|
||||
@@ -13,5 +13,21 @@
|
||||
not path /rounds/stream
|
||||
}
|
||||
encode @not_sse gzip
|
||||
|
||||
# B-43: Caddy adds none of these on its own. The JWT lives in
|
||||
# localStorage, so any XSS exfiltrates it — CSP is the main mitigation.
|
||||
# script-src/style-src need 'unsafe-inline' because both SPAs
|
||||
# (app/static/index.html, admin.html) use inline onclick handlers and
|
||||
# style="" attributes throughout; removing those is a separate,
|
||||
# larger refactor, not a header change. fonts.googleapis.com/gstatic.com
|
||||
# are the one external asset (the Google Fonts @import in style.css/admin.css).
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
|
||||
}
|
||||
|
||||
reverse_proxy app:8123
|
||||
}
|
||||
|
||||
@@ -13,31 +13,30 @@ 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
|
||||
|
||||
@@ -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,
|
||||
232 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
|
||||
the pending-inclusive balance calculation, and the SSE push channel. No
|
||||
automated integration tests against a live Electrum connection — mainnet
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""The caller's real IP, from Caddy's X-Forwarded-For (see Caddyfile) —
|
||||
request.client.host would otherwise be the reverse proxy's own address, not
|
||||
the caller's. Falls back to request.client.host only if the header is
|
||||
somehow missing (e.g. the app container hit directly, bypassing Caddy).
|
||||
|
||||
Shared by the login/registration throttles (B-33) and the SSE per-IP
|
||||
subscriber cap (B-38) so the two can't drift into different notions of
|
||||
"the client's IP".
|
||||
"""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
+82
-24
@@ -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.config import settings
|
||||
from app.db.models import AuditLog, 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:
|
||||
setattr(config, field, value)
|
||||
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
|
||||
]
|
||||
@@ -170,6 +218,11 @@ async def reset_user_password(
|
||||
|
||||
new_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = hash_password(new_password)
|
||||
# B-34: this endpoint exists precisely for the "account compromised" case —
|
||||
# without bumping token_version, whoever was already logged in (the
|
||||
# attacker, if that's who prompted the reset) stayed logged in on their
|
||||
# existing token until it naturally expired, unaffected by the reset.
|
||||
user.token_version += 1
|
||||
await write_audit_log(session, "admin_password_reset", {"user_id": user_id}, user_id=user_id)
|
||||
await session.commit()
|
||||
return AdminPasswordResetResponse(username=user.username, new_password=new_password)
|
||||
@@ -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,7 +341,7 @@ 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
|
||||
|
||||
@@ -36,7 +36,14 @@ async def create_bet(
|
||||
try:
|
||||
participant = await place_bet(session, listener.client, user)
|
||||
except BetError as exc:
|
||||
raise from_api_error(status.HTTP_400_BAD_REQUEST, 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,
|
||||
|
||||
@@ -8,11 +8,13 @@ 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.events import EVICTED, RoundEventCapacityError, broadcaster
|
||||
from app.rounds.service import get_active_round
|
||||
|
||||
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:
|
||||
@@ -90,6 +99,10 @@ class CurrentRoundResponse(BaseModel):
|
||||
winner_amount_sats: int | None = None
|
||||
draw_block_height: int | None = None
|
||||
draw_block_hash: str | None = None
|
||||
# B-36: set only while status == "drawing", so the frontend can show "still
|
||||
# waiting for a block" rather than a countdown implying a bounded wait — this
|
||||
# phase has no timeout, only draw_animation_seconds' cosmetic minimum.
|
||||
draw_waiting_since: str | None = None
|
||||
chain_tip_height: int | None = None
|
||||
lottery_paused: bool = False
|
||||
user_played: bool = False
|
||||
@@ -118,6 +131,16 @@ async def current_round(
|
||||
participant_count = await session.scalar(
|
||||
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
|
||||
) or 0
|
||||
# The pool is the sum of what the participants' bets actually paid into the pool
|
||||
# address — each one is already net of that bet's network fee. Deriving it from
|
||||
# participant_count * the *current* bet_amount_sats instead overstated it, and
|
||||
# silently changed the advertised jackpot of a round in progress whenever an
|
||||
# operator edited the bet amount (B-11).
|
||||
pool_amount_sats = await session.scalar(
|
||||
select(func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0)).where(
|
||||
RoundParticipant.round_id == round_.id
|
||||
)
|
||||
) or 0
|
||||
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
|
||||
closes_at = opened_at + timedelta(seconds=config.round_duration_seconds)
|
||||
|
||||
@@ -137,10 +160,11 @@ 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
|
||||
# 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 = pool_amount_sats * 70 // 100
|
||||
|
||||
return CurrentRoundResponse(
|
||||
@@ -157,6 +181,7 @@ async def current_round(
|
||||
winner_amount_sats=round_.winner_amount_sats,
|
||||
draw_block_height=round_.draw_block_height,
|
||||
draw_block_hash=round_.draw_block_hash,
|
||||
draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None,
|
||||
chain_tip_height=chain_tip_height,
|
||||
lottery_paused=config.paused,
|
||||
user_played=user_played,
|
||||
|
||||
+20
-9
@@ -4,16 +4,15 @@ 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, verify_password
|
||||
from app.db.models import Round, RoundParticipant, User
|
||||
from app.db.session import get_session
|
||||
from app.wallet.balance import compute_pending_balance
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
_MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
id: int
|
||||
@@ -38,7 +37,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),
|
||||
)
|
||||
|
||||
|
||||
@@ -47,12 +46,16 @@ 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)."""
|
||||
@@ -60,16 +63,24 @@ async def change_password(
|
||||
raise http_error(
|
||||
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
|
||||
)
|
||||
if len(body.new_password) < _MIN_PASSWORD_LENGTH:
|
||||
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,
|
||||
f"new password must be at least {MIN_PASSWORD_LENGTH} characters",
|
||||
minimum=MIN_PASSWORD_LENGTH,
|
||||
)
|
||||
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
# B-34: bumping token_version invalidates every token issued before this
|
||||
# point — including this very request's own bearer token, and any an
|
||||
# attacker who knew the old password might be holding. A fresh token is
|
||||
# handed back so *this* session keeps working without forcing a re-login;
|
||||
# every other open session (this user's other devices, or an attacker's)
|
||||
# gets "session_expired" on its next request.
|
||||
user.token_version += 1
|
||||
await session.commit()
|
||||
return ChangePasswordResponse(access_token=create_access_token(user.id, user.token_version))
|
||||
|
||||
|
||||
class LastRoundResultResponse(BaseModel):
|
||||
|
||||
@@ -44,7 +44,12 @@ async def create_withdrawal(
|
||||
session, listener.client, user, body.external_address, body.amount_sats
|
||||
)
|
||||
except WithdrawalError as exc:
|
||||
raise from_api_error(status.HTTP_400_BAD_REQUEST, 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()
|
||||
@@ -16,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 http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "invalid token") from exc
|
||||
|
||||
user = await session.scalar(select(User).where(User.id == user_id))
|
||||
if user is None:
|
||||
raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "user not found")
|
||||
if user.token_version != token_version:
|
||||
# B-34: a password change (self-service or admin reset) bumps
|
||||
# token_version, so a token issued before it — including one an
|
||||
# attacker who had the old password is still holding — reads as
|
||||
# expired rather than staying valid until it naturally times out.
|
||||
raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "token has been superseded")
|
||||
return user
|
||||
|
||||
|
||||
@@ -37,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,75 @@
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Bucket:
|
||||
failures: int = 0
|
||||
locked_until: float = 0.0
|
||||
last_failure_at: float = 0.0
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""In-process failed-attempt throttle with exponential backoff, keyed by an
|
||||
arbitrary string (username, IP...). Single-process-only, like UserLocks
|
||||
(app/tx/locks.py) — an accepted MVP constraint; a multi-worker deployment
|
||||
would need a shared store (Redis) instead (B-33).
|
||||
|
||||
Brute-forcing a login here isn't a spammy client to be capped at N req/s —
|
||||
it's an attempt to withdraw someone else's funds — so failures are
|
||||
penalized with a delay that doubles each time past `threshold` free
|
||||
attempts, rather than a flat rate cap. `decay_seconds` ages a bucket back
|
||||
to zero once failures stop, so a shared/NAT IP isn't punished forever for
|
||||
someone else's earlier mistakes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
threshold: int = 5,
|
||||
base_delay: float = 2.0,
|
||||
max_delay: float = 300.0,
|
||||
decay_seconds: float = 900.0,
|
||||
) -> None:
|
||||
self._threshold = threshold
|
||||
self._base_delay = base_delay
|
||||
self._max_delay = max_delay
|
||||
self._decay_seconds = decay_seconds
|
||||
self._buckets: dict[str, _Bucket] = {}
|
||||
|
||||
def retry_after(self, key: str) -> float:
|
||||
bucket = self._buckets.get(key)
|
||||
if bucket is None:
|
||||
return 0.0
|
||||
remaining = bucket.locked_until - time.monotonic()
|
||||
return remaining if remaining > 0 else 0.0
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
bucket = self._buckets.setdefault(key, _Bucket())
|
||||
if bucket.failures and now - bucket.last_failure_at > self._decay_seconds:
|
||||
bucket.failures = 0
|
||||
bucket.failures += 1
|
||||
bucket.last_failure_at = now
|
||||
if bucket.failures >= self._threshold:
|
||||
delay = min(self._max_delay, self._base_delay * 2 ** (bucket.failures - self._threshold))
|
||||
bucket.locked_until = now + delay
|
||||
|
||||
def record_success(self, key: str) -> None:
|
||||
self._buckets.pop(key, None)
|
||||
|
||||
|
||||
class AuthRateLimiters:
|
||||
"""The three throttles B-33 needs, bundled so they can live on `app.state`
|
||||
(like `UserLocks`, see app/tx/locks.py) rather than as module globals.
|
||||
|
||||
A module global would persist for the lifetime of the process — fine in
|
||||
production (one app instance), but wrong in the test suite, where every
|
||||
test builds its own FastAPI app against a fresh in-memory DB and expects a
|
||||
clean slate; a shared global would leak failure counts between unrelated
|
||||
tests. Per-`app.state` state gets a fresh instance per app automatically.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.login = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0)
|
||||
self.login_ip = RateLimiter(threshold=20, base_delay=2.0, max_delay=300.0)
|
||||
self.register_ip = RateLimiter(threshold=5, base_delay=5.0, max_delay=600.0)
|
||||
+79
-8
@@ -1,11 +1,13 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.client_ip import client_ip as _client_ip
|
||||
from app.api.errors import http_error
|
||||
from app.auth.security import create_access_token, hash_password, verify_password
|
||||
from app.auth.rate_limit import AuthRateLimiters
|
||||
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
|
||||
from app.db.models import User
|
||||
from app.db.session import get_session
|
||||
from app.wallet.hd import derive_user_address
|
||||
@@ -15,9 +17,41 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
_MAX_REGISTER_RETRIES = 5
|
||||
|
||||
|
||||
def _rate_limiters(request: Request) -> AuthRateLimiters:
|
||||
# B-33: no rate limiting on login was a brute-forceable path to withdrawing
|
||||
# someone else's funds. Keyed per-username *and* per-IP so an attacker can't
|
||||
# dodge the throttle by spraying one password across many accounts, nor by
|
||||
# routing one account's guesses through many IPs alone (the username key
|
||||
# still catches that). The IP limiter's threshold is deliberately higher
|
||||
# than the username one: a single account should lock out fast, but a
|
||||
# shared/NAT IP hosting several genuine users shouldn't be punished for one
|
||||
# of them mistyping a password a few times. Registration gets its own,
|
||||
# coarser limiter, IP-only — no username exists yet to key on — mainly to
|
||||
# bound how many accounts one IP can spin up (B-31), not to protect a
|
||||
# secret. Lives on app.state (see AuthRateLimiters) rather than a module
|
||||
# global so each app instance gets its own, isolated throttle state.
|
||||
if not hasattr(request.app.state, "auth_rate_limiters"):
|
||||
request.app.state.auth_rate_limiters = AuthRateLimiters()
|
||||
return request.app.state.auth_rate_limiters
|
||||
|
||||
|
||||
def _rate_limited_error(retry_after: float):
|
||||
return http_error(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"rate_limited",
|
||||
"too many attempts, try again later",
|
||||
retry_after_seconds=int(retry_after) + 1,
|
||||
)
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
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):
|
||||
@@ -29,6 +63,13 @@ class TokenResponse(BaseModel):
|
||||
async def register(
|
||||
body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session)
|
||||
) -> TokenResponse:
|
||||
limiters = _rate_limiters(request)
|
||||
ip_key = f"ip:{_client_ip(request)}"
|
||||
retry_after = limiters.register_ip.retry_after(ip_key)
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
limiters.register_ip.record_failure(ip_key)
|
||||
|
||||
existing = await session.scalar(select(User).where(User.username == body.username))
|
||||
if existing is not None:
|
||||
raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken")
|
||||
@@ -48,12 +89,22 @@ 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)
|
||||
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 http_error(
|
||||
status.HTTP_409_CONFLICT,
|
||||
@@ -68,8 +119,28 @@ class LoginRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)) -> TokenResponse:
|
||||
async def login(
|
||||
body: LoginRequest, request: Request, session: AsyncSession = Depends(get_session)
|
||||
) -> TokenResponse:
|
||||
limiters = _rate_limiters(request)
|
||||
username_key = f"user:{body.username.lower()}"
|
||||
ip_key = f"ip:{_client_ip(request)}"
|
||||
retry_after = max(limiters.login.retry_after(username_key), limiters.login_ip.retry_after(ip_key))
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
|
||||
user = await session.scalar(select(User).where(User.username == body.username))
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
# Same code path (and therefore the same response) whether the username
|
||||
# doesn't exist or the password is wrong — no enumeration oracle here.
|
||||
limiters.login.record_failure(username_key)
|
||||
limiters.login_ip.record_failure(ip_key)
|
||||
raise http_error(status.HTTP_401_UNAUTHORIZED, "invalid_credentials", "invalid credentials")
|
||||
return TokenResponse(access_token=create_access_token(user.id), address=user.address)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
+33
-6
@@ -1,11 +1,19 @@
|
||||
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 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 +22,37 @@ 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:
|
||||
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.bet_txid == pending.current_txid)
|
||||
select(RoundParticipant).where(
|
||||
RoundParticipant.round_id == pending.round_id,
|
||||
RoundParticipant.user_id == pending.user_id,
|
||||
)
|
||||
)
|
||||
if participant is not None and participant.status == "broadcast":
|
||||
if participant is None:
|
||||
# Fall back to the txid for rows written before this changed, and for any
|
||||
# pending row missing its round/user link.
|
||||
participant = await session.scalar(
|
||||
select(RoundParticipant).where(RoundParticipant.bet_txid == pending.current_txid)
|
||||
)
|
||||
if participant is not None and participant.status in ("building", "broadcast"):
|
||||
participant.status = "confirmed"
|
||||
participant.confirmed_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
+66
-8
@@ -62,14 +62,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(exc.code, 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)
|
||||
@@ -79,10 +84,25 @@ 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)
|
||||
await session.commit()
|
||||
|
||||
# --- Phase 2: broadcast, then promote both rows to their live state ---------
|
||||
try:
|
||||
await client.broadcast(built.raw_hex)
|
||||
except Exception as exc:
|
||||
# The node refused it (fee too low, dust, mempool conflict, or simply an
|
||||
# unreachable server) — nothing is on-chain, so undo phase 1 completely and
|
||||
# give the user a translatable failure instead of a bare 500 (B-07).
|
||||
await _release_failed_bet(session, participant, pending, built, user.id, str(exc))
|
||||
raise BetError("broadcast_failed", f"the network refused the transaction: {exc}") from exc
|
||||
|
||||
participant.status = "broadcast"
|
||||
pending.status = "pending"
|
||||
await write_audit_log(
|
||||
session,
|
||||
"bet_placed",
|
||||
@@ -97,6 +117,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:
|
||||
@@ -107,5 +161,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))
|
||||
|
||||
+36
-1
@@ -1,9 +1,44 @@
|
||||
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
|
||||
# five concurrent background tasks (scheduler, confirmation poller, RBF bumper,
|
||||
# two reconcilers) plus HTTP handlers briefly overlapping a write.
|
||||
_SQLITE_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
|
||||
def _register_sqlite_pragmas(target_engine: AsyncEngine) -> None:
|
||||
"""Without WAL, SQLite's default (rollback-journal) mode lets a writer block
|
||||
every reader for the duration of its transaction, and a second writer arriving
|
||||
while one is already active fails immediately rather than waiting at all —
|
||||
realistic under this app's concurrency, and nothing previously handled it.
|
||||
WAL lets readers and writers proceed without blocking each other, and
|
||||
busy_timeout gives a second writer a real window to wait for the first
|
||||
instead of an instant `OperationalError`.
|
||||
|
||||
No-op for any dialect other than sqlite (e.g. a future PostgreSQL
|
||||
DATABASE_URL), which neither needs nor understands these pragmas.
|
||||
"""
|
||||
if target_engine.dialect.name != "sqlite":
|
||||
return
|
||||
|
||||
@event.listens_for(target_engine.sync_engine, "connect")
|
||||
def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
engine = create_async_engine(settings.database_url)
|
||||
_register_sqlite_pragmas(engine)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
|
||||
+57
-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
|
||||
@@ -21,6 +21,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 +45,38 @@ 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)
|
||||
# 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 +133,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 +154,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)
|
||||
|
||||
|
||||
@@ -140,7 +193,7 @@ class AuditLog(Base):
|
||||
|
||||
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,8 @@ from app.db.models import UtxoEvent
|
||||
from app.rounds.events import broadcaster
|
||||
from app.wallet.balance import recompute_balance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
|
||||
"""Insert utxo_events for newly-confirmed entries from an Electrum
|
||||
@@ -54,3 +58,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
|
||||
|
||||
+116
-6
@@ -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"
|
||||
self._writer.write(payload.encode())
|
||||
await self._writer.drain()
|
||||
return await future
|
||||
try:
|
||||
self._writer.write(payload.encode())
|
||||
await self._writer.drain()
|
||||
except (ConnectionError, ssl.SSLError, OSError) as exc:
|
||||
self._pending.pop(request_id, None)
|
||||
self._closed.set()
|
||||
raise ElectrumError(f"write failed: {exc}") from exc
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout=_REQUEST_TIMEOUT_SECONDS)
|
||||
except (TimeoutError, asyncio.TimeoutError) as exc:
|
||||
self._pending.pop(request_id, None)
|
||||
# A server that owes us a reply and never sends one is indistinguishable
|
||||
# from a dead socket, and retrying on the same connection would keep
|
||||
# hitting it — tear it down so the listener reconnects.
|
||||
self._closed.set()
|
||||
raise ElectrumError(f"{method} timed out after {_REQUEST_TIMEOUT_SECONDS}s") from exc
|
||||
|
||||
async def ping(self) -> None:
|
||||
"""Keepalive: Electrum servers drop idle connections (commonly after ~10
|
||||
minutes), which on a quiet instance would otherwise be the normal way this
|
||||
connection dies. Called periodically by ElectrumListener."""
|
||||
await self.request("server.ping")
|
||||
|
||||
def notifications(self, method: str) -> asyncio.Queue:
|
||||
return self._subscriptions.setdefault(method, asyncio.Queue())
|
||||
@@ -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():
|
||||
|
||||
+352
-35
@@ -6,95 +6,386 @@ 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_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
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
continue
|
||||
backoff = 1
|
||||
|
||||
async def _run_once(self) -> None:
|
||||
client = self._client_factory()
|
||||
# Move on to the next server regardless of why this session ended: a
|
||||
# server that just dropped us has no claim on being tried first again.
|
||||
if len(self._endpoints) > 1:
|
||||
self._endpoint_index = (self._endpoint_index + 1) % len(self._endpoints)
|
||||
|
||||
if connected:
|
||||
# We did reach a server, so the network is up — don't let an earlier
|
||||
# streak of failures keep penalizing the next attempt.
|
||||
backoff = 1
|
||||
failures_this_cycle = 0
|
||||
logger.info("Electrum connection to %s ended, reconnecting", endpoint)
|
||||
continue
|
||||
|
||||
failures_this_cycle += 1
|
||||
if failures_this_cycle < len(self._endpoints):
|
||||
continue # other servers untried — go straight to the next one
|
||||
failures_this_cycle = 0
|
||||
logger.warning("all %s Electrum server(s) unreachable, retrying in %ss", len(self._endpoints), backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30)
|
||||
|
||||
async def _run_once(self, endpoint: ElectrumEndpoint) -> bool:
|
||||
"""One connection's whole lifetime. Returns True if the connection was
|
||||
actually established (so the caller knows the network is reachable and can
|
||||
reset its backoff), False if it never got that far."""
|
||||
client = self._client_factory(endpoint)
|
||||
await client.connect()
|
||||
self.client = client
|
||||
logger.info("Electrum connected to %s", endpoint)
|
||||
|
||||
header = await client.subscribe_headers()
|
||||
self.tip_height = header["height"]
|
||||
self.tip_header_hex = header.get("hex")
|
||||
|
||||
await self._subscribe_all_users()
|
||||
|
||||
headers_queue = client.notifications("blockchain.headers.subscribe")
|
||||
scripthash_queue = client.notifications("blockchain.scripthash.subscribe")
|
||||
try:
|
||||
await asyncio.gather(
|
||||
self._consume_headers(headers_queue),
|
||||
self._consume_scripthash(scripthash_queue),
|
||||
)
|
||||
header = await client.subscribe_headers()
|
||||
self._apply_header(header)
|
||||
|
||||
headers_queue = client.notifications("blockchain.headers.subscribe")
|
||||
scripthash_queue = client.notifications("blockchain.scripthash.subscribe")
|
||||
# The consumers below block on their queues forever by design, so they
|
||||
# can never notice the connection dying — client.wait_closed() and the
|
||||
# keepalive are what make the drop observable. Whichever finishes first
|
||||
# ends the session and sends run() around to the next server.
|
||||
tasks = [
|
||||
asyncio.create_task(self._consume_headers(headers_queue)),
|
||||
asyncio.create_task(self._consume_scripthash(scripthash_queue)),
|
||||
asyncio.create_task(self._keepalive(client)),
|
||||
asyncio.create_task(client.wait_closed()),
|
||||
]
|
||||
# B-31: resubscribing every user is O(users) sequential round-trips —
|
||||
# at thousands of users that's minutes during which, previously,
|
||||
# nothing above had started yet: tip_height was frozen and an
|
||||
# in-flight draw's _wait_for_next_block made zero progress for the
|
||||
# entire resubscribe. Running it as its own background task instead
|
||||
# of awaiting it inline here means tip updates (and notifications for
|
||||
# whichever users are already subscribed) keep flowing throughout.
|
||||
# It's deliberately not one of the raced `tasks` above: unlike those,
|
||||
# it's expected to finish normally, and its own completion must not
|
||||
# look like the session ending. Any failure partway through is
|
||||
# logged the same way address_for_new_user's background task is
|
||||
# (B-30), and it's cancelled below along with everything else once
|
||||
# the session actually does end.
|
||||
subscribe_task = asyncio.create_task(self._subscribe_all_users())
|
||||
subscribe_task.add_done_callback(self._log_subscribe_all_users_failure)
|
||||
try:
|
||||
done, still_running = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
subscribe_task.cancel()
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
for task in done:
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
logger.warning("Electrum session on %s ending: %r", endpoint, exc)
|
||||
finally:
|
||||
self.client = None
|
||||
await client.close()
|
||||
return True
|
||||
|
||||
def _log_subscribe_all_users_failure(self, task: asyncio.Task) -> None:
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
logger.warning("resubscribing all users failed partway through: %r", exc)
|
||||
|
||||
async def _keepalive(self, client: ElectrumClient) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(_PING_INTERVAL_SECONDS)
|
||||
await client.ping() # raises (and so ends the session) on timeout or a dead socket
|
||||
|
||||
async def _subscribe_all_users(self) -> None:
|
||||
"""B-31: subscribes with bounded concurrency (_RESUBSCRIBE_CONCURRENCY at
|
||||
a time) instead of one user at a time — at thousands of users a serial
|
||||
loop meant thousands of sequential round-trips. One user's failure (a
|
||||
single slow or briefly-erroring request) must not stop the rest from
|
||||
being subscribed, mirroring the same per-item isolation used elsewhere
|
||||
(e.g. tx/confirmation.py's poll_once, deposits/reconcile.py's sweep)."""
|
||||
async with self._session_factory() as session:
|
||||
users = (await session.scalars(select(User))).all()
|
||||
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
|
||||
await self._subscribe_and_refresh(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.
|
||||
|
||||
Full reorg handling is out of scope for v1 by explicit design decision, but
|
||||
the tip must never regress: `_wait_for_next_block` waits for
|
||||
`tip_height > tip_at_close`, so a lower height would silently add a block to
|
||||
the draw's wait. height and hex are applied together or not at all —
|
||||
applying a losing header's hex would leave tip_height and tip_header_hex
|
||||
describing different blocks, and that hex is the draw's entropy source.
|
||||
|
||||
Two validation checks guard against a hostile or MITM'd server simply
|
||||
fabricating a header (B-28), since that header is the draw's sole source of
|
||||
entropy: it must satisfy the difficulty target it claims for itself, and —
|
||||
when it's a direct single-block advance from our own current tip, the only
|
||||
case we can check without a full header chain — it must chain from that
|
||||
tip's hash. Either failure raises HeaderValidationError rather than
|
||||
silently ignoring the header, which (via _consume_headers/_run_once) ends
|
||||
this session the same way a dropped connection would, so run() rotates to
|
||||
the next configured server instead of continuing to trust this one.
|
||||
"""
|
||||
height = header["height"]
|
||||
header_hex = header.get("hex")
|
||||
if height < self.tip_height:
|
||||
logger.warning(
|
||||
"ignoring Electrum header at height %s, below the current tip %s (reorg or server switch?)",
|
||||
height,
|
||||
self.tip_height,
|
||||
)
|
||||
return
|
||||
|
||||
if header_hex:
|
||||
if not header_meets_its_own_target(header_hex):
|
||||
raise HeaderValidationError(
|
||||
f"header at height {height} does not satisfy its own claimed difficulty target"
|
||||
)
|
||||
if (
|
||||
self.tip_header_hex
|
||||
and height == self.tip_height + 1
|
||||
and header_prev_hash(header_hex) != header_hex_to_block_hash(self.tip_header_hex)
|
||||
):
|
||||
raise HeaderValidationError(
|
||||
f"header at height {height} does not chain from the current tip (height {self.tip_height})"
|
||||
)
|
||||
|
||||
self.tip_height = height
|
||||
self.tip_header_hex = header_hex
|
||||
|
||||
async def _corroborate_majority(
|
||||
self,
|
||||
ask: Callable[[ElectrumEndpoint], "asyncio.Future"],
|
||||
agrees: Callable[[object], bool],
|
||||
description: str,
|
||||
) -> bool:
|
||||
"""Shared quorum logic behind corroborate_header (B-28) and
|
||||
corroborate_utxo_spent (B-29): ask every *other* configured server (never
|
||||
the currently active one — that's exactly what a hostile server or a MITM
|
||||
would control) and require a strict majority of the ones that actually
|
||||
answer to agree, via `agrees`, with what our own connection reported.
|
||||
|
||||
Returns True with no other servers configured — nothing to corroborate
|
||||
against, a risk accepted when ELECTRUM_FALLBACK_SERVERS was left empty
|
||||
(see CLAUDE.md). Returns False (never silently "passes") if none of the
|
||||
others could be reached, since an unreachable network proves nothing
|
||||
either way.
|
||||
"""
|
||||
others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
|
||||
if not others:
|
||||
return True
|
||||
|
||||
results = await asyncio.gather(*(ask(endpoint) for endpoint in others))
|
||||
responded = [result for result in results if result is not None]
|
||||
if not responded:
|
||||
logger.warning(
|
||||
"could not corroborate %s with any of %s other configured server(s)", description, len(others)
|
||||
)
|
||||
return False
|
||||
|
||||
agreements = sum(1 for result in responded if agrees(result))
|
||||
return agreements * 2 > len(responded)
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
"""B-28: is `expected_hash` — the header our own active connection
|
||||
reported for `height` — corroborated by other configured servers before
|
||||
the draw (rounds/scheduler.py:_wait_for_next_block) treats it as
|
||||
trustworthy entropy? Without this, a single hostile server (or a MITM on
|
||||
the one active connection) can single-handedly decide who wins every
|
||||
round; this raises the bar to controlling a majority of the configured
|
||||
servers. See _corroborate_majority for the shared quorum logic.
|
||||
"""
|
||||
|
||||
async def _ask(endpoint: ElectrumEndpoint) -> str | None:
|
||||
client = self._client_factory(endpoint)
|
||||
try:
|
||||
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
result = await asyncio.wait_for(
|
||||
client.request("blockchain.block.header", [height]),
|
||||
timeout=_CORROBORATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
if not isinstance(result, str):
|
||||
return None
|
||||
return header_hex_to_block_hash(result)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return await self._corroborate_majority(_ask, lambda block_hash: block_hash == expected_hash, f"block {height} header")
|
||||
|
||||
async def corroborate_utxo_spent(self, scripthash: str, txid: str, vout: int) -> bool:
|
||||
"""B-29: before deposits/service.py's find_utxos_missing_from candidates
|
||||
are treated as genuinely spent outside the platform, ask the other
|
||||
configured servers whether *they* also no longer report this outpoint as
|
||||
unspent. A single broken, behind, or malicious server could otherwise zero
|
||||
a user's balance on one incomplete listunspent reply. See
|
||||
_corroborate_majority for the shared quorum logic.
|
||||
"""
|
||||
|
||||
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
|
||||
client = self._client_factory(endpoint)
|
||||
try:
|
||||
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
entries = await asyncio.wait_for(
|
||||
client.listunspent(scripthash), timeout=_CORROBORATION_TIMEOUT_SECONDS
|
||||
)
|
||||
still_unspent = any(e.get("tx_hash") == txid and e.get("tx_pos") == vout for e in entries)
|
||||
return not still_unspent # True = this server agrees the outpoint is gone
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return await self._corroborate_majority(_ask, lambda agrees: agrees, f"outpoint {txid}:{vout}")
|
||||
|
||||
async def _consume_headers(self, queue: asyncio.Queue) -> None:
|
||||
while True:
|
||||
params = await queue.get()
|
||||
for header in params:
|
||||
self.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 +396,38 @@ 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 any
|
||||
candidate external spends against other servers (B-29), then persist.
|
||||
"""
|
||||
assert self.client is not None
|
||||
entries = await self.client.listunspent(scripthash)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
credited = await credit_confirmed_utxos(session, user_id, entries)
|
||||
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
|
||||
candidates = [
|
||||
(row.id, row.txid, row.vout)
|
||||
for row in await find_utxos_missing_from(session, user_id, entries)
|
||||
]
|
||||
|
||||
confirmed_ids = [
|
||||
utxo_id
|
||||
for utxo_id, txid, vout in candidates
|
||||
if await self.corroborate_utxo_spent(scripthash, txid, vout)
|
||||
]
|
||||
|
||||
spent_externally = 0
|
||||
if confirmed_ids:
|
||||
async with self._session_factory() as session:
|
||||
spent_externally = await mark_utxos_spent_externally(session, user_id, confirmed_ids)
|
||||
|
||||
if credited:
|
||||
logger.info("credited %s new UTXO(s) for user_id=%s", credited, user_id)
|
||||
if reinstated:
|
||||
logger.info("reinstated %s previously-flagged UTXO(s) for user_id=%s", reinstated, user_id)
|
||||
if spent_externally:
|
||||
logger.warning("%s UTXO(s) spent outside the platform for user_id=%s", spent_externally, user_id)
|
||||
|
||||
+52
-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
|
||||
|
||||
@@ -20,37 +20,60 @@ 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,7 +84,16 @@ 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)
|
||||
@@ -73,8 +105,14 @@ 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 +135,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,7 +8,13 @@ from app.tx.confirmation import register_handler
|
||||
|
||||
|
||||
async def _on_payout_confirmed(session: AsyncSession, pending: PendingTransaction) -> None:
|
||||
round_ = await session.scalar(select(Round).where(Round.payout_txid == pending.current_txid))
|
||||
# Resolved by round_id rather than by payout_txid: an RBF-bumped payout confirms
|
||||
# under a different txid than the one first recorded (B-02).
|
||||
round_ = None
|
||||
if pending.round_id is not None:
|
||||
round_ = await session.get(Round, pending.round_id)
|
||||
if round_ is None:
|
||||
round_ = await session.scalar(select(Round).where(Round.payout_txid == pending.current_txid))
|
||||
if round_ is not None and round_.status == "paying_out":
|
||||
round_.status = "closed"
|
||||
# closed_at is what round_cooldown_seconds counts from (service.py's
|
||||
|
||||
@@ -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)
|
||||
|
||||
+294
-46
@@ -3,11 +3,12 @@ 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
|
||||
@@ -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
|
||||
@@ -55,8 +70,17 @@ class RoundScheduler:
|
||||
round_id, status, opened_at = round_.id, round_.status, round_.opened_at
|
||||
round_duration_seconds = (await get_round_config(session)).round_duration_seconds
|
||||
|
||||
if status == "paying_out":
|
||||
# B-26: _trigger_payout used to run exactly once, from _close_and_draw —
|
||||
# any failure after that (no Electrum client, insufficient pool UTXOs, a
|
||||
# rejected broadcast) or a process restart while paying_out left the round
|
||||
# wedged here forever. Every tick now re-checks and retries, throttled by
|
||||
# _retry_payout_if_due so a persistent failure doesn't retry on every tick.
|
||||
await self._retry_payout_if_due(round_id)
|
||||
return
|
||||
|
||||
if status not in ("open", "closing"):
|
||||
return # 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)
|
||||
@@ -75,10 +99,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"
|
||||
@@ -114,11 +145,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 +181,290 @@ 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]:
|
||||
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
|
||||
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
|
||||
return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex)
|
||||
async def _wait_for_next_block(
|
||||
self, round_id: int, tip_at_close: int, waiting_since: datetime
|
||||
) -> tuple[int, str]:
|
||||
"""Waits for a block after tip_at_close and, before handing it back as the
|
||||
draw's entropy source, requires it to be corroborated by the other
|
||||
configured Electrum servers (B-28) — our own active connection is exactly
|
||||
the thing a hostile server or a MITM would control, so its header alone is
|
||||
not enough to seed a payout. A candidate that fails corroboration is never
|
||||
used: this keeps waiting for a further block and tries corroborating that
|
||||
one instead, logging why every time so a stuck draw is visible in
|
||||
/admin's audit log rather than a silent, unexplained wait.
|
||||
|
||||
This wait has no timeout — it can't, since the draw's entropy genuinely
|
||||
depends on a future block. B-36: what it lacked was *visibility*, so a
|
||||
connection that stopped advancing the tip left the round silently frozen
|
||||
in "drawing" with nothing in the logs or /admin to explain why. Progress
|
||||
is now logged periodically, and past _DRAW_STALL_THRESHOLD_SECONDS a
|
||||
draw_stalled audit entry is written (and re-written every threshold
|
||||
interval for as long as the stall continues) so the wait shows up next
|
||||
to the draw_header_corroboration_failed entries above.
|
||||
"""
|
||||
next_progress_log_at = waiting_since + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
|
||||
next_stall_audit_at = waiting_since + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
||||
while True:
|
||||
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
|
||||
now = datetime.now(timezone.utc)
|
||||
if now >= next_progress_log_at:
|
||||
logger.info(
|
||||
"round %s: still waiting for a block past height %s (%.0fs since drawing started)",
|
||||
round_id,
|
||||
tip_at_close,
|
||||
(now - waiting_since).total_seconds(),
|
||||
)
|
||||
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
|
||||
if now >= next_stall_audit_at:
|
||||
async with self._session_factory() as session:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"draw_stalled",
|
||||
{
|
||||
"tip_at_close": tip_at_close,
|
||||
"current_tip_height": self._listener.tip_height,
|
||||
"elapsed_seconds": int((now - waiting_since).total_seconds()),
|
||||
},
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
||||
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
|
||||
height = self._listener.tip_height
|
||||
block_hash = header_hex_to_block_hash(self._listener.tip_header_hex)
|
||||
if await self._listener.corroborate_header(height, block_hash):
|
||||
return height, block_hash
|
||||
logger.error(
|
||||
"round %s: block %s header %s could not be corroborated by other Electrum servers; "
|
||||
"waiting for a further block",
|
||||
round_id,
|
||||
height,
|
||||
block_hash,
|
||||
)
|
||||
async with self._session_factory() as session:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"draw_header_corroboration_failed",
|
||||
{"height": height, "reported_hash": block_hash},
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
tip_at_close = height
|
||||
|
||||
async def _retry_payout_if_due(self, round_id: int) -> None:
|
||||
"""B-26: whether a "paying_out" round is due for another payout attempt.
|
||||
|
||||
Throttled by the most recent payout_failed audit entry for this round
|
||||
(written by _log_payout_failure on every early return in _trigger_payout,
|
||||
including ones that used to fail silently) rather than by any new DB state,
|
||||
since a failed attempt doesn't necessarily leave a PendingTransaction behind
|
||||
(a build failure like a missing fee_address never gets that far). No entry
|
||||
yet means this round hasn't failed before — either it's a fresh "paying_out"
|
||||
(the very first call already happened from _close_and_draw and hasn't had a
|
||||
chance to fail yet) or the process restarted before ever recording one —
|
||||
either way it's due immediately.
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
last_failure_at = await session.scalar(
|
||||
select(AuditLog.created_at)
|
||||
.where(AuditLog.event_type == "payout_failed", AuditLog.round_id == round_id)
|
||||
.order_by(AuditLog.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if last_failure_at is not None:
|
||||
last_failure_at = last_failure_at.replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) < last_failure_at + timedelta(seconds=_PAYOUT_RETRY_INTERVAL_SECONDS):
|
||||
return # too soon — avoid hammering a persistently-broken payout
|
||||
await self._trigger_payout(round_id)
|
||||
|
||||
async def _trigger_payout(self, round_id: int) -> None:
|
||||
"""Four phases, so no DB session is held across a network call (B-18): read
|
||||
what's needed, build the tx, persist the intent, then broadcast.
|
||||
|
||||
The persist happens *before* the broadcast (B-25) — the same two-phase shape
|
||||
as place_bet/request_withdrawal (B-08): a crash between building the payout
|
||||
and recording it used to leave money on-chain with zero trace in the DB (no
|
||||
payout_txid, no PendingTransaction), so a manual retry would have paid the
|
||||
winner a second time. Now the worst case is a "building" PendingTransaction
|
||||
the reconciler (app/tx/reconcile.py) can resolve either way by asking the
|
||||
chain whether the tx exists, exactly like it already does for bets and
|
||||
withdrawals.
|
||||
"""
|
||||
client = self._listener.client
|
||||
if client is None:
|
||||
logger.error("round %s payout deferred: not connected", round_id)
|
||||
await self._log_payout_failure(round_id, None, "electrum client not connected")
|
||||
return
|
||||
|
||||
# --- Phase 1: read (session closed before any network I/O) ---------------
|
||||
async with self._session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
config = await get_round_config(session)
|
||||
if not config.fee_address:
|
||||
logger.error(
|
||||
"round %s payout blocked: no fee_address configured (set it via the admin endpoint)", round_id
|
||||
already_in_flight = await session.scalar(
|
||||
select(PendingTransaction).where(
|
||||
PendingTransaction.round_id == round_id,
|
||||
PendingTransaction.kind == "payout",
|
||||
PendingTransaction.status.in_(("building", "pending")),
|
||||
)
|
||||
)
|
||||
if already_in_flight is not None:
|
||||
# A payout for this round is already building or broadcast — this
|
||||
# must not build a second one, or a retry (manual, or a future
|
||||
# automatic one) would pay the winner twice. Confirmation/
|
||||
# reconciliation already owns resolving that row.
|
||||
logger.info(
|
||||
"round %s payout already in flight (pending_transaction %s), skipping",
|
||||
round_id,
|
||||
already_in_flight.id,
|
||||
)
|
||||
return
|
||||
reserved_outpoints = await _reserved_payout_outpoints(session)
|
||||
config = await get_round_config(session)
|
||||
fee_address = config.fee_address
|
||||
fee_rate = config.fee_rate_sat_vb
|
||||
pool_amount_sats = round_.pool_amount_sats
|
||||
winner_user_id = round_.winner_user_id
|
||||
winner = await session.get(User, winner_user_id)
|
||||
winner_address = winner.address if winner is not None else None
|
||||
await session.commit() # get_round_config may have created the row
|
||||
|
||||
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
|
||||
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_share = pool_amount_sats * 70 // 100
|
||||
commission_share = pool_amount_sats - winner_share # 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,
|
||||
commission_sats=commission_share,
|
||||
change_address=pool_address,
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
)
|
||||
except InsufficientFundsError:
|
||||
logger.exception("round %s payout failed: insufficient pool UTXOs", round_id)
|
||||
return
|
||||
|
||||
await client.broadcast(built.raw_hex)
|
||||
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=fee_address,
|
||||
commission_sats=commission_share,
|
||||
change_address=pool_address,
|
||||
fee_rate_sat_vb=fee_rate,
|
||||
)
|
||||
except InsufficientFundsError as exc:
|
||||
# Includes the B-48 "too_many_inputs" case: the pool holds enough, but spread
|
||||
# over more UTXOs than one transaction may spend, so /admin has to say which.
|
||||
reason = "insufficient pool UTXOs" if exc.code == "insufficient_balance" else exc.code
|
||||
logger.exception("round %s payout failed: %s", round_id, reason)
|
||||
await self._log_payout_failure(round_id, winner_user_id, reason)
|
||||
return
|
||||
except Exception:
|
||||
# Anything else — a malformed fee_address (EmbitError) or similar. This
|
||||
# used to escape all the way to run()'s catch-all, which logged it
|
||||
# without recording anything, leaving no trace of *why* the round was
|
||||
# stuck (B-05). _retry_payout_if_due (B-26) is what turns this recorded
|
||||
# failure into an automatic retry instead of a dead end.
|
||||
logger.exception("round %s payout build failed", round_id)
|
||||
await self._log_payout_failure(round_id, winner_user_id, "payout build failed")
|
||||
return
|
||||
|
||||
# --- Phase 3: persist the intent, *then* broadcast (B-25) -----------------
|
||||
async with self._session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
round_.winner_amount_sats = built.winner_sats
|
||||
round_.fee_amount_sats = built.commission_sats
|
||||
round_.payout_txid = built.txid
|
||||
session.add(
|
||||
PendingTransaction(
|
||||
kind="payout",
|
||||
round_id=round_id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
status="pending",
|
||||
)
|
||||
pending = PendingTransaction(
|
||||
kind="payout",
|
||||
round_id=round_id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=fee_rate,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
# "building" until the broadcast succeeds, exactly like place_bet's
|
||||
# two phases — see the reconciler, which gives this a short grace
|
||||
# period before asking the chain whether it made it out after all.
|
||||
status="building",
|
||||
)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
pending_id = pending.id
|
||||
|
||||
# --- Phase 4: broadcast, then promote the pending row --------------------
|
||||
try:
|
||||
await client.broadcast(built.raw_hex)
|
||||
except Exception:
|
||||
# The row stays "building": the reconciler will ask the chain about it
|
||||
# and, finding nothing, abandon it and clear payout_txid (B-25) — instead
|
||||
# of the round being stuck with a payout_txid that never went anywhere.
|
||||
logger.exception("round %s payout broadcast failed", round_id)
|
||||
await self._log_payout_failure(round_id, winner_user_id, "broadcast rejected")
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
pending.status = "pending"
|
||||
await write_audit_log(
|
||||
session,
|
||||
"payout_sent",
|
||||
{"txid": built.txid, "winner_sats": built.winner_sats, "commission_sats": built.commission_sats},
|
||||
user_id=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
|
||||
|
||||
+55
-11
@@ -1,20 +1,43 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Round
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out")
|
||||
|
||||
# Bounded: a conflict means someone else is opening a round right now, so a couple
|
||||
# of retries is plenty. Unbounded retries could spin if the invariant were ever
|
||||
# broken in a way we don't anticipate.
|
||||
_OPEN_ROUND_ATTEMPTS = 3
|
||||
|
||||
|
||||
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:
|
||||
@@ -55,12 +78,33 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
|
||||
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=config.round_cooldown_seconds):
|
||||
return None
|
||||
|
||||
round_ = Round(status="open")
|
||||
session.add(round_)
|
||||
await session.flush()
|
||||
# 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_
|
||||
for attempt in range(_OPEN_ROUND_ATTEMPTS):
|
||||
round_ = Round(status="open")
|
||||
session.add(round_)
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError:
|
||||
# Another caller (the scheduler tick, or a concurrent place_bet) got
|
||||
# there first — ix_rounds_single_active turns what used to be two live
|
||||
# rounds into a clean failure here. Roll our insert back and use theirs.
|
||||
# Safe to roll back: this runs before its callers have written anything
|
||||
# else in this session.
|
||||
await session.rollback()
|
||||
existing = await get_active_round(session)
|
||||
if existing is not None:
|
||||
logger.info("lost the race to open a round; using round %s", existing.id)
|
||||
return existing
|
||||
# Nothing active *and* the insert conflicted: the winner's transaction
|
||||
# hadn't committed yet when we looked. Try again rather than failing the
|
||||
# caller — a bet shouldn't 500 because of a scheduler tick's timing.
|
||||
logger.info("round-open conflict with nothing active yet (attempt %s), retrying", attempt + 1)
|
||||
continue
|
||||
# Published pre-commit (the caller commits right after) — acceptable: this
|
||||
# only tells subscribers "go refetch", and by the time an SSE client's
|
||||
# refetch request actually lands, this in-process commit (microseconds
|
||||
# away) has essentially always already happened.
|
||||
broadcaster.publish()
|
||||
return round_
|
||||
|
||||
logger.error("could not open a round after %s attempts", _OPEN_ROUND_ATTEMPTS)
|
||||
return await get_active_round(session)
|
||||
|
||||
+12
-4
@@ -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) {
|
||||
@@ -218,7 +226,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>
|
||||
@@ -288,9 +296,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>';
|
||||
|
||||
+60
-17
@@ -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');
|
||||
@@ -17,17 +31,35 @@ function toast(message, type) {
|
||||
// <span data-i18n=...>, both of which a textContent round-trip would flatten away
|
||||
// — losing the icon for good and, worse, stripping the data-i18n hook so the
|
||||
// button would stop following later language changes.
|
||||
//
|
||||
// Only the outermost call owns the markup. refreshMe() is fired from the SSE
|
||||
// handler, the poll chain, placeBet, withdraw and showDashboard, all sharing
|
||||
// #refresh-btn: two overlapping calls used to make the second one snapshot the
|
||||
// *loading* label and then restore it permanently, leaving the button stuck on
|
||||
// "Aggiornamento…" (B-23). A nested call now just awaits the one already running.
|
||||
const _loadingByButton = new WeakMap();
|
||||
|
||||
async function withLoading(button, label, fn) {
|
||||
const inFlight = _loadingByButton.get(button);
|
||||
if (inFlight) {
|
||||
await inFlight.catch(() => {}); // its own caller reports the failure
|
||||
return fn();
|
||||
}
|
||||
const original = button.innerHTML;
|
||||
button.disabled = true;
|
||||
button.textContent = label;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.innerHTML = original;
|
||||
applyStaticTranslations(button); // the snapshot may predate a language switch made while loading
|
||||
}
|
||||
const run = (async () => {
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.innerHTML = original;
|
||||
applyStaticTranslations(button); // the snapshot may predate a language switch made while loading
|
||||
_loadingByButton.delete(button);
|
||||
}
|
||||
})();
|
||||
_loadingByButton.set(button, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
@@ -83,7 +115,7 @@ 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'] = value / SATS_PER_PLM;
|
||||
out[key.slice(0, -5) + '_plm'] = formatPlm(value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -292,7 +324,7 @@ function renderPersistedResult(result) {
|
||||
setRoundInfoVisible(false);
|
||||
setResultBoxVisible(
|
||||
true,
|
||||
result.won ? t('result.win', { amount: result.amount_sats / SATS_PER_PLM }) : t('result.lose'),
|
||||
result.won ? t('result.win', { amount: formatPlm(result.amount_sats) }) : t('result.lose'),
|
||||
result.won ? 'win' : 'lose'
|
||||
);
|
||||
}
|
||||
@@ -328,7 +360,7 @@ 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;
|
||||
const won = formatPlm(data.amount_sats);
|
||||
toast(t('toast.roundWon', { id: data.round_id, amount: won }), 'success');
|
||||
refreshMe();
|
||||
}
|
||||
@@ -395,7 +427,7 @@ function renderBetButton() {
|
||||
if (btn.disabled) return;
|
||||
btn.textContent = betAmountSats === null
|
||||
? t('bet.buttonNoAmount')
|
||||
: t('bet.button', { amount: betAmountSats / SATS_PER_PLM });
|
||||
: t('bet.button', { amount: formatPlm(betAmountSats) });
|
||||
}
|
||||
|
||||
function showNormalState() {
|
||||
@@ -422,7 +454,7 @@ async function refreshRound() {
|
||||
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;
|
||||
jackpotEl.textContent = formatPlm(data.jackpot_sats);
|
||||
if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) {
|
||||
jackpotEl.classList.remove('jackpot-bump');
|
||||
void jackpotEl.offsetWidth; // restart the animation
|
||||
@@ -464,7 +496,7 @@ async function refreshRound() {
|
||||
if (!alreadyKnown) {
|
||||
persistResult(data.round_id, won, data.winner_amount_sats);
|
||||
if (won) {
|
||||
const wonAmount = (data.winner_amount_sats / SATS_PER_PLM);
|
||||
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
|
||||
}
|
||||
@@ -555,6 +587,12 @@ async function register() {
|
||||
toast(t('toast.passwordMismatch'), 'error');
|
||||
return;
|
||||
}
|
||||
// Mirrors what the server now enforces (app/auth/routes.py's RegisterRequest),
|
||||
// so the failure is immediate and translated instead of a generic 422 (B-12).
|
||||
if (p.length < 8) {
|
||||
toast(t('toast.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
await withLoading(btn, t('loading.creating'), async () => {
|
||||
try {
|
||||
const data = await call('POST', '/auth/register', { username: u, password: p });
|
||||
@@ -657,7 +695,7 @@ 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);
|
||||
}
|
||||
@@ -670,14 +708,14 @@ async function refreshMe() {
|
||||
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(currentDateLocale());
|
||||
document.getElementById('wd-full-amount-value').textContent = data.balance_sats / SATS_PER_PLM;
|
||||
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;
|
||||
}
|
||||
@@ -711,10 +749,15 @@ async function changePassword() {
|
||||
|
||||
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 = '';
|
||||
|
||||
@@ -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>
|
||||
@@ -121,6 +121,7 @@ const TRANSLATIONS = {
|
||||
'error.round_closing': 'The current round is closing, please try again shortly.',
|
||||
'error.already_betting': 'You already have an active bet in the current round.',
|
||||
'error.insufficient_balance': 'Insufficient balance.',
|
||||
'error.balance_pending_confirmation': 'You have {pending_plm} PLM pending confirmation — it is not spendable yet.',
|
||||
'error.amount_below_network_fee': 'The amount is too small to cover the network fee.',
|
||||
'error.invalid_address': 'Not a valid PLM address (it must start with plm1q…).',
|
||||
'error.amount_below_minimum': 'The minimum withdrawal amount is {minimum_plm} PLM.',
|
||||
@@ -132,6 +133,13 @@ const TRANSLATIONS = {
|
||||
'error.session_expired': 'Session expired, please log in again.',
|
||||
'error.invalid_request': 'Invalid request, please check the entered data.',
|
||||
'error.invalid_amount': 'Enter an amount greater than zero.',
|
||||
'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.',
|
||||
'error.amount_below_dust_limit': 'The amount is too small to be sent.',
|
||||
'error.too_many_inputs': 'Your balance is split across too many small deposits to be spent in a single transaction (max {max_inputs}). Please contact support to consolidate it.',
|
||||
'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.',
|
||||
'error.internal_error': 'Unexpected server error. Please try again shortly.',
|
||||
'error.guide_unavailable': 'The guide is not available right now.',
|
||||
'error.rate_limited': 'Too many attempts, please try again in {retry_after_seconds} seconds.',
|
||||
|
||||
'loading.creating': 'Creating…',
|
||||
'loading.loggingIn': 'Logging in…',
|
||||
@@ -253,6 +261,7 @@ const TRANSLATIONS = {
|
||||
'error.round_closing': 'Il round corrente si sta chiudendo, riprova tra poco.',
|
||||
'error.already_betting': 'Hai già una bet attiva nel round corrente.',
|
||||
'error.insufficient_balance': 'Saldo insufficiente.',
|
||||
'error.balance_pending_confirmation': 'Hai {pending_plm} PLM in attesa di conferma — non ancora disponibili per la spesa.',
|
||||
'error.amount_below_network_fee': "L'importo è troppo basso per coprire la fee di rete.",
|
||||
'error.invalid_address': 'Indirizzo PLM non valido (deve iniziare con plm1q…).',
|
||||
'error.amount_below_minimum': "L'importo minimo di prelievo è {minimum_plm} PLM.",
|
||||
@@ -264,6 +273,13 @@ const TRANSLATIONS = {
|
||||
'error.session_expired': 'Sessione scaduta, accedi di nuovo.',
|
||||
'error.invalid_request': 'Richiesta non valida, controlla i dati inseriti.',
|
||||
'error.invalid_amount': 'Inserisci un importo maggiore di zero.',
|
||||
'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.',
|
||||
'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.",
|
||||
'error.too_many_inputs': 'Il tuo saldo è suddiviso in troppi piccoli depositi per essere speso in una sola transazione (max {max_inputs}). Contatta l\'assistenza per consolidarlo.',
|
||||
'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.',
|
||||
'error.internal_error': 'Errore inatteso del server. Riprova tra poco.',
|
||||
'error.guide_unavailable': 'La guida non è disponibile in questo momento.',
|
||||
'error.rate_limited': 'Troppi tentativi, riprova tra {retry_after_seconds} secondi.',
|
||||
|
||||
'loading.creating': 'Creazione…',
|
||||
'loading.loggingIn': 'Accesso…',
|
||||
@@ -385,6 +401,7 @@ const TRANSLATIONS = {
|
||||
'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.',
|
||||
'error.already_betting': 'Ya tienes una apuesta activa en la ronda actual.',
|
||||
'error.insufficient_balance': 'Saldo insuficiente.',
|
||||
'error.balance_pending_confirmation': 'Tienes {pending_plm} PLM pendientes de confirmación — todavía no se pueden gastar.',
|
||||
'error.amount_below_network_fee': 'El importe es demasiado pequeño para cubrir la comisión de red.',
|
||||
'error.invalid_address': 'Dirección PLM no válida (debe empezar por plm1q…).',
|
||||
'error.amount_below_minimum': 'El importe mínimo de retiro es {minimum_plm} PLM.',
|
||||
@@ -396,6 +413,13 @@ const TRANSLATIONS = {
|
||||
'error.session_expired': 'Sesión caducada, vuelve a iniciar sesión.',
|
||||
'error.invalid_request': 'Solicitud no válida, revisa los datos introducidos.',
|
||||
'error.invalid_amount': 'Introduce un importe mayor que cero.',
|
||||
'error.broadcast_failed': 'La red rechazó la transacción. Inténtalo de nuevo en un momento.',
|
||||
'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.',
|
||||
'error.too_many_inputs': 'Tu saldo está repartido en demasiados depósitos pequeños para gastarse en una sola transacción (máx. {max_inputs}). Contacta con soporte para consolidarlo.',
|
||||
'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.',
|
||||
'error.internal_error': 'Error inesperado del servidor. Inténtalo de nuevo en un momento.',
|
||||
'error.guide_unavailable': 'La guía no está disponible en este momento.',
|
||||
'error.rate_limited': 'Demasiados intentos, inténtalo de nuevo en {retry_after_seconds} segundos.',
|
||||
|
||||
'loading.creating': 'Creando…',
|
||||
'loading.loggingIn': 'Entrando…',
|
||||
@@ -517,6 +541,7 @@ const TRANSLATIONS = {
|
||||
'error.round_closing': 'Le round en cours est en train de se fermer, réessayez dans un instant.',
|
||||
'error.already_betting': 'Vous avez déjà une mise active dans le round en cours.',
|
||||
'error.insufficient_balance': 'Solde insuffisant.',
|
||||
'error.balance_pending_confirmation': 'Vous avez {pending_plm} PLM en attente de confirmation — pas encore disponibles.',
|
||||
'error.amount_below_network_fee': 'Le montant est trop faible pour couvrir les frais de réseau.',
|
||||
'error.invalid_address': 'Adresse PLM invalide (elle doit commencer par plm1q…).',
|
||||
'error.amount_below_minimum': 'Le montant minimum de retrait est de {minimum_plm} PLM.',
|
||||
@@ -528,6 +553,13 @@ const TRANSLATIONS = {
|
||||
'error.session_expired': 'Session expirée, veuillez vous reconnecter.',
|
||||
'error.invalid_request': 'Requête invalide, vérifiez les données saisies.',
|
||||
'error.invalid_amount': 'Saisissez un montant supérieur à zéro.',
|
||||
'error.broadcast_failed': 'Le réseau a refusé la transaction. Veuillez réessayer dans un instant.',
|
||||
'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.",
|
||||
'error.too_many_inputs': 'Votre solde est réparti sur trop de petits dépôts pour être dépensé en une seule transaction (max {max_inputs}). Contactez le support pour le consolider.',
|
||||
'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.",
|
||||
'error.internal_error': 'Erreur inattendue du serveur. Veuillez réessayer dans un instant.',
|
||||
'error.guide_unavailable': "Le guide n'est pas disponible pour le moment.",
|
||||
'error.rate_limited': 'Trop de tentatives, réessayez dans {retry_after_seconds} secondes.',
|
||||
|
||||
'loading.creating': 'Création…',
|
||||
'loading.loggingIn': 'Connexion…',
|
||||
@@ -649,6 +681,7 @@ const TRANSLATIONS = {
|
||||
'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.',
|
||||
'error.already_betting': 'Du hast bereits eine aktive Wette in der laufenden Runde.',
|
||||
'error.insufficient_balance': 'Nicht genügend Guthaben.',
|
||||
'error.balance_pending_confirmation': 'Sie haben {pending_plm} PLM, die noch auf Bestätigung warten — noch nicht verfügbar.',
|
||||
'error.amount_below_network_fee': 'Der Betrag ist zu klein, um die Netzwerkgebühr zu decken.',
|
||||
'error.invalid_address': 'Keine gültige PLM-Adresse (sie muss mit plm1q… beginnen).',
|
||||
'error.amount_below_minimum': 'Der Mindestauszahlungsbetrag beträgt {minimum_plm} PLM.',
|
||||
@@ -660,6 +693,13 @@ const TRANSLATIONS = {
|
||||
'error.session_expired': 'Sitzung abgelaufen, bitte melde dich erneut an.',
|
||||
'error.invalid_request': 'Ungültige Anfrage, bitte überprüfe die eingegebenen Daten.',
|
||||
'error.invalid_amount': 'Gib einen Betrag größer als null ein.',
|
||||
'error.broadcast_failed': 'Das Netzwerk hat die Transaktion abgelehnt. Bitte versuche es in Kürze erneut.',
|
||||
'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.',
|
||||
'error.too_many_inputs': 'Ihr Guthaben ist auf zu viele kleine Einzahlungen verteilt, um in einer einzigen Transaktion ausgegeben zu werden (max. {max_inputs}). Bitte wenden Sie sich an den Support, um es zusammenzufassen.',
|
||||
'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.',
|
||||
'error.internal_error': 'Unerwarteter Serverfehler. Bitte versuche es in Kürze erneut.',
|
||||
'error.guide_unavailable': 'Die Anleitung ist derzeit nicht verfügbar.',
|
||||
'error.rate_limited': 'Zu viele Versuche, bitte versuche es in {retry_after_seconds} Sekunden erneut.',
|
||||
|
||||
'loading.creating': 'Wird erstellt…',
|
||||
'loading.loggingIn': 'Anmeldung…',
|
||||
@@ -781,6 +821,7 @@ const TRANSLATIONS = {
|
||||
'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.',
|
||||
'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.',
|
||||
'error.insufficient_balance': 'Недостаточно средств.',
|
||||
'error.balance_pending_confirmation': 'У вас есть {pending_plm} PLM, ожидающих подтверждения — они пока недоступны для расходования.',
|
||||
'error.amount_below_network_fee': 'Сумма слишком мала, чтобы покрыть комиссию сети.',
|
||||
'error.invalid_address': 'Некорректный адрес PLM (он должен начинаться с plm1q…).',
|
||||
'error.amount_below_minimum': 'Минимальная сумма вывода — {minimum_plm} PLM.',
|
||||
@@ -792,6 +833,13 @@ const TRANSLATIONS = {
|
||||
'error.session_expired': 'Сессия истекла, войдите снова.',
|
||||
'error.invalid_request': 'Некорректный запрос, проверьте введённые данные.',
|
||||
'error.invalid_amount': 'Введите сумму больше нуля.',
|
||||
'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.',
|
||||
'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.',
|
||||
'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.',
|
||||
'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.',
|
||||
'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.',
|
||||
'error.guide_unavailable': 'Руководство сейчас недоступно.',
|
||||
'error.rate_limited': 'Слишком много попыток, повторите через {retry_after_seconds} сек.',
|
||||
|
||||
'loading.creating': 'Создание…',
|
||||
'loading.loggingIn': 'Вход…',
|
||||
@@ -913,6 +961,7 @@ const TRANSLATIONS = {
|
||||
'error.round_closing': '当前回合正在结束,请稍后重试。',
|
||||
'error.already_betting': '你在当前回合已有一笔有效下注。',
|
||||
'error.insufficient_balance': '余额不足。',
|
||||
'error.balance_pending_confirmation': '您有 {pending_plm} PLM 待确认 —— 尚不可用于支出。',
|
||||
'error.amount_below_network_fee': '金额太小,不足以支付网络手续费。',
|
||||
'error.invalid_address': 'PLM 地址无效(必须以 plm1q… 开头)。',
|
||||
'error.amount_below_minimum': '最低提现金额为 {minimum_plm} PLM。',
|
||||
@@ -924,6 +973,13 @@ const TRANSLATIONS = {
|
||||
'error.session_expired': '会话已过期,请重新登录。',
|
||||
'error.invalid_request': '请求无效,请检查填写的内容。',
|
||||
'error.invalid_amount': '请输入大于零的金额。',
|
||||
'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。',
|
||||
'error.amount_below_dust_limit': '金额过小,无法发送。',
|
||||
'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。',
|
||||
'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。',
|
||||
'error.internal_error': '服务器发生意外错误,请稍后重试。',
|
||||
'error.guide_unavailable': '指南当前不可用。',
|
||||
'error.rate_limited': '尝试次数过多,请在 {retry_after_seconds} 秒后重试。',
|
||||
|
||||
'loading.creating': '正在创建…',
|
||||
'loading.loggingIn': '正在登录…',
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
<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" data-i18n-title="nav.bugReport" title="Segnala un bug" data-i18n-aria-label="nav.bugReport" 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()" 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>
|
||||
@@ -131,11 +131,11 @@
|
||||
|
||||
<div class="tab-panel" id="panel-register">
|
||||
<label for="reg-username" data-i18n="auth.username">Username</label>
|
||||
<input id="reg-username" autocomplete="username">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -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>Segnala un bug — PLM Lottery</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<h1>Segnala un bug</h1>
|
||||
<p>Questa pagina è un placeholder. Il modulo per la segnalazione dei bug sarà disponibile qui a breve.</p>
|
||||
<p><a class="link" href="/">← Torna alla home</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+155
-45
@@ -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)
|
||||
|
||||
pending.current_txid = new_txid
|
||||
pending.raw_tx_hex = raw_hex
|
||||
pending.fee_rate_sat_vb = new_fee_rate
|
||||
pending.attempt_count += 1
|
||||
pending.broadcast_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
# --- Phase 3: persist the outcome ----------------------------------------------
|
||||
async with session_factory() as session:
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
old_txid = pending.current_txid
|
||||
pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced
|
||||
pending.current_txid = new_txid
|
||||
pending.raw_tx_hex = raw_hex
|
||||
# The *actual* resulting rate, not target_fee_rate: when the BIP125-minimum
|
||||
# floor above raised fee_delta past the naive target, the tx now pays more
|
||||
# than target_fee_rate implied. Recording the true rate keeps the next bump's
|
||||
# arithmetic honest instead of drifting from what's really being paid.
|
||||
pending.fee_rate_sat_vb = (old_fee + fee_delta) // vsize
|
||||
pending.attempt_count += 1
|
||||
# last_broadcast_at, not broadcast_at (B-27): broadcast_at must stay the *first*
|
||||
# broadcast, since reconcile.py's abandon-after-N-hours grace period is measured
|
||||
# from it — overwriting it here used to reset that clock on every bump, so a
|
||||
# repeatedly-bumped-but-never-mined tx was never abandoned.
|
||||
pending.last_broadcast_at = datetime.now(timezone.utc)
|
||||
await _retarget_txid_references(session, pending, old_txid, new_txid)
|
||||
await session.commit()
|
||||
|
||||
logger.info("bumped %s pending_transaction %s: %s -> %s", 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
|
||||
try:
|
||||
await bump_fee(session, client, row)
|
||||
except RbfError:
|
||||
logger.exception("could not bump pending_transaction %s", row.id)
|
||||
except Exception:
|
||||
logger.exception("unexpected error bumping pending_transaction %s", row.id)
|
||||
for pending_id in due_ids:
|
||||
try:
|
||||
await bump_fee(self._session_factory, client, pending_id)
|
||||
except RbfError:
|
||||
logger.exception("could not bump pending_transaction %s", pending_id)
|
||||
except Exception:
|
||||
logger.exception("unexpected error bumping pending_transaction %s", pending_id)
|
||||
|
||||
+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,260 @@
|
||||
"""Resolves in-flight transactions against the chain.
|
||||
|
||||
Everything else in this codebase assumes a broadcast either confirms or gets
|
||||
fee-bumped until it does. Neither is guaranteed: an RBF bump raises RbfError
|
||||
whenever there's no change output big enough to absorb it (see tx/broadcast.py),
|
||||
a node can drop a low-fee tx from its mempool, and the process can die between
|
||||
building a transaction and broadcasting it. Without this module those cases were
|
||||
permanent: `spent_txid` was set at build time and never cleared, so the coins
|
||||
stayed spendable on-chain while the database considered them gone — the user's
|
||||
balance simply lost them, with no path back short of editing the DB by hand
|
||||
(B-04, and the "building" half of B-08).
|
||||
|
||||
What it does, per PendingTransaction that isn't already terminal:
|
||||
|
||||
* status "building" — we crashed (or were killed) between writing the row and
|
||||
broadcasting. Ask the chain: if the tx is there after all, promote everything
|
||||
to its live state; if it isn't, release the UTXOs and undo the intent.
|
||||
* status "pending" — broadcast, still unconfirmed. Left alone until it has been
|
||||
unconfirmed for `_ABANDON_AFTER_SECONDS`, since absence from one server's
|
||||
mempool is not proof of death; only then is it abandoned like the above.
|
||||
|
||||
Deliberately conservative: it never touches a tx the chain knows about, and the
|
||||
grace period is long (multiples of the RBF timeout) so a slow-but-alive tx is
|
||||
bumped by RbfBumper rather than abandoned here.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant, UtxoEvent, Withdrawal
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.events import broadcaster
|
||||
from app.tx.pending_address import own_address_for
|
||||
from app.wallet.balance import recompute_balance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POLL_INTERVAL_SECONDS = 120
|
||||
|
||||
# A "building" row means we never got confirmation that the broadcast happened, so
|
||||
# it only needs long enough to rule out a request still in flight.
|
||||
_BUILDING_GRACE_SECONDS = 120
|
||||
|
||||
# A "pending" row was accepted by a node once. Give it a wide margin — the RBF
|
||||
# bumper gets several attempts inside this window — before concluding it's gone.
|
||||
_ABANDON_AFTER_SECONDS = 6 * 60 * 60
|
||||
|
||||
|
||||
class PendingTransactionReconciler:
|
||||
def __init__(self, session_factory: async_sessionmaker, get_client: Callable[[], ElectrumClient | None]):
|
||||
self._session_factory = session_factory
|
||||
self._get_client = get_client
|
||||
|
||||
async def run(self) -> None:
|
||||
# Runs once promptly at startup: a crash mid-broadcast is exactly the case
|
||||
# that leaves a "building" row, and the restart is when we can clear it.
|
||||
while True:
|
||||
client = self._get_client()
|
||||
if client is not None:
|
||||
try:
|
||||
await reconcile_once(self._session_factory, client)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("pending-transaction reconciliation failed")
|
||||
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
|
||||
"""Returns how many rows were resolved (promoted or abandoned).
|
||||
|
||||
Existence is decided by checking whether a row's own address's history
|
||||
(blockchain.scripthash.get_history) includes its txid at all — mempool or
|
||||
mined — rather than asking blockchain.transaction.get for a verbose reply
|
||||
(B-41): several Electrum server implementations and versions reject the
|
||||
verbose flag outright, and the previous substring-matching on the error
|
||||
text (looking for "missing", "not found", ...) was fragile as the basis for
|
||||
a decision that releases funds. A transport failure fetching history still
|
||||
raises and leaves the row alone until next time — get_history not
|
||||
returning our txid is the only thing that means "gone". History is cached
|
||||
per scripthash within one pass, since every "payout" row shares the same
|
||||
pool address.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(PendingTransaction).where(PendingTransaction.status.in_(("building", "pending")))
|
||||
)
|
||||
).all()
|
||||
candidates = []
|
||||
for row in rows:
|
||||
if not _is_due(row, now):
|
||||
continue
|
||||
try:
|
||||
address = await own_address_for(session, row.kind, row.user_id)
|
||||
scripthash = address_to_scripthash(address)
|
||||
except Exception:
|
||||
logger.exception("could not derive the address for pending_transaction %s", row.id)
|
||||
continue
|
||||
candidates.append((row.id, row.status, row.current_txid, scripthash))
|
||||
|
||||
resolved = 0
|
||||
history_cache: dict[str, list[dict]] = {}
|
||||
for row_id, status, txid, scripthash in candidates:
|
||||
try:
|
||||
if scripthash not in history_cache:
|
||||
history_cache[scripthash] = await client.get_history(scripthash)
|
||||
exists = any(entry.get("tx_hash") == txid for entry in history_cache[scripthash])
|
||||
except Exception:
|
||||
# Transport/server problem — say nothing about this tx and try again on
|
||||
# the next pass rather than abandoning a tx that may be perfectly alive.
|
||||
logger.warning("could not check pending_transaction %s (txid %s) against the chain", row_id, txid)
|
||||
continue
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, row_id)
|
||||
if row is None or row.status != status:
|
||||
continue # something else moved it while we were asking
|
||||
if exists:
|
||||
if row.status == "building":
|
||||
await _promote(session, row)
|
||||
resolved += 1
|
||||
else:
|
||||
await _abandon(session, row, "not found on chain")
|
||||
resolved += 1
|
||||
await session.commit()
|
||||
broadcaster.publish()
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def _is_due(row: PendingTransaction, now: datetime) -> bool:
|
||||
# Deliberately broadcast_at (the *first* broadcast), not last_broadcast_at: an
|
||||
# RBF bump used to overwrite this same field, which reset this grace period on
|
||||
# every bump and meant a repeatedly-bumped-but-never-mined tx was never
|
||||
# abandoned (B-27). tx/broadcast.py:bump_fee now only ever touches
|
||||
# last_broadcast_at, so this keeps measuring from when the tx first appeared,
|
||||
# no matter how many times it's since been bumped.
|
||||
grace = _BUILDING_GRACE_SECONDS if row.status == "building" else _ABANDON_AFTER_SECONDS
|
||||
return now >= row.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=grace)
|
||||
|
||||
|
||||
async def _promote(session: AsyncSession, row: PendingTransaction) -> None:
|
||||
"""The tx did make it onto the chain before we died — finish what phase 2 of
|
||||
place_bet/request_withdrawal would have done."""
|
||||
row.status = "pending"
|
||||
if row.kind == "bet":
|
||||
participant = await session.scalar(
|
||||
select(RoundParticipant).where(
|
||||
RoundParticipant.round_id == row.round_id, RoundParticipant.user_id == row.user_id
|
||||
)
|
||||
)
|
||||
if participant is not None and participant.status == "building":
|
||||
participant.status = "broadcast"
|
||||
elif row.kind == "withdrawal" and row.withdrawal_id is not None:
|
||||
withdrawal = await session.get(Withdrawal, row.withdrawal_id)
|
||||
if withdrawal is not None and withdrawal.status == "building":
|
||||
withdrawal.status = "broadcast"
|
||||
await write_audit_log(
|
||||
session,
|
||||
"pending_tx_recovered",
|
||||
{"pending_transaction_id": row.id, "kind": row.kind, "txid": row.current_txid},
|
||||
user_id=row.user_id,
|
||||
round_id=row.round_id,
|
||||
)
|
||||
logger.info("recovered %s pending_transaction %s: tx %s is on-chain", row.kind, row.id, row.current_txid)
|
||||
|
||||
|
||||
async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str) -> None:
|
||||
"""The tx is gone for good. Release whatever it reserved so the funds come back,
|
||||
and roll the domain row back to something truthful."""
|
||||
row.status = "failed"
|
||||
row.failure_reason = reason[:128]
|
||||
|
||||
released = await _release_inputs(session, row)
|
||||
|
||||
if row.kind == "bet":
|
||||
participant = await session.scalar(
|
||||
select(RoundParticipant).where(
|
||||
RoundParticipant.round_id == row.round_id, RoundParticipant.user_id == row.user_id
|
||||
)
|
||||
)
|
||||
if participant is not None and participant.status in ("building", "broadcast"):
|
||||
# The bet never happened, so the user is not in this round. Removing the
|
||||
# row also unblocks the scheduler, which waits for every non-confirmed
|
||||
# participant before closing the round.
|
||||
await session.delete(participant)
|
||||
elif row.kind == "withdrawal" and row.withdrawal_id is not None:
|
||||
withdrawal = await session.get(Withdrawal, row.withdrawal_id)
|
||||
if withdrawal is not None and withdrawal.status in ("building", "broadcast"):
|
||||
withdrawal.status = "failed"
|
||||
withdrawal.txid = None
|
||||
elif row.kind == "payout":
|
||||
# Payout funds come from the pool address, which isn't tracked in
|
||||
# utxo_events, so there's nothing to release. Clearing payout_txid leaves the
|
||||
# round in "paying_out" with no tx attached, which is the state an operator
|
||||
# (or a future payout-retry routine — still an open gap) can act on.
|
||||
round_ = await session.get(Round, row.round_id) if row.round_id else None
|
||||
if round_ is not None and round_.status == "paying_out":
|
||||
round_.payout_txid = None
|
||||
logger.error(
|
||||
"round %s payout tx %s vanished — round needs operator attention", round_.id, row.current_txid
|
||||
)
|
||||
|
||||
if row.user_id is not None:
|
||||
await recompute_balance(session, row.user_id)
|
||||
|
||||
await write_audit_log(
|
||||
session,
|
||||
"pending_tx_abandoned",
|
||||
{
|
||||
"pending_transaction_id": row.id,
|
||||
"kind": row.kind,
|
||||
"txid": row.current_txid,
|
||||
"reason": reason,
|
||||
"utxos_released": released,
|
||||
},
|
||||
user_id=row.user_id,
|
||||
round_id=row.round_id,
|
||||
)
|
||||
logger.warning(
|
||||
"abandoned %s pending_transaction %s (txid %s): %s — released %s UTXO(s)",
|
||||
row.kind,
|
||||
row.id,
|
||||
row.current_txid,
|
||||
reason,
|
||||
released,
|
||||
)
|
||||
|
||||
|
||||
async def _release_inputs(session: AsyncSession, row: PendingTransaction) -> int:
|
||||
"""Clear spent_txid on every UTXO this transaction consumed, so the balance
|
||||
counts them again. The inputs come from the stored raw tx, which is kept current
|
||||
across RBF bumps, so this works for a bumped tx too."""
|
||||
try:
|
||||
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
|
||||
except Exception:
|
||||
logger.exception("could not parse raw tx of pending_transaction %s; inputs not released", row.id)
|
||||
return 0
|
||||
|
||||
released = 0
|
||||
for vin in tx.vin:
|
||||
utxo = await session.scalar(
|
||||
select(UtxoEvent).where(UtxoEvent.txid == vin.txid.hex(), UtxoEvent.vout == vin.vout)
|
||||
)
|
||||
# Only release what this tx actually reserved: if another tx has since spent
|
||||
# the same UTXO, its claim is the live one and must not be cleared.
|
||||
if utxo is not None and utxo.spent_txid == row.current_txid:
|
||||
utxo.spent_txid = None
|
||||
released += 1
|
||||
return released
|
||||
@@ -46,7 +46,10 @@ 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()
|
||||
|
||||
@@ -17,15 +17,46 @@ _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 transaction may spend (B-48). Every extra input costs
|
||||
# ~68 vbytes of fee, and that fee comes out of the amount being moved — so an address
|
||||
# fragmented into hundreds of small deposits would silently erode its own bet (shrinking
|
||||
# the user's share of the pool) or withdrawal, and past a few hundred inputs the tx also
|
||||
# stops being standard and gets refused at broadcast. Failing the build with a
|
||||
# translatable error is the honest outcome; consolidating the address is the way out.
|
||||
MAX_TX_INPUTS = 50
|
||||
|
||||
|
||||
class InsufficientFundsError(Exception):
|
||||
"""`code` is the machine-readable identifier the API layer forwards to the
|
||||
client so it can translate the failure (see app/api/errors.py); the message
|
||||
itself stays English."""
|
||||
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") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: str = "insufficient_balance",
|
||||
**params: int | str,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.params = params
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -52,11 +83,22 @@ def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
|
||||
def select_utxos(utxos: list[Utxo], target_sats: int) -> tuple[list[Utxo], int]:
|
||||
"""Greedily select UTXOs (largest first, to minimize input count) covering
|
||||
target_sats — the amount deducted from the sender's balance. The fee is paid
|
||||
out of target_sats (see build_signed_transaction), not added on top of it."""
|
||||
out of target_sats (see build_signed_transaction), not added on top of it.
|
||||
|
||||
At most MAX_TX_INPUTS are ever selected (B-48): if the largest MAX_TX_INPUTS
|
||||
UTXOs don't cover the target, the balance is there but too fragmented to spend
|
||||
in one transaction, which is a different failure from having no funds at all
|
||||
and gets its own code."""
|
||||
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
|
||||
selected: list[Utxo] = []
|
||||
total = 0
|
||||
for utxo in ordered:
|
||||
if len(selected) == MAX_TX_INPUTS:
|
||||
raise InsufficientFundsError(
|
||||
f"balance too fragmented: more than {MAX_TX_INPUTS} inputs would be needed",
|
||||
code="too_many_inputs",
|
||||
max_inputs=MAX_TX_INPUTS,
|
||||
)
|
||||
selected.append(utxo)
|
||||
total += utxo.amount_sats
|
||||
if total >= target_sats:
|
||||
@@ -81,6 +123,10 @@ 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.
|
||||
|
||||
A change amount below DUST_LIMIT_SATS is dropped and left to the fee — paying it
|
||||
back to ourselves would produce an unrelayable transaction. The fee estimate
|
||||
already assumes two outputs, so dropping one never underpays.
|
||||
"""
|
||||
selected, total_in = select_utxos(utxos, amount_sats)
|
||||
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
|
||||
@@ -90,6 +136,13 @@ def build_signed_transaction(
|
||||
"amount too small to cover the network fee", code="amount_below_network_fee"
|
||||
)
|
||||
change = total_in - amount_sats
|
||||
if change < DUST_LIMIT_SATS:
|
||||
fee += change # dust change is unspendable and unrelayable — miners get it
|
||||
change = 0
|
||||
if recipient_amount < DUST_LIMIT_SATS:
|
||||
raise InsufficientFundsError(
|
||||
"amount too small to be sent (dust)", code="amount_below_dust_limit"
|
||||
)
|
||||
|
||||
# TransactionInput.txid is natural/display byte order (as in tx_hash from Electrum);
|
||||
# embit reverses it internally when serializing to wire format.
|
||||
@@ -147,16 +200,26 @@ 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)."""
|
||||
target = winner_share_sats + commission_sats
|
||||
selected, total_in = select_utxos(utxos, target)
|
||||
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
|
||||
winner_amount = winner_share_sats - fee
|
||||
if winner_amount <= 0:
|
||||
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)
|
||||
|
||||
|
||||
+92
-17
@@ -9,9 +9,14 @@ from app.electrum.client import ElectrumClient
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
from app.wallet.address import is_valid_plm_address
|
||||
from app.wallet.balance import recompute_balance
|
||||
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(ApiError):
|
||||
@@ -27,6 +32,17 @@ async def request_withdrawal(
|
||||
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(
|
||||
@@ -40,7 +56,22 @@ async def request_withdrawal(
|
||||
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:
|
||||
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)
|
||||
@@ -58,10 +89,10 @@ async def request_withdrawal(
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
)
|
||||
except InsufficientFundsError as exc:
|
||||
raise WithdrawalError(exc.code, 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
|
||||
@@ -73,21 +104,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(
|
||||
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",
|
||||
)
|
||||
pending = PendingTransaction(
|
||||
kind="withdrawal",
|
||||
withdrawal_id=withdrawal.id,
|
||||
user_id=user.id,
|
||||
current_txid=built.txid,
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
raw_tx_hex=built.raw_hex,
|
||||
status="building",
|
||||
)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
await client.broadcast(built.raw_hex)
|
||||
except Exception as exc:
|
||||
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, str(exc))
|
||||
raise WithdrawalError(
|
||||
"broadcast_failed", f"the network refused the transaction: {exc}"
|
||||
) from exc
|
||||
|
||||
withdrawal.status = "broadcast"
|
||||
pending.status = "pending"
|
||||
await write_audit_log(
|
||||
session,
|
||||
"withdrawal_sent",
|
||||
@@ -99,3 +141,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)
|
||||
|
||||
+25
-2
@@ -108,11 +108,22 @@ piazzate, payout inviati, round chiusi, configurazione modificata, accessi
|
||||
alle chiavi private, ecc. È il primo posto da controllare per ricostruire
|
||||
cosa è successo dopo un problema.
|
||||
|
||||
Eventi a cui vale la pena prestare attenzione:
|
||||
|
||||
| Evento | Significato |
|
||||
|---|---|
|
||||
| `config_updated` | Un parametro è stato modificato; il payload contiene valore precedente e nuovo per ogni campo cambiato. |
|
||||
| `bet_broadcast_failed` / `withdrawal_broadcast_failed` | La rete ha rifiutato la transazione. Non è stato speso nulla: gli UTXO sono stati liberati e il saldo dell'utente è tornato come prima. |
|
||||
| `pending_tx_abandoned` | Una transazione trasmessa è scomparsa dalla catena e il sistema l'ha dichiarata persa: UTXO liberati, bet rimossa o prelievo segnato `failed`. Se capita spesso, la fee rate configurata è probabilmente troppo bassa. |
|
||||
| `pending_tx_recovered` | Una transazione che si credeva incompleta è invece finita in catena (tipicamente dopo un riavvio a metà invio) e il sistema l'ha ripresa da sé. |
|
||||
| `payout_failed` | Il payout di un round non è partito. Il round resta in `paying_out` e **richiede intervento manuale**: non esiste un retry automatico. Controlla `fee_address`, il saldo dell'indirizzo pool e la connessione Electrum. |
|
||||
|
||||
## Alternative all'interfaccia grafica
|
||||
|
||||
Le stesse operazioni si possono fare da terminale o da Swagger UI
|
||||
(`https://<host>/docs`, sezione `admin`), 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 +136,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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 ###
|
||||
+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,126 @@
|
||||
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_rate_limited_per_ip(client):
|
||||
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"
|
||||
@@ -8,8 +8,10 @@ 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_TX_INPUTS
|
||||
|
||||
|
||||
class FakeElectrumClient:
|
||||
@@ -91,6 +93,35 @@ 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 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()
|
||||
@@ -133,3 +164,99 @@ 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"]
|
||||
|
||||
+395
-10
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
|
||||
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 +11,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 +22,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 +31,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 +40,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 +141,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,6 +159,61 @@ async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory):
|
||||
assert row.attempt_count == 2
|
||||
|
||||
|
||||
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(97).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
utxo_amount = 150_000_000
|
||||
utxo_txid = "33" * 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,
|
||||
)
|
||||
|
||||
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):
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
|
||||
@@ -175,7 +253,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)
|
||||
with pytest.raises(RbfError):
|
||||
await bump_fee(session, client, row)
|
||||
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_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,35 @@
|
||||
"""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
|
||||
@@ -0,0 +1,43 @@
|
||||
"""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."""
|
||||
|
||||
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_first_hop_of_a_forwarded_chain():
|
||||
request = _request(forwarded="5.6.7.8, 10.0.0.1, 172.17.0.1")
|
||||
assert client_ip(request) == "5.6.7.8"
|
||||
|
||||
|
||||
def test_client_ip_strips_whitespace():
|
||||
request = _request(forwarded=" 5.6.7.8 , 10.0.0.1")
|
||||
assert client_ip(request) == "5.6.7.8"
|
||||
|
||||
|
||||
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,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 == []
|
||||
+134
-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,129 @@ 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
|
||||
|
||||
@@ -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
|
||||
+41
-10
@@ -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,
|
||||
)
|
||||
|
||||
# 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():
|
||||
# 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 = (
|
||||
"0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000"
|
||||
"009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875"
|
||||
"e6a47a61b1ad8012582"
|
||||
)
|
||||
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,632 @@
|
||||
"""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_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")
|
||||
|
||||
others_factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": [], "third.example": []}
|
||||
)
|
||||
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}]
|
||||
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_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
|
||||
@@ -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)
|
||||
@@ -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,11 @@
|
||||
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_TX_INPUTS,
|
||||
InsufficientFundsError,
|
||||
Utxo,
|
||||
build_signed_transaction,
|
||||
@@ -35,6 +37,22 @@ 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_build_signed_transaction_deducts_fee_from_amount_not_change():
|
||||
signer = _key(1)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
@@ -111,3 +129,75 @@ def test_build_signed_transaction_raises_when_amount_smaller_than_fee():
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
|
||||
def test_dust_change_is_left_to_the_fee():
|
||||
"""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."""
|
||||
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,
|
||||
)
|
||||
|
||||
tx = Transaction.parse(bytes.fromhex(built.raw_hex))
|
||||
assert len(tx.vout) == 1 # no dust output
|
||||
assert built.change_sats == 0
|
||||
# Nothing vanishes: the dust ends up in the fee, and inputs still equal outputs+fee.
|
||||
assert built.fee_sats >= dust_change
|
||||
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,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,100 @@ 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_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
|
||||
@@ -105,3 +106,68 @@ async def test_pause_does_not_interrupt_a_round_in_progress(session_factory):
|
||||
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
|
||||
|
||||
@@ -4,9 +4,10 @@ import pytest
|
||||
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, User
|
||||
from app.rounds.scheduler import RoundScheduler, _reserved_payout_outpoints
|
||||
|
||||
|
||||
class FakeListener:
|
||||
@@ -52,3 +53,407 @@ 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_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-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
|
||||
|
||||
@@ -9,5 +9,36 @@ 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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -2,9 +2,11 @@ import pytest
|
||||
from sqlalchemy import select
|
||||
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, User, UtxoEvent, Withdrawal
|
||||
from app.rounds.events import broadcaster
|
||||
from app.wallet.hd import derive_user_address
|
||||
from app.withdrawals.service import WithdrawalError, request_withdrawal
|
||||
|
||||
@@ -99,6 +101,35 @@ async def test_withdrawal_rejects_insufficient_balance(session_factory):
|
||||
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS)
|
||||
|
||||
|
||||
async def test_withdrawal_distinguishes_pending_from_truly_insufficient_balance(session_factory):
|
||||
"""B-37: right after a bet, cached_balance_sats is ~0 because the whole funding
|
||||
UTXO was spent as input and the change hasn't confirmed yet — but the UI shows
|
||||
the pending-inclusive balance (compute_pending_balance), which does cover a
|
||||
withdrawal of this size. The error must say "not confirmed yet", not flatly
|
||||
"insufficient balance", or it contradicts what the user is looking at."""
|
||||
user_id = await _make_funded_user(session_factory, 4, 3_000_000_000)
|
||||
bet_client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
await place_bet(session, bet_client, user)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 0 # the whole funding UTXO was spent as input
|
||||
|
||||
withdraw_client = FakeElectrumClient()
|
||||
with pytest.raises(WithdrawalError) as exc_info:
|
||||
# Above the withdrawal minimum (BET_AMOUNT_SATS) and covered by the
|
||||
# unconfirmed change (~1_999_800_000 sats), but not by the (zero)
|
||||
# confirmed balance.
|
||||
await request_withdrawal(session, withdraw_client, user, EXTERNAL_ADDRESS, 1_500_000_000)
|
||||
|
||||
assert exc_info.value.code == "balance_pending_confirmation"
|
||||
assert exc_info.value.params["pending_sats"] > 0
|
||||
assert not withdraw_client.broadcasted
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
@@ -122,3 +153,69 @@ async def test_withdrawal_rejects_non_plm_address(session_factory, address):
|
||||
|
||||
assert exc_info.value.code == "invalid_address"
|
||||
assert not client.broadcasted
|
||||
|
||||
|
||||
async def test_withdrawal_to_own_address_is_rejected(session_factory):
|
||||
"""B-17: allowed before, and it broke two things that assume the recipient and
|
||||
the change are distinguishable by address — the RBF bump would shrink the
|
||||
recipient output, and compute_pending_balance counted the amount twice."""
|
||||
user_id = await _make_funded_user(session_factory, 8, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(WithdrawalError, match="own deposit address"):
|
||||
await request_withdrawal(session, client, user, user.address, 1_000_000_000)
|
||||
|
||||
assert not client.broadcasted
|
||||
async with session_factory() as session:
|
||||
assert (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().spent_txid is None
|
||||
|
||||
|
||||
async def test_failed_broadcast_publishes_an_sse_update(session_factory): # B-49
|
||||
"""The released UTXOs are spendable again and the balance changed back, so the
|
||||
rollback must nudge the dashboard to refetch instead of leaving it stale until
|
||||
its next poll."""
|
||||
user_id = await _make_funded_user(session_factory, 10, 3_000_000_000)
|
||||
|
||||
class RejectingClient:
|
||||
async def broadcast(self, raw_tx_hex: str) -> str:
|
||||
raise RuntimeError("min relay fee not met")
|
||||
|
||||
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(WithdrawalError, match="refused"):
|
||||
await request_withdrawal(session, RejectingClient(), user, derive_user_address(98), 1_000_000_000)
|
||||
|
||||
assert not queue.empty()
|
||||
finally:
|
||||
broadcaster.unsubscribe(queue)
|
||||
|
||||
|
||||
async def test_failed_broadcast_marks_the_withdrawal_failed_and_frees_the_coins(session_factory):
|
||||
"""B-07/B-08: the Withdrawal row is kept (unlike a bet) so the user can see the
|
||||
instruction didn't go through, but the coins must come back."""
|
||||
user_id = await _make_funded_user(session_factory, 9, 3_000_000_000)
|
||||
|
||||
class RejectingClient:
|
||||
async def broadcast(self, raw_tx_hex: str) -> str:
|
||||
raise RuntimeError("min relay fee not met")
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
external = derive_user_address(99)
|
||||
with pytest.raises(WithdrawalError, match="refused"):
|
||||
await request_withdrawal(session, RejectingClient(), user, external, 1_000_000_000)
|
||||
|
||||
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).where(UtxoEvent.user_id == user_id))).one().spent_txid is None
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 3_000_000_000
|
||||
|
||||
Reference in New Issue
Block a user