Compare commits
14
Commits
daa85cf4f0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
575f1a5354 | ||
|
|
9520c80dcf | ||
|
|
24e5fc3d8b | ||
|
|
c1b192f6d1 | ||
|
|
40ac490826 | ||
|
|
55d852fe08 | ||
|
|
32df50cb75 | ||
|
|
264441f5d6 | ||
|
|
cef05943f9 | ||
|
|
2c0e71a74c | ||
|
|
1433813ec7 | ||
|
|
8b182ac8b6 | ||
|
|
ccd15807ed | ||
|
|
54dbffb552 |
No files matched your search
@@ -11,3 +11,4 @@ data/
|
||||
*.egg-info/
|
||||
tests/
|
||||
.claude/
|
||||
secrets/
|
||||
+13
-3
@@ -30,9 +30,14 @@ ELECTRUM_CERT_PIN=
|
||||
# NEVER true in production.
|
||||
ALLOW_NO_QUORUM=false
|
||||
|
||||
# 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=
|
||||
# File holding the Fernet key that protects the master xprv at rest (B-91): in a file, not in
|
||||
# an environment variable, so `docker inspect` cannot show it and a backup of ./data does not
|
||||
# carry both. Inside the container docker-compose.yml mounts it as a secret and overrides
|
||||
# this path. Create it once:
|
||||
# mkdir -p secrets && chmod 700 secrets
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" > secrets/xprv_encryption.key
|
||||
# chmod 600 secrets/xprv_encryption.key
|
||||
XPRV_ENCRYPTION_KEY_FILE=./secrets/xprv_encryption.key
|
||||
|
||||
# Random secret for JWT session signing. Generate with:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
@@ -50,3 +55,8 @@ ADMIN_TOKEN=
|
||||
# endpoints included — to anyone who requests them. Off by default; set to
|
||||
# true only for local development, never in production.
|
||||
ENABLE_API_DOCS=false
|
||||
|
||||
# Start with the lottery paused (as the operator's pause, audit-logged), e.g. after
|
||||
# restoring a backup: nothing opens a round until you resume from /admin. See
|
||||
# docs/operativita.md (OPS-27).
|
||||
START_PAUSED=false
|
||||
@@ -8,3 +8,4 @@ master.xprv.enc
|
||||
*.egg-info/
|
||||
logs/
|
||||
data/
|
||||
secrets/
|
||||
@@ -1,77 +1,10 @@
|
||||
# BUGS.md — open findings
|
||||
|
||||
Two audits currently have open entries: 2026-08-03 (B-71, B-72, below) and the
|
||||
2026-09-28 specification review (B-73 … B-98, at the end of the file).
|
||||
|
||||
## Audit of 2026-08-03
|
||||
|
||||
Third full-codebase audit, opened after the 2026-07-26 (B-01 … B-24) and
|
||||
2026-07-27 (B-25 … B-49) lists were emptied. Numbering continues from the last
|
||||
fixed finding, B-51.
|
||||
|
||||
The list opened at B-52 … B-72 and holds only what is still **open**: a finding is
|
||||
removed from this file once it is fixed, and is not listed here afterwards. Per
|
||||
CLAUDE.md's convention each entry gets its own commit with its own regression test,
|
||||
and the `B-nn` marker goes in a comment next to the fix, so
|
||||
`git log --all --grep 'B-nn'` is the record of how any closed finding was closed.
|
||||
|
||||
State of the tree at audit time: 264 unit tests, all passing; `tests/integration/`
|
||||
still empty; withdrawal and the RBF bump path still never live-broadcast.
|
||||
|
||||
Verified as *not* broken while looking for these: i18n key parity (146 identical
|
||||
keys across all 7 languages), HTML escaping of every user-controlled value in
|
||||
`admin.js`, the Alembic chain (linear, single head, matching `models.py`),
|
||||
strictly-integer satoshi arithmetic everywhere, and `.gitignore` coverage of
|
||||
secrets/DB/logs (nothing sensitive is tracked in git).
|
||||
|
||||
Severity is about consequence, not likelihood:
|
||||
**critical** = money stuck or lost, or the lottery stops;
|
||||
**high** = a security control that does not hold;
|
||||
**medium** = wrong behaviour with a bounded blast radius;
|
||||
**low** = drift between documentation and code.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### (not new) `drawing` does not resume after a restart
|
||||
|
||||
Already tracked as an accepted gap in CLAUDE.md's "Known gaps", not re-numbered
|
||||
here. Worth restating in context: with `restart: unless-stopped` on the app
|
||||
container, this is the one state that gets stuck with money in play, and it
|
||||
remains the last prerequisite for running unattended.
|
||||
|
||||
---
|
||||
|
||||
## Low — documentation and consistency drift
|
||||
|
||||
### B-71 — `.env` points `MASTER_KEY_PATH` at a second copy of the master key
|
||||
|
||||
CLAUDE.md's deployment section prescribes pointing `MASTER_KEY_PATH` at the
|
||||
host-side `./data/keys/master.xprv.enc` so the venv scripts and the container read
|
||||
one file. `.env` instead sets `./master.xprv.enc`, and both files now exist in the
|
||||
working tree (both gitignored). They were verified during this audit to decrypt to
|
||||
the *same* xprv, so nothing has diverged yet — but `scripts/decrypt_master_key.py`
|
||||
reads a different file from the one the container uses, and a future
|
||||
`generate_master_key.py --overwrite` would split them silently, with an ops
|
||||
recovery path that then reports the wrong key.
|
||||
|
||||
The same duplication exists for the database (`./plm_lottery.db` next to
|
||||
`data/db/plm_lottery.db`), which is less dangerous but equally confusing.
|
||||
|
||||
Fix: set `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, delete the stray
|
||||
root copy once confirmed redundant, and state the same for `DATABASE_URL`.
|
||||
|
||||
### B-72 — `docs/setup.md` still frames setup as "locally or via Docker"
|
||||
|
||||
`docs/setup.md:1-10` lists Python as a prerequisite "for the local/venv workflow"
|
||||
and describes the master-key step as local *or* Docker, while B-44 made Docker the
|
||||
only supported way to run the server (`docs/running-the-server.md` and README were
|
||||
updated, this file was not). The venv genuinely is needed for tests, migrations and
|
||||
the key scripts — the wording just needs to say that instead of implying a second
|
||||
way to run the server.
|
||||
|
||||
---
|
||||
Only the 2026-09-28 specification review (B-73 … B-100) still has open entries
|
||||
(B-93). A finding is removed from this file once it is fixed, and
|
||||
`git log --all --grep 'B-nn'` is the record of how it was closed. Per CLAUDE.md's
|
||||
convention each entry gets its own commit with its own regression test, and the `B-nn`
|
||||
marker goes in a comment next to the fix.
|
||||
|
||||
# Audit of 2026-09-28 — specification review (B-73 … B-98)
|
||||
|
||||
@@ -100,78 +33,8 @@ decisions, see `docs/limiti-noti.md`):
|
||||
- **Contain reorgs instead of pretending they don't happen:** detect them, pause
|
||||
on deep ones, and pay out only what is verifiably on chain.
|
||||
|
||||
## Critical
|
||||
|
||||
### B-82 — A deposit reorged out after a bet leaves the round unpayable
|
||||
|
||||
Deposits are credited at 1 confirmation. A user can bet, then (if they lost)
|
||||
double-spend the deposit the bet spent with a private chain: the bet disappears but
|
||||
its participant stays `confirmed` and counted, and the payout fails "insufficient
|
||||
pool UTXOs" forever with every other player's money stuck. Immature coinbase outputs
|
||||
are also credited at 1 confirmation (maturity is 120).
|
||||
|
||||
Fix: deposits credited at `deposit_confirmations` (round_config, default 6),
|
||||
coinbase outputs at 120. Before building a payout the round address's UTXOs are
|
||||
verified with the quorum; participants whose bet outpoint is gone are marked
|
||||
`reorged_out` and the 70/30 split is computed on what is actually there. If the
|
||||
winner's own bet is gone the payout is held (`payout_blocked`) and the lottery pauses.
|
||||
|
||||
## High
|
||||
|
||||
### B-87 — The RBF bump can leave a dust change output
|
||||
|
||||
`bump_fee` only requires change > fee delta. A 294-sat change (full-balance
|
||||
withdrawal) becomes ~153 sat after the minimum bump and every replacement is refused
|
||||
by relay, every 30s.
|
||||
|
||||
Fix: when a bump would leave change below `DUST_LIMIT_SATS`, the change output is
|
||||
dropped and its value goes to the fee (the replacement is then final); `RbfError`
|
||||
and every refused bump write a `rbf_bump_failed` audit row.
|
||||
|
||||
### B-89 — Key problems surface only at first use
|
||||
|
||||
Startup checks only that the key file exists and `XPRV_ENCRYPTION_KEY` is non-empty;
|
||||
a wrong key or a different xprv is found at the first registration or payout, and a
|
||||
different xprv silently moves the pool address. The scripts accept any xprv and write
|
||||
files `0644`.
|
||||
|
||||
Fix: at startup decrypt the key and check the pool reserve address and a sample of
|
||||
stored user addresses; refuse to serve on mismatch. The scripts refuse an xprv whose
|
||||
derived addresses differ from the DB's and write `0600`.
|
||||
|
||||
### B-90 — Restoring a backup is unsafe
|
||||
|
||||
A restored DB reopens rounds that were already drawn and paid, and reassigns the
|
||||
derivation indexes of users registered after the backup to new users — who then
|
||||
receive the old users' funds.
|
||||
|
||||
Fix: `START_PAUSED=true` starts with the lottery paused; at startup the next 20
|
||||
indexes past the highest assigned are checked for on-chain history and, if any has
|
||||
some, registration is refused until the operator reconciles (`restore_gap_detected`);
|
||||
per-round addresses (B-80) and B-82's pre-payout check make a replayed round
|
||||
unpayable instead of payable twice.
|
||||
|
||||
### B-91 — Host and supply-chain hardening
|
||||
|
||||
The container runs as root, `.env` and key files are world-readable, dependencies are
|
||||
unpinned `>=` and base images float, secrets sit in env vars visible to
|
||||
`docker inspect`.
|
||||
|
||||
Fix: non-root `USER`, `0600`/`0700` permissions, a hash-pinned lock file,
|
||||
digest-pinned base images, the Fernet key mounted as a file rather than an env var.
|
||||
|
||||
## Medium
|
||||
|
||||
### B-92 — No health, readiness or real audit browsing
|
||||
|
||||
`/health` always answers ok; nothing exposes Electrum state; the audit endpoint
|
||||
returns only the latest 500 rows unfiltered; registration writes no audit row; the
|
||||
access log records Caddy's IP; app logs go only to the file.
|
||||
|
||||
Fix: `GET /health/ready` (Electrum connected, tip age < 360s, scheduler heartbeat <
|
||||
30s) used as the container healthcheck; Electrum status in `/admin`; audit filters by
|
||||
event type, round, user and time, with pagination; `user_registered` audit row with
|
||||
index and address; uvicorn proxy headers from Caddy only; logs also to stdout.
|
||||
|
||||
### B-93 — Deploys and rollbacks
|
||||
|
||||
@@ -183,52 +46,3 @@ kills after 10s); an older image cannot start on a newer DB; images are untagged
|
||||
Fix: close SSE streams on shutdown, `stop_grace_period: 30s`, await task
|
||||
cancellation; tag images per release; `SITE_ADDRESS` in `.env` with `${SITE_ADDRESS:?}`;
|
||||
documented pre-deploy backup and downgrade procedure.
|
||||
|
||||
### B-94 — Account and endpoint abuse limits
|
||||
|
||||
`change-password` has no rate limit, no audit and no maximum length; login fields
|
||||
have no length bounds; `POST /bug-reports` is anonymous and unlimited;
|
||||
`GET /admin/users` has no pagination; admin, SSE and framework errors are bare
|
||||
strings; static JS/CSS have no `Cache-Control`.
|
||||
|
||||
Fix: throttle and audit (`password_changed`) the password change, cap every
|
||||
credential field at 256, rate-limit bug reports per IP, paginate the user list,
|
||||
structured errors everywhere, `Cache-Control: no-cache` on static files.
|
||||
|
||||
### B-95 — Round parameters read live instead of snapshotted
|
||||
|
||||
Only duration and cooldown are snapshotted at open; bet amount, `fee_address` and the
|
||||
new `draw_confirmations` are read live, so an edit mid-round changes the round in
|
||||
progress (e.g. redirects its commission).
|
||||
|
||||
Fix: snapshot `bet_amount_sats`, `fee_address` and `draw_confirmations` on the round
|
||||
at open. Optional per-round cap `max_round_pool_sats` (round_config, 0 = disabled):
|
||||
once reached, further bets get `round_full`.
|
||||
|
||||
### B-96 — Smaller state inconsistencies
|
||||
|
||||
A reduced full-balance withdrawal is not re-checked against the minimum; an abandoned
|
||||
payout leaves `winner_amount_sats`/`fee_amount_sats` set; a promoted payout writes no
|
||||
`payout_sent` and a promoted bet no `bet_placed`; the reconciler pings SSE for every
|
||||
row it checks; concurrent refreshes of one user can collide on the unique outpoint
|
||||
and end the Electrum session; `listener.client` is read outside the per-user lock;
|
||||
a bet covered only by pending change gets `insufficient_balance` instead of
|
||||
`balance_pending_confirmation`. A round waiting in `closing` on a bet that never
|
||||
leaves the mempool gives no signal at all.
|
||||
|
||||
Fix: each as described, one regression test per item; for the `closing` wait, a
|
||||
`closing_stalled` audit row every 1800s past the deadline and `closing_waiting_since`
|
||||
on `GET /rounds/current` (spec: TX-29).
|
||||
|
||||
### B-98 — Coin selection stops at the first covering UTXO, refusing a payable bet
|
||||
|
||||
Found while writing the spec on 2026-09-28 (medium). `select_utxos` returns as soon
|
||||
as the selected total covers the target. When that happens exactly (a 10 PLM coin
|
||||
for a 10 PLM bet), the change is 0: the bet is refused with
|
||||
`balance_leaves_no_change` and a withdrawal is reduced by `DUST_LIMIT_SATS`, even
|
||||
though a second coin would have left a proper change output.
|
||||
|
||||
Fix: select until the total covers `target + DUST_LIMIT_SATS` (still largest-first,
|
||||
still capped at `max_inputs`); only when every allowed coin together cannot reach it
|
||||
fall back to today's behaviour (refuse the bet, reduce the withdrawal). Spec:
|
||||
`docs/scommesse-e-prelievi.md` BET-24.
|
||||
@@ -8,9 +8,9 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
||||
|
||||
## Project status
|
||||
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 637 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 758 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
|
||||
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
|
||||
Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), 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.
|
||||
|
||||
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. A third audit (2026-08-03, B-52 … B-72) and a fourth, the 2026-09-28 specification review (B-73 … B-98: draw manipulation, ambiguous broadcasts, single-txid tracking, single-server abandonment, the 64 KiB Electrum reply limit, unbumpable payouts, 1-conf deposit reorgs, ops hazards), live in the tracked [BUGS.md](BUGS.md), which lists only what is still **open** (B-71 … B-98 at the time of writing). The spec in `docs/` already describes the *target* behaviour for all of them — every affected requirement is marked `*Stato:* da implementare (B-nn)` with an `*Oggi:*` line saying what the code does now, so read both before assuming a requirement is implemented; the design decisions and the implementation order are in [docs/piano-correzioni.md](docs/piano-correzioni.md); the full history of every B-nn, open and closed, is in [docs/storico-bug.md](docs/storico-bug.md). Closed findings leave the file, 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.
|
||||
|
||||
@@ -44,23 +44,23 @@ pip install -e ".[dev]"
|
||||
|
||||
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 master xprv (needs XPRV_ENCRYPTION_KEY in .env)
|
||||
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the master xprv (needs the key file `XPRV_ENCRYPTION_KEY_FILE` points at)
|
||||
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 # all 637 tests
|
||||
python -m pytest # all 758 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
|
||||
```
|
||||
|
||||
`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.
|
||||
`.env` (gitignored) holds the real secrets (except the Fernet key, which is `secrets/xprv_encryption.key`); `.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)
|
||||
|
||||
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.
|
||||
Same `docker-compose.yml` for dev and prod — only `SITE_ADDRESS` differs. Three services: a one-shot `data-permissions` (root, chowns `./data` to uid 1000 and closes it to others, B-91), `app` (non-root uid 1000; 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
|
||||
@@ -78,7 +78,7 @@ The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Option
|
||||
- Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite, `embit` for keys/PSBT/tx parsing.
|
||||
- **PLM access via the Electrum protocol only** (no full node/P2P). Dev bootstrap server: `santantonio.sytes.net:50002` (SSL).
|
||||
- Auth: Argon2 hashing + JWT (HS256, 24h). Tokens **are** revocable (B-34): the token carries a `tv` claim, `User.token_version` is bumped by a self-service password change and by an admin reset, and `get_current_user`/`get_optional_user` reject any token whose `tv` no longer matches — so changing the password invalidates every session issued before it, instead of leaving them valid for up to `jwt_expire_minutes`. A token predating the claim decodes as `tv = 0`, which is what a migrated user starts at, so the deploy didn't log everyone out. Argon2 costs tens of ms of CPU per call by design, so every async caller goes through `hash_password_async`/`verify_password_async` (`run_in_threadpool`, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts.
|
||||
- Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or `XPRV_ENCRYPTION_KEY` is empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
|
||||
- Secrets: master xprv Fernet-encrypted at rest, encryption key in its own file, `./secrets/xprv_encryption.key` (`XPRV_ENCRYPTION_KEY_FILE`; a compose secret in the container, never in the DB, git or an environment variable — B-91). `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 the encryption key file is missing or empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
|
||||
- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway.
|
||||
|
||||
## PLM network parameters (mainnet)
|
||||
@@ -98,16 +98,18 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
|
||||
|
||||
| Parameter | Value | Where |
|
||||
|---|---|---|
|
||||
| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable |
|
||||
| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable, **snapshotted onto `Round.bet_amount_sats` when a round opens** (B-95) — an edit applies from the next round |
|
||||
| Prize split | **70% winner / 30% platform**, rounding remainder to the platform; the platform's share pays the **bump reserve** (`max(294, vsize(inputs, 3 outputs) × 10)` sat, B-79) to the reserve address `1/0` and the rest goes to `fee_address` | **hardcoded** in `rounds/service.py:winner_share` / `wallet/psbt_builder.py:payout_bump_reserve` — a code change, not an admin edit |
|
||||
| Round duration / cooldown | 600s / 30s | `RoundConfig`, but **snapshotted onto `Round.duration_seconds`/`cooldown_seconds` when a round opens** (B-61) — an edit applies from the next round, never to the one in progress |
|
||||
| Draw animation | 20s (cosmetic frontend minimum only) | `RoundConfig` |
|
||||
| Draw confirmations | 3 blocks on the draw block (`draw_confirmations`, 1–20) | `RoundConfig`, admin-editable, **snapshotted onto `Round.draw_confirmations` at open** (B-73) |
|
||||
| Commission address | `fee_address` | `RoundConfig`, admin-editable, **snapshotted onto `Round.fee_address` at open** (B-95): the payout pays the round's own, never the live one |
|
||||
| Pool cap | `max_round_pool_sats`, 0 = none (otherwise ≥ the bet cost, ≤ 4·10^15) | `RoundConfig`, **snapshotted onto `Round.max_round_pool_sats` at open** (B-95); `place_bet` refuses with `round_full` (params `max_round_pool_sats`) once the sum of the round's participant rows has *reached* it, in-flight bets included — the pool can end up above the cap by at most one bet |
|
||||
| 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` |
|
||||
| Confirmations | **1** for every platform-built tx (confirmation poller) and for the outputs it creates (bet/withdrawal change, payouts); **`deposit_confirmations` (6)** for a deposit, **120** for a coinbase output (B-82) | tx confirmation hardcoded in `tx/confirmation.py`; deposit threshold in `round_config` (`/admin`), coinbase maturity hardcoded in `deposits/service.py` |
|
||||
| Max inputs per *user* tx (bet, withdrawal) | 50 (`MAX_TX_INPUTS`, B-48) — over it the build fails with `too_many_inputs`, it never spends more | hardcoded in `wallet/psbt_builder.py` |
|
||||
| Max inputs per *payout* | 500 (`MAX_PAYOUT_TX_INPUTS`, B-52) — the pool holds one UTXO per bet, so reusing the user cap made any round past ~50 players unpayable | hardcoded in `wallet/psbt_builder.py` |
|
||||
| Max participants per round | 400 (`MAX_PARTICIPANTS_PER_ROUND`, B-52) — the 401st bet is refused with `round_full` *before* any money moves, so "a round can always be paid out" is an invariant rather than something discovered at payout time | hardcoded in `wallet/psbt_builder.py`, enforced in `bets/service.py` |
|
||||
@@ -116,9 +118,9 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
|
||||
|
||||
**Round cooldown** (`round_cooldown_seconds`, not in the original design): 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, with `paused_reason` (B-78, DAT-69): `operator`, or an automatic reason the system wrote itself (`resurrected_tx` so far, `rounds/pause.py`) — an operator pause never overwrites an automatic one, and resume clears whichever it is.
|
||||
**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, with `paused_reason` (B-78, DAT-69): `operator`, or an automatic reason the system wrote itself (`resurrected_tx`, `reorg`, `payout_blocked`, `rounds/pause.py`) — an operator pause never overwrites an automatic one, and resume clears whichever it is.
|
||||
|
||||
**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time. Since B-79 (RND-40) that includes the amounts: `payout_config_problem(bet_amount_sats, fee_rate_sat_vb)` checks a round of 1 and of `MAX_PARTICIPANTS_PER_ROUND` participants against the winner's network fee, the bump reserve and dust outputs, and `PUT /admin/config` refuses a bet amount or fee rate that fails it with `422 config_not_payable` (the payout reads `fee_rate_sat_vb` live, so raising it mid-round is exactly what could otherwise strand the round in progress).
|
||||
**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time. Since B-79 (RND-40) that includes the amounts: `payout_config_problem(bet_amount_sats, fee_rate_sat_vb)` checks a round of 1 and of `MAX_PARTICIPANTS_PER_ROUND` participants against the winner's network fee, the bump reserve and dust outputs, and `PUT /admin/config` refuses a bet amount or fee rate that fails it with `422 config_not_payable` (judged on the resulting values and, for a fee-rate change, on the in-progress round's own snapshotted cost as well; B-95 adds `422 max_round_pool_below_bet_amount` for a cap below the cost of one bet) (the payout reads `fee_rate_sat_vb` live, so raising it mid-round is exactly what could otherwise strand the round in progress).
|
||||
|
||||
## Code map
|
||||
|
||||
@@ -158,8 +160,9 @@ One connection serves everything — deposit credits, broadcasts, confirmations,
|
||||
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls][:pin=<sha256>]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed; `pin=` fixes the server's DER certificate hash, `ELECTRUM_CERT_PIN` does the same for the primary, B-84). The server **refuses to start with fewer than 2 fallbacks** (`config.validate_quorum_servers`, B-84; `ALLOW_NO_QUORUM=true` overrides it, development only). 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`. A server that keeps answering pings but stopped following the chain is caught too (B-86): at every ping, if no header has been *accepted* for 360s (`_STALL_SECONDS`), `_check_not_stalled` asks the other servers for their tip and ends the session with `ServerStalledError` if any is higher, so `run()` rotates; none higher means a quiet chain and the session goes on. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
|
||||
- **The chain is checked, not just the headers** (B-99, ELC-30): `ElectrumClient.connect` follows `server.version` with `server.features` and refuses a server whose `genesis_hash` is not PLM's. PLM shares Bitcoin's genesis *and* powLimit, so a Bitcoin Electrum server would pass every header check below; this is what tells them apart, for the quorum's connections too.
|
||||
- **Headers are validated, not trusted** (`_apply_header`, async since B-74): the tip never regresses, a header must meet the difficulty target it claims *and* that target must be no easier than `draw.POW_LIMIT` (PLM's powLimit; `tests/unit/conftest.py` swaps in a regtest limit so tests can mine headers, and the tests of the limit itself put `MAINNET_POW_LIMIT` back), and every advance must be linked to the tip: a single block by its `prev_hash`, a jump by downloading the headers in between (`blockchain.block.headers`, ≤2016 per request) from the active server and checking each. 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. The listener keeps the last 10 headers (`_window`, built from the first header plus its 9 predecessors); a header that does not join the tip is a reorg, walked back to its fork point inside the window and followed (`_follow_reorg`), a fork older than the window ends the session. A followed reorg writes `chain_reorg_detected` *before* the window moves (`_record_reorg`, B-85), and a depth of 2 or more pauses the lottery with `paused_reason = 'reorg'` in the same transaction; a header at the tip's own height with a different hash is a reorg too, not ignored. One more header is refused *without* ending the session (B-64): one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63.
|
||||
- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — and since B-83 that includes `corroborate_utxo_reinstatement` before a UTXO written off as externally spent is put back (same outpoint, amount, confirmed; `deposits/service.py:find_reinstate_candidates` only reads, `reinstate_reappeared_utxos` writes what the quorum agreed on), and `confirmation_proven` before a tx is marked confirmed: `tx/confirmation.py:poll_once` takes the height the active server reports only as a *claim*, and the row confirms when a strict majority of at least 2 of the *other* servers return a `get_merkle` proof that recomputes to the merkle root of the block's header (`electrum/merkle.py`; the header is the tip we hold, or for an older block the one the quorum agrees on and that meets its own target). With no `network` nothing confirms. The one decision still on the active server's word is the pool-coin listing for a payout (B-82). It needs **at least 2 answers** and a strict majority of those that answered (B-84): fewer → returns **False**, since one voice decides nothing independently and an unreachable network proves nothing. Only `ALLOW_NO_QUORUM=true` keeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep.
|
||||
- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — and since B-83 that includes `corroborate_utxo_reinstatement` before a UTXO written off as externally spent is put back (same outpoint, amount, confirmed; `deposits/service.py:find_reinstate_candidates` only reads, `reinstate_reappeared_utxos` writes what the quorum agreed on), and `confirmation_proven` before a tx is marked confirmed: `tx/confirmation.py:poll_once` takes the height the active server reports only as a *claim*, and the row confirms when a strict majority of at least 2 of the *other* servers return a `get_merkle` proof that recomputes to the merkle root of the block's header (`electrum/merkle.py`; the header is the tip we hold, or for an older block the one the quorum agrees on and that meets its own target). With no `network` nothing confirms. Since B-82 the pool-coin check before a payout is on the quorum too (`verify_pool_outpoints`, below). A quorum needs **at least 2 answers** and a strict majority of those that answered (B-84): fewer → returns **False**, since one voice decides nothing independently and an unreachable network proves nothing. Only `ALLOW_NO_QUORUM=true` keeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep.
|
||||
|
||||
On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep.
|
||||
|
||||
@@ -169,7 +172,7 @@ Diagrams: [come-funziona.md](docs/come-funziona.md) (the 5 phases), [ciclo-di-vi
|
||||
|
||||
**REG** — on signup the server derives a P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from the encrypted master xprv. Permanent, and doubles as deposit address, winnings address and withdrawal change address.
|
||||
|
||||
**DEP** — the listener subscribes to the user's scripthash; balance is credited after **1 confirmation** and only once the other servers corroborate the outpoint and its amount (B-59), with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
|
||||
**DEP** — the listener subscribes to the user's scripthash; balance is credited once the output reaches its threshold (B-82, DEP-03: **1** if a transaction of the platform created it — any txid of a `pending_transactions` row —, **120** for a coinbase, recognised from the raw tx that must hash to the txid, `ElectrumListener.is_coinbase`, and `round_config.deposit_confirmations`, default 6, for everything else) **and** only once the other servers corroborate the outpoint, its amount and a height that gives it that many confirmations on our tip (B-59, ELC-17). The deposit is the one input a user can double-spend, hence the 6. Outputs short of their threshold are *pending deposits* (`ElectrumListener._pending_deposits`, memory only): shown in `pending_balance_sats` (amber), never spendable, and since no scripthash notification announces a maturing deposit each new block refreshes only the users that have one (`_refresh_waiting_deposits`). A deposit that vanishes later takes the spent-outside path; there is no other rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
|
||||
|
||||
**PLAY** — fixed cost, **at most one active bet per user**, and **at most `MAX_PARTICIPANTS_PER_ROUND` (400) players per round** — past that the bet is refused with `round_full` and the player waits for the next round (B-52: the payout must spend one pool UTXO per bet, so a round is only ever allowed to grow to what a single payout tx can drain). PSBT user-address → pool-address, always with a **change output back to the same user address** of at least `DUST_LIMIT_SATS` — a user's balance must never exactly equal the bet, and since B-62 that's enforced (`balance_leaves_no_change`) rather than assumed. Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
|
||||
|
||||
@@ -177,7 +180,7 @@ Diagrams: [come-funziona.md](docs/come-funziona.md) (the 5 phases), [ciclo-di-vi
|
||||
- *Bet cutoff is the round's own deadline* (`round_deadline` = `opened_at + Round.duration_seconds`, the value snapshotted at open time — B-61), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a **compare-and-set on the round row** (`UPDATE rounds ... WHERE status = 'open'`, B-53): the scheduler flips `open` → `closing` in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only `confirmed` participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is `_close_and_draw` re-counting in-flight bets in the same session it snapshots the participants from.
|
||||
- *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
|
||||
- *Algorithm* (deliberately simple, meant to be replaced; **commit-reveal since B-73**, spec RND-14 … RND-20, RND-34 … RND-37): at open the round gets a 32-byte `server_seed` — stored only **Fernet-encrypted** with the master-xprv key (`server_seed_enc`; the plaintext column `server_seed` stays NULL until the reveal) — and publishes `server_seed_hash = sha256(seed)`. Entering `drawing` fixes, in one transaction with the status change, the confirmed participants **ordered by bet txid ascending** (public data, no ties — never `broadcast_at`), each one's `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n` lines; `bet_vout` is the output paying the round's pool address), and `draw_height = tip_at_close + 1`, and writes `draw_committed`. The draw waits until the chain has `draw_confirmations` blocks from `draw_height` (snapshotted at open from `round_config`, default 3), then asks **every** server, the active one included, for the header *at that exact height* (`ElectrumListener.agreed_block_hash`: valid work, at least 2 other answers, strict majority of all answers) — no agreement means the same height is asked again, never another block, so whoever mines or relays blocks cannot pick the one that seeds the draw (`draw_header_corroboration_failed`, once per distinct hash the active server reported). `seed = sha256(server_seed ‖ block_hash ‖ participants_hash)` as a big-endian integer, `index = seed mod n`, winner = participant with that `draw_position`; equal probability for everyone, regardless of amount. The reveal (`server_seed` column + `winner_drawn` payload) happens in the same transaction that stores the winner (or when a round closes with no participants), and `GET /rounds/{id}/proof` (public) returns everything needed to recompute it. A tip of 0 at closing means *unknown*: the round stays `closing` (`draw_baseline_tip_unknown`, once per round, B-63) because `draw_height` must come from a height we actually knew. `draw_stalled` fires when the chain has not advanced for 360s while waiting for confirmations, or the quorum keeps failing to agree. `_complete_draw` reads everything it needs from the database (`_close_and_draw` enters `drawing` and then calls it, and since B-81 `_tick` calls it again for any round found in `drawing` — after an exception or a restart — so it is idempotent: a round no longer in `drawing`, or already holding a winner, is left alone); a round opened before B-73 (no `server_seed_enc`) is given a secret as it enters `drawing`.
|
||||
- *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/<round_id>`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **network fee comes out of the winner's 70%**; the platform's 30% pays the **bump reserve** (B-79, RND-38: always a third output, to the reserve address `1/0`, recorded in `rounds.bump_reserve_sats`) and the remainder goes to `fee_address`. The reserve is what makes the payout bumpable — it normally spends exactly the round's bet outputs and so has no change — and `bump_fee` shrinks it (`_find_reducible_output`: the reserve output for a payout, the user's change for a bet/withdrawal), never the winner's or the fee address's output. Same timeout → RBF → rebroadcast pattern.
|
||||
- *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/<round_id>`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **network fee comes out of the winner's 70%**; the platform's 30% pays the **bump reserve** (B-79, RND-38: always a third output, to the reserve address `1/0`, recorded in `rounds.bump_reserve_sats`) and the remainder goes to `fee_address`. The reserve is what makes the payout bumpable — it normally spends exactly the round's bet outputs and so has no change — and `bump_fee` shrinks it (`_find_reducible_output`: the reserve output for a payout, the user's change for a bet/withdrawal), never the winner's or the fee address's output. Same timeout → RBF → rebroadcast pattern. **Verified before it is built** (B-82, RND-22, RND-39, `_trigger_payout` → `_verify_round_pool` → `ElectrumListener.verify_pool_outpoints`): a round with its own pool address spends exactly its confirmed participants' bet outputs (`bet_txid:bet_vout`), never whatever else sits at the address, and first asks the *other* servers whether each is still there — a strict majority of at least 2 answers; a mempool-only coin or a tie concludes nothing (`payout_failed`, `pool verification unavailable`, retried). A participant whose bet is gone becomes `reorged_out`, `participant_reorged_out` is audit-logged, `pool_amount_sats` is rewritten (only ever downward) in the same transaction and the 70/30 split is computed on what is left. If the **winner's own** bet is gone nothing is built: `payout_blocked_at` is set, `payout_blocked` is logged and the lottery is paused with `paused_reason = 'payout_blocked'`; the winner's row stays `confirmed` so that after `POST /admin/resume` (which clears the mark) the verification really runs again. A round opened before B-80 (pool = the shared reserve address) keeps the old active-server `listunspent` choice.
|
||||
- *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`.
|
||||
|
||||
@@ -251,19 +254,19 @@ Explicit design choices, not derivable from any single file — respect them:
|
||||
|
||||
- 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.
|
||||
- **1 confirmation** for every transaction the platform builds (bets, withdrawals, payouts, and the outputs they create). Deposits (`deposit_confirmations`, 6) and coinbase outputs (120) are the explicit exceptions of B-82 — don't add other thresholds without an explicit decision.
|
||||
- The draw algorithm is a **replaceable component**, not the final design — don't architect around its current form.
|
||||
- `GET /admin/users/{id}/privkey` exporting a raw WIF is **intentional**, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes `admin_privkey_accessed` — don't remove that logging.
|
||||
- **Usernames are case-insensitive** (B-57): one namespace, enforced by a unique index on `lower(username)` (`app/db/models.py`) and matched with `func.lower(...)` on both register and login. The name is still *stored* as typed — that's what `/admin` and the audit log show. The migration refuses to run if two existing accounts differ only by case, rather than guessing which one to rename: both may hold funds.
|
||||
- Argon2 hashing means **no password recovery, only reset**: `POST /admin/users/{id}/reset-password` sets a new random password, returns it once for the operator to relay, and logs `admin_password_reset`. No self-service reset exists (no email is ever collected); a logged-in user can only *change* their password by supplying the current one.
|
||||
- RBF bumps are paid by whoever's change the tx pays back to — the user for bets/withdrawals, the pool for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only the sender's own change shrinks (`bump_fee`).
|
||||
- RBF bumps are paid by whoever's output the tx reserves for it — the user's change for bets/withdrawals, the bump reserve (paid to the reserve address) for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only that one output shrinks, or is dropped into the fee when it would fall under dust (`bump_fee`, B-87).
|
||||
|
||||
## Known gaps / TODO
|
||||
|
||||
Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here.
|
||||
|
||||
- **The `drawing` wait is unbounded by design** (the draw's entropy genuinely depends on a future block). It resumes on its own after an exception or a restart (B-81), and is observable rather than 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).
|
||||
- **RBF handles one shape only**: a single reducible output — the sender's change for a bet/withdrawal, the payout's bump reserve — big enough to absorb the increase. No extra-input fallback, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why `build_signed_transaction` now guarantees a change output of at least `DUST_LIMIT_SATS` instead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused with `balance_leaves_no_change`. What's left is a bump whose *delta* exceeds an otherwise-fine change output, which still raises `RbfError`; that tx is eventually abandoned and its UTXOs released.
|
||||
- **RBF handles one shape only**: a single reducible output — the sender's change for a bet/withdrawal, the payout's bump reserve (B-79). No extra-input fallback and no CPFP, and none would help the case that used to hurt (a transaction that already spends everything the sender has) — which is why `build_signed_transaction` guarantees a change output of at least `DUST_LIMIT_SATS` (B-62) and every payout carries the reserve. Since B-87 a bump that would leave that output under dust drops it and gives its whole value to the fee (the replacement is then the last possible one); a bump with no output, or one whose output cannot even pay BIP125's minimum increase, raises `RbfError`. Every such failure — and `max_fee_rate` and a replacement the network refuses — is written by `RbfBumper._record_failure` as one `rbf_bump_failed` audit row per (current txid, reason). What stays accepted: a transaction that has exhausted its change or reserve and still does not confirm waits, visibly, and is never abandoned while a server knows it.
|
||||
- **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network).
|
||||
- **No user-facing history of rounds or transactions.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above) and `GET /bug-reports/mine` one more (the reporter's own reports). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
|
||||
- **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
|
||||
|
||||
+13
-3
@@ -1,15 +1,25 @@
|
||||
FROM python:3.12-slim
|
||||
# B-91 (SEC-51, SEC-52): base image pinned by digest, dependencies installed from a
|
||||
# hash-checked lock file, process running as a non-root user.
|
||||
# To update: `docker buildx imagetools inspect python:3.12-slim` for the digest, and
|
||||
# `pip-compile --generate-hashes --strip-extras -o requirements.lock pyproject.toml` for the lock.
|
||||
FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.lock ./
|
||||
RUN pip install --no-cache-dir --require-hashes --no-deps -r requirements.lock
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY app ./app
|
||||
COPY migrations ./migrations
|
||||
COPY alembic.ini ./
|
||||
COPY scripts ./scripts
|
||||
|
||||
RUN pip install --no-cache-dir .
|
||||
RUN pip install --no-cache-dir --no-deps . \
|
||||
&& useradd --uid 1000 --no-create-home --shell /usr/sbin/nologin app
|
||||
USER 1000:1000
|
||||
|
||||
EXPOSE 8123
|
||||
|
||||
CMD ["/bin/sh", "-c", "test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123"]
|
||||
# umask 077: the database, its WAL files and the logs are created readable by the app user only.
|
||||
CMD ["/bin/sh", "-c", "umask 077; test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123 --proxy-headers --forwarded-allow-ips \"${FORWARDED_ALLOW_IPS:-127.0.0.1}\""]
|
||||
@@ -62,7 +62,7 @@ python -m pytest # all tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
```
|
||||
|
||||
637 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
758 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
|
||||
|
||||
+127
-22
@@ -1,9 +1,10 @@
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import http_error
|
||||
@@ -14,8 +15,8 @@ from app.config import settings
|
||||
from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
|
||||
from app.db.session import get_session
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.pause import AUTOMATIC_REASONS, OPERATOR_REASON
|
||||
from app.rounds.service import payout_config_problem
|
||||
from app.rounds.pause import pause_by_operator
|
||||
from app.rounds.service import get_active_round, payout_config_problem
|
||||
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
|
||||
@@ -28,11 +29,11 @@ async def require_admin(x_admin_token: str = Header(default="")) -> None:
|
||||
# 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")
|
||||
raise http_error(status.HTTP_403_FORBIDDEN, "invalid_admin_token", "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")
|
||||
raise http_error(status.HTTP_403_FORBIDDEN, "invalid_admin_token", "invalid admin token")
|
||||
|
||||
|
||||
# `paused` is deliberately NOT here: it has its own audit-logged endpoints
|
||||
@@ -47,7 +48,9 @@ _CONFIG_FIELDS = (
|
||||
"fee_rate_sat_vb",
|
||||
"rbf_timeout_seconds",
|
||||
"draw_animation_seconds",
|
||||
"deposit_confirmations", # B-82 (DAT-62)
|
||||
"draw_confirmations", # B-73 (DAT-62)
|
||||
"max_round_pool_sats", # B-95 (DAT-62)
|
||||
)
|
||||
|
||||
|
||||
@@ -59,7 +62,9 @@ class RoundConfigResponse(BaseModel):
|
||||
fee_rate_sat_vb: int
|
||||
rbf_timeout_seconds: int
|
||||
draw_animation_seconds: int
|
||||
deposit_confirmations: int # B-82 (DAT-62)
|
||||
draw_confirmations: int # B-73 (DAT-62)
|
||||
max_round_pool_sats: int # B-95 (DAT-62)
|
||||
paused: bool
|
||||
paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused
|
||||
|
||||
@@ -81,7 +86,9 @@ class RoundConfigUpdate(BaseModel):
|
||||
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)
|
||||
deposit_confirmations: int | None = Field(default=None, ge=1, le=100) # B-82 (DAT-62)
|
||||
draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62)
|
||||
max_round_pool_sats: int | None = Field(default=None, ge=0, le=4_000_000_000_000_000) # B-95 (DAT-62)
|
||||
|
||||
@field_validator("fee_address")
|
||||
@classmethod
|
||||
@@ -134,7 +141,16 @@ async def update_config(
|
||||
# refused before anything is written — the payout reads fee_rate_sat_vb at build time, so
|
||||
# a rate raised mid-round could otherwise make the round in progress unpayable.
|
||||
if "bet_amount_sats" in changes or "fee_rate_sat_vb" in changes:
|
||||
problem = payout_config_problem(config.bet_amount_sats, config.fee_rate_sat_vb)
|
||||
amounts = {config.bet_amount_sats}
|
||||
if "fee_rate_sat_vb" in changes:
|
||||
# B-95: the round in progress is paid with the bet amount it opened with, which may
|
||||
# differ from the one configured now — a higher fee rate has to leave *it* payable too.
|
||||
active = await get_active_round(session)
|
||||
if active is not None:
|
||||
amounts.add(active.bet_amount_sats)
|
||||
problem = next(
|
||||
(p for p in (payout_config_problem(a, config.fee_rate_sat_vb) for a in sorted(amounts)) if p), None
|
||||
)
|
||||
if problem is not None:
|
||||
await session.rollback()
|
||||
raise http_error(
|
||||
@@ -145,6 +161,21 @@ async def update_config(
|
||||
reason=problem,
|
||||
)
|
||||
|
||||
# B-95 (DAT-62): a cap below the price of one bet would refuse every bet of every round.
|
||||
if (
|
||||
("max_round_pool_sats" in changes or "bet_amount_sats" in changes)
|
||||
and config.max_round_pool_sats
|
||||
and config.max_round_pool_sats < config.bet_amount_sats
|
||||
):
|
||||
offending = {"max_round_pool_sats": config.max_round_pool_sats, "bet_amount_sats": config.bet_amount_sats}
|
||||
await session.rollback()
|
||||
raise http_error(
|
||||
422,
|
||||
"max_round_pool_below_bet_amount",
|
||||
"max_round_pool_sats must be 0 (no cap) or at least the bet amount",
|
||||
**offending,
|
||||
)
|
||||
|
||||
if changes:
|
||||
await write_audit_log(session, "config_updated", changes)
|
||||
await session.commit()
|
||||
@@ -166,14 +197,7 @@ async def pause_lottery(
|
||||
B-78 (DAT-69, API-32): the reason is "operator", except that an automatic reason
|
||||
already in force stays — it is the one that needs a look. The audit row always
|
||||
says `operator` for this call."""
|
||||
config = await get_round_config(session)
|
||||
if not (config.paused and config.paused_reason in AUTOMATIC_REASONS):
|
||||
config.paused_reason = OPERATOR_REASON
|
||||
config.paused = True
|
||||
payload = {"reason": OPERATOR_REASON}
|
||||
if body is not None and body.note:
|
||||
payload["note"] = body.note
|
||||
await write_audit_log(session, "lottery_paused", payload)
|
||||
config = await pause_by_operator(session, body.note if body is not None else None)
|
||||
await session.commit()
|
||||
return _config_response(config)
|
||||
|
||||
@@ -186,6 +210,9 @@ async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundC
|
||||
lifted = config.paused_reason if config.paused else None
|
||||
config.paused = False
|
||||
config.paused_reason = None
|
||||
# B-82 (DAT-68, API-32): a payout held because the winner's bet left the chain is released
|
||||
# for a new attempt, which verifies the coins again, in the same transaction as the resume.
|
||||
await session.execute(update(Round).where(Round.payout_blocked_at.is_not(None)).values(payout_blocked_at=None))
|
||||
await write_audit_log(session, "lottery_resumed", {"reason": lifted})
|
||||
await session.commit()
|
||||
return _config_response(config)
|
||||
@@ -200,8 +227,17 @@ class AdminUserResponse(BaseModel):
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserResponse], dependencies=[Depends(require_admin)])
|
||||
async def list_users(session: AsyncSession = Depends(get_session)) -> list[AdminUserResponse]:
|
||||
users = (await session.scalars(select(User).order_by(User.id))).all()
|
||||
async def list_users(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
after_id: int | None = Query(default=None, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AdminUserResponse]:
|
||||
"""B-94 (API-33): by ascending id, a page at a time; the next page asks for `after_id` equal
|
||||
to the last id received, and a list shorter than `limit` is the last one."""
|
||||
query = select(User).order_by(User.id).limit(limit)
|
||||
if after_id is not None:
|
||||
query = query.where(User.id > after_id)
|
||||
users = (await session.scalars(query)).all()
|
||||
return [
|
||||
AdminUserResponse(
|
||||
id=u.id,
|
||||
@@ -228,7 +264,7 @@ async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session
|
||||
the most sensitive data the platform holds."""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
raise http_error(status.HTTP_404_NOT_FOUND, "user_not_found", "user not found")
|
||||
|
||||
wif = derive_user_wif(user.derivation_index)
|
||||
await write_audit_log(session, "admin_privkey_accessed", {"user_id": user_id}, user_id=user_id)
|
||||
@@ -256,7 +292,7 @@ async def reset_user_password(
|
||||
reset; only an admin (via /admin, token-gated) can trigger this."""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
raise http_error(status.HTTP_404_NOT_FOUND, "user_not_found", "user not found")
|
||||
|
||||
new_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = await hash_password_async(new_password)
|
||||
@@ -287,6 +323,7 @@ class AdminRoundResponse(BaseModel):
|
||||
server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret
|
||||
draw_height: int | None # B-73
|
||||
bump_reserve_sats: int | None # B-79 (API-36)
|
||||
payout_blocked_at: str | None # B-82 (API-36, DAT-68)
|
||||
|
||||
|
||||
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
|
||||
@@ -318,6 +355,7 @@ async def list_rounds(
|
||||
server_seed_hash=r.server_seed_hash,
|
||||
draw_height=r.draw_height,
|
||||
bump_reserve_sats=r.bump_reserve_sats,
|
||||
payout_blocked_at=isoformat_utc(r.payout_blocked_at),
|
||||
)
|
||||
for r in rounds
|
||||
]
|
||||
@@ -332,13 +370,54 @@ class AdminAuditLogResponse(BaseModel):
|
||||
created_at: str
|
||||
|
||||
|
||||
def _parse_instant(name: str, value: str | None) -> datetime | None:
|
||||
"""An ISO 8601 instant with an offset, as UTC; anything else is a 422 (API-37)."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if parsed is None or parsed.tzinfo is None:
|
||||
raise http_error(
|
||||
status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
"invalid_request",
|
||||
"invalid request",
|
||||
errors=[{"loc": ["query", name], "msg": "an ISO 8601 instant with an offset is required", "type": "value_error"}],
|
||||
)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)]
|
||||
)
|
||||
async def list_audit_log(
|
||||
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
|
||||
session: AsyncSession = Depends(get_session),
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
before_id: int | None = Query(default=None),
|
||||
event_type: list[str] | None = Query(default=None),
|
||||
round_id: int | None = Query(default=None),
|
||||
user_id: int | None = Query(default=None),
|
||||
from_: str | None = Query(default=None, alias="from"),
|
||||
to: str | None = Query(default=None),
|
||||
) -> list[AdminAuditLogResponse]:
|
||||
entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all()
|
||||
"""B-92 (API-37): newest first, a page at a time, every filter optional and combined with
|
||||
"and". The next page asks for `before_id` equal to the last id received."""
|
||||
query = select(AuditLog).order_by(AuditLog.id.desc()).limit(limit)
|
||||
if before_id is not None:
|
||||
query = query.where(AuditLog.id < before_id)
|
||||
if event_type:
|
||||
query = query.where(AuditLog.event_type.in_(event_type))
|
||||
if round_id is not None:
|
||||
query = query.where(AuditLog.round_id == round_id)
|
||||
if user_id is not None:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
start, end = _parse_instant("from", from_), _parse_instant("to", to)
|
||||
if start is not None:
|
||||
query = query.where(AuditLog.created_at >= start)
|
||||
if end is not None:
|
||||
query = query.where(AuditLog.created_at < end)
|
||||
entries = (await session.scalars(query)).all()
|
||||
return [
|
||||
AdminAuditLogResponse(
|
||||
id=e.id,
|
||||
@@ -456,7 +535,7 @@ async def update_bug_report_status(
|
||||
) -> AdminBugReportResponse:
|
||||
report = await session.get(BugReport, report_id)
|
||||
if report is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found")
|
||||
raise http_error(status.HTTP_404_NOT_FOUND, "bug_report_not_found", "bug report not found")
|
||||
|
||||
# B-60: every other admin mutation (config edit, pause/resume, privkey export,
|
||||
# password reset) leaves a trace; this one silently marked a report `resolved`.
|
||||
@@ -480,3 +559,29 @@ async def update_bug_report_status(
|
||||
username = user.username if user is not None else None
|
||||
|
||||
return _bug_report_response(report, username)
|
||||
|
||||
|
||||
class AdminElectrumResponse(BaseModel):
|
||||
connected: bool
|
||||
endpoint: str | None
|
||||
tip_height: int
|
||||
last_header_age_seconds: int | None
|
||||
fallback_count: int
|
||||
quorum_enforced: bool
|
||||
last_rotation: dict | None
|
||||
|
||||
|
||||
@router.get("/electrum", response_model=AdminElectrumResponse, dependencies=[Depends(require_admin)])
|
||||
async def electrum_status(request: Request) -> AdminElectrumResponse:
|
||||
"""B-92 (API-47, ELC-29): the connection to the network, for the panel's indicator."""
|
||||
listener = request.app.state.electrum_listener
|
||||
connected = listener.client is not None
|
||||
return AdminElectrumResponse(
|
||||
connected=connected,
|
||||
endpoint=str(listener.current_endpoint) if connected and listener.current_endpoint else None,
|
||||
tip_height=listener.tip_height,
|
||||
last_header_age_seconds=listener.last_header_age_seconds(),
|
||||
fallback_count=listener.fallback_count,
|
||||
quorum_enforced=listener.quorum_enforced,
|
||||
last_rotation=listener.last_rotation,
|
||||
)
|
||||
+10
-7
@@ -26,16 +26,19 @@ async def create_bet(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> BetResponse:
|
||||
listener = request.app.state.electrum_listener
|
||||
if listener.client is None:
|
||||
raise http_error(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"network_unavailable",
|
||||
"not connected to the network, try again shortly",
|
||||
)
|
||||
|
||||
async with request.app.state.user_locks.acquire(user.id):
|
||||
# B-96 (BET-02): read after the lock is taken — a wait on it across a reconnection
|
||||
# must not hand place_bet the connection that closed while this request queued.
|
||||
client = listener.client
|
||||
if client is None:
|
||||
raise http_error(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"network_unavailable",
|
||||
"not connected to the network, try again shortly",
|
||||
)
|
||||
try:
|
||||
participant = await place_bet(session, listener.client, user, network=listener)
|
||||
participant = await place_bet(session, client, user, network=listener)
|
||||
except BetError as exc:
|
||||
# A rejected broadcast isn't the client's fault — it's the network
|
||||
# refusing our transaction, so it answers 502 rather than 400 (B-07).
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import 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_current_user, get_optional_user
|
||||
from app.auth.routes import _rate_limited_error, _rate_limiters
|
||||
from app.db.models import BugReport, User
|
||||
from app.db.session import get_session
|
||||
|
||||
@@ -39,9 +41,17 @@ class BugReportResponse(BaseModel):
|
||||
@router.post("", response_model=BugReportResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bug_report(
|
||||
body: BugReportCreate,
|
||||
request: Request,
|
||||
user: User | None = Depends(get_optional_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> BugReportResponse:
|
||||
# B-94 (SEC-55): a quota per IP, checked before the insert and consumed only by a report
|
||||
# that was stored (validation failures never get here).
|
||||
quota = _rate_limiters(request).bug_reports
|
||||
ip_key = f"ip:{client_ip(request)}"
|
||||
retry_after = quota.retry_after(ip_key)
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
report = BugReport(
|
||||
description=body.description,
|
||||
contact=body.contact,
|
||||
@@ -49,6 +59,7 @@ async def create_bug_report(
|
||||
)
|
||||
session.add(report)
|
||||
await session.commit()
|
||||
quota.record(ip_key)
|
||||
return BugReportResponse(id=report.id)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -57,7 +57,7 @@ async def round_stream(request: Request) -> Response:
|
||||
try:
|
||||
queue = broadcaster.subscribe(client_ip(request))
|
||||
except RoundEventCapacityError:
|
||||
return JSONResponse(status_code=503, content={"detail": "too many concurrent update streams"})
|
||||
raise http_error(503, "too_many_streams", "too many concurrent update streams")
|
||||
|
||||
async def event_generator():
|
||||
ticks_since_keepalive = 0
|
||||
@@ -119,6 +119,8 @@ class CurrentRoundResponse(BaseModel):
|
||||
# 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
|
||||
# B-96 (TX-29): since when the round has waited in "closing" for in-flight bets; only then.
|
||||
closing_waiting_since: str | None = None
|
||||
# B-73 (RND-34, RND-14): the commitment published from the opening, and what is fixed
|
||||
# on entering "drawing". The secret itself is never in this response (API-45 has it, once
|
||||
# revealed).
|
||||
@@ -222,7 +224,7 @@ async def current_round(
|
||||
opened_at=opened_at.isoformat(),
|
||||
closes_at=closes_at.isoformat(),
|
||||
participant_count=participant_count,
|
||||
bet_amount_sats=config.bet_amount_sats,
|
||||
bet_amount_sats=round_.bet_amount_sats, # B-95 (API-21): the round's own, fixed at open
|
||||
jackpot_sats=jackpot_sats,
|
||||
pending_participant_count=all_count,
|
||||
pending_jackpot_sats=winner_share(all_pool_sats),
|
||||
@@ -233,6 +235,7 @@ async def current_round(
|
||||
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,
|
||||
closing_waiting_since=isoformat_utc(round_.closed_at) if round_.status == "closing" and round_.closed_at else None,
|
||||
server_seed_hash=round_.server_seed_hash,
|
||||
participants_hash=round_.participants_hash,
|
||||
draw_height=round_.draw_height,
|
||||
|
||||
+23
-5
@@ -1,11 +1,13 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import http_error
|
||||
from app.audit.log import write_audit_log
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.auth.routes import _rate_limited_error, _rate_limiters
|
||||
from app.auth.security import (
|
||||
MIN_PASSWORD_LENGTH,
|
||||
create_access_token,
|
||||
@@ -31,10 +33,15 @@ class MeResponse(BaseModel):
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MeResponse:
|
||||
pending_balance_sats, has_pending = await compute_pending_balance(session, user)
|
||||
# B-82 (DEP-12 (b)): deposits confirmed but short of their threshold, kept in memory by
|
||||
# the listener (absent in an app without one, e.g. a test of this route alone).
|
||||
listener = getattr(request.app.state, "electrum_listener", None)
|
||||
waiting_sats = listener.pending_deposit_sats(user.id) if listener is not None else 0
|
||||
pending_balance_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
|
||||
return MeResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
@@ -47,8 +54,8 @@ async def me(
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
current_password: str = Field(max_length=256) # B-94 (REG-12)
|
||||
new_password: str = Field(max_length=256)
|
||||
|
||||
|
||||
class ChangePasswordResponse(BaseModel):
|
||||
@@ -58,13 +65,22 @@ class ChangePasswordResponse(BaseModel):
|
||||
@router.post("/me/change-password", response_model=ChangePasswordResponse)
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ChangePasswordResponse:
|
||||
"""Self-service password change — requires the current password, unlike the
|
||||
admin-only /admin/users/{id}/reset-password (which is for a user who's
|
||||
actually locked out and can't provide it)."""
|
||||
# B-94 (SEC-54): a stolen token must not be able to guess the current password, change it
|
||||
# and lock the owner out. Checked before the Argon2 hash and even for the right password.
|
||||
limiter = _rate_limiters(request).change_password
|
||||
key = f"user:{user.id}"
|
||||
retry_after = limiter.retry_after(key)
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
if not await verify_password_async(body.current_password, user.password_hash):
|
||||
limiter.record_failure(key)
|
||||
raise http_error(
|
||||
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
|
||||
)
|
||||
@@ -84,7 +100,9 @@ async def change_password(
|
||||
# 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 write_audit_log(session, "password_changed", {"user_id": user.id}, user_id=user.id)
|
||||
await session.commit()
|
||||
limiter.record_success(key)
|
||||
return ChangePasswordResponse(access_token=create_access_token(user.id, user.token_version))
|
||||
|
||||
|
||||
|
||||
@@ -32,17 +32,19 @@ async def create_withdrawal(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WithdrawalResponse:
|
||||
listener = request.app.state.electrum_listener
|
||||
if listener.client is None:
|
||||
raise http_error(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"network_unavailable",
|
||||
"not connected to the network, try again shortly",
|
||||
)
|
||||
|
||||
async with request.app.state.user_locks.acquire(user.id):
|
||||
# B-96 (WDR-01): read after the lock is taken, as in create_bet.
|
||||
client = listener.client
|
||||
if client is None:
|
||||
raise http_error(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"network_unavailable",
|
||||
"not connected to the network, try again shortly",
|
||||
)
|
||||
try:
|
||||
withdrawal = await request_withdrawal(
|
||||
session, listener.client, user, body.external_address, body.amount_sats, network=listener
|
||||
session, client, user, body.external_address, body.amount_sats, network=listener
|
||||
)
|
||||
except WithdrawalError as exc:
|
||||
code = (
|
||||
|
||||
@@ -186,6 +186,8 @@ class RollingQuota:
|
||||
|
||||
_REGISTRATIONS_PER_IP = 5
|
||||
_REGISTRATION_WINDOW_SECONDS = 3600.0
|
||||
_BUG_REPORTS_PER_IP = 10
|
||||
_BUG_REPORT_WINDOW_SECONDS = 3600.0
|
||||
|
||||
|
||||
class AuthRateLimiters:
|
||||
@@ -208,3 +210,8 @@ class AuthRateLimiters:
|
||||
self.register_ip = RollingQuota(
|
||||
limit=_REGISTRATIONS_PER_IP, window_seconds=_REGISTRATION_WINDOW_SECONDS
|
||||
)
|
||||
# B-94 (SEC-54): wrong `current_password` on a password change, per user — a stolen
|
||||
# token must not be able to guess the password and lock the owner out.
|
||||
self.change_password = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0)
|
||||
# B-94 (SEC-55): bug reports are anonymous; a quota per IP keeps them from filling the DB.
|
||||
self.bug_reports = RollingQuota(limit=_BUG_REPORTS_PER_IP, window_seconds=_BUG_REPORT_WINDOW_SECONDS)
|
||||
+22
-2
@@ -6,6 +6,7 @@ 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.audit.log import write_audit_log
|
||||
from app.auth.rate_limit import AuthRateLimiters
|
||||
from app.auth.security import (
|
||||
MIN_PASSWORD_LENGTH,
|
||||
@@ -70,6 +71,14 @@ class TokenResponse(BaseModel):
|
||||
async def register(
|
||||
body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session)
|
||||
) -> TokenResponse:
|
||||
# B-90 (REG-15): after a restore a new user could be handed the derivation index — the
|
||||
# address, with the funds — of someone who registered after the backup.
|
||||
if not getattr(request.app.state.electrum_listener, "registration_open", True):
|
||||
raise http_error(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"registration_unavailable",
|
||||
"registration is temporarily unavailable",
|
||||
)
|
||||
limiters = _rate_limiters(request)
|
||||
ip_key = f"ip:{_client_ip(request)}"
|
||||
# B-58: checked before the Argon2 hash below, so an IP that's out of quota
|
||||
@@ -113,6 +122,15 @@ async def register(
|
||||
)
|
||||
session.add(user)
|
||||
try:
|
||||
# B-92 (AUD-41, REG-16): in the same transaction as the user, so after a restore the log
|
||||
# says which derivation indexes were already assigned.
|
||||
await session.flush()
|
||||
await write_audit_log(
|
||||
session,
|
||||
"user_registered",
|
||||
{"derivation_index": next_index, "address": address},
|
||||
user_id=user.id,
|
||||
)
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
@@ -140,8 +158,10 @@ async def register(
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
# B-94 (API-19): bounded before anything is hashed or counted; a megabyte password would
|
||||
# otherwise be an Argon2 input and a rate-limit key.
|
||||
username: str = Field(max_length=256)
|
||||
password: str = Field(max_length=256)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
|
||||
+35
-3
@@ -16,7 +16,7 @@ from app.rounds.events import broadcaster
|
||||
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
|
||||
from app.tx.versions import delete_versions, record_version
|
||||
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
|
||||
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 (
|
||||
MAX_PARTICIPANTS_PER_ROUND,
|
||||
@@ -93,19 +93,51 @@ async def place_bet(
|
||||
max_participants=MAX_PARTICIPANTS_PER_ROUND,
|
||||
)
|
||||
|
||||
# B-95 (BET-20, DAT-27): the round's own cap on the pool, when it has one. The sum counts
|
||||
# every participant row (a bet still in flight may yet confirm — the conservative side), and
|
||||
# a bet is refused once the sum has *reached* the cap, since its own net amount is not known
|
||||
# before it is built: the pool can therefore end up above the cap by at most one bet.
|
||||
if round_.max_round_pool_sats:
|
||||
pool_so_far = await session.scalar(
|
||||
select(func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0)).where(
|
||||
RoundParticipant.round_id == round_.id
|
||||
)
|
||||
)
|
||||
if pool_so_far >= round_.max_round_pool_sats:
|
||||
raise BetError(
|
||||
"round_full",
|
||||
"this round has reached its limit, wait for the next one",
|
||||
max_participants=MAX_PARTICIPANTS_PER_ROUND,
|
||||
max_round_pool_sats=round_.max_round_pool_sats,
|
||||
)
|
||||
|
||||
if not round_.pool_address:
|
||||
# B-80: open_new_round_if_needed always writes it; a row without one cannot
|
||||
# take money that would then have nowhere verifiable to go.
|
||||
raise RuntimeError(f"round {round_.id} has no pool address")
|
||||
|
||||
bet_amount = config.bet_amount_sats
|
||||
# B-95 (BET-21): the stake of the round — what every player of it pays — not the value in
|
||||
# round_config now, which an operator may have edited since the round opened.
|
||||
bet_amount = round_.bet_amount_sats
|
||||
|
||||
unspent = (
|
||||
await session.scalars(
|
||||
select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None))
|
||||
)
|
||||
).all()
|
||||
if sum(u.amount_sats for u in unspent) < bet_amount:
|
||||
confirmed_sats = sum(u.amount_sats for u in unspent)
|
||||
if confirmed_sats < bet_amount:
|
||||
# B-96 (BET-07, DEP-13): the balance on screen includes unconfirmed change and deposits
|
||||
# short of their threshold; a bet that balance would cover gets "wait for the
|
||||
# confirmation", as a withdrawal does, instead of a flat "insufficient".
|
||||
waiting_sats = network.pending_deposit_sats(user.id) if hasattr(network, "pending_deposit_sats") else 0
|
||||
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
|
||||
if has_pending and pending_inclusive_sats >= bet_amount:
|
||||
raise BetError(
|
||||
"balance_pending_confirmation",
|
||||
"the bet cost is covered by your pending balance, which has not confirmed yet",
|
||||
pending_sats=pending_inclusive_sats - confirmed_sats,
|
||||
)
|
||||
raise BetError("insufficient_balance", "insufficient balance", required_sats=bet_amount)
|
||||
|
||||
user_key = derive_user_key(user.derivation_index)
|
||||
|
||||
+21
-1
@@ -19,6 +19,14 @@ logger = logging.getLogger(__name__)
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
if self.xprv_encryption_key_file and not self.xprv_encryption_key:
|
||||
try:
|
||||
with open(self.xprv_encryption_key_file) as f:
|
||||
self.xprv_encryption_key = f.read().strip()
|
||||
except OSError:
|
||||
pass # validate_runtime_secrets reports it, naming the file
|
||||
|
||||
database_url: str = "sqlite+aiosqlite:///./plm_lottery.db"
|
||||
|
||||
electrum_host: str = "santantonio.sytes.net"
|
||||
@@ -41,7 +49,14 @@ class Settings(BaseSettings):
|
||||
# trust the quorum exists to remove.
|
||||
allow_no_quorum: bool = False
|
||||
|
||||
# B-90 (OPS-27): start with the lottery paused, as after restoring a backup.
|
||||
start_paused: bool = False
|
||||
|
||||
xprv_encryption_key: str = ""
|
||||
# B-91 (SEC-53): the key as a file (a compose secret in the container), so it is in no
|
||||
# environment variable and `docker inspect` cannot show it. When set it is read into
|
||||
# `xprv_encryption_key` at load, which is what the rest of the code uses.
|
||||
xprv_encryption_key_file: str = ""
|
||||
master_key_path: str = "./master.xprv.enc"
|
||||
jwt_secret: str = ""
|
||||
jwt_algorithm: str = "HS256"
|
||||
@@ -92,8 +107,13 @@ def validate_runtime_secrets(config: Settings | None = None) -> None:
|
||||
'(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")'
|
||||
)
|
||||
if not config.xprv_encryption_key.strip():
|
||||
where = (
|
||||
f"XPRV_ENCRYPTION_KEY_FILE ({config.xprv_encryption_key_file}) is missing or empty"
|
||||
if config.xprv_encryption_key_file
|
||||
else "XPRV_ENCRYPTION_KEY_FILE must point to a file holding the key"
|
||||
)
|
||||
problems.append(
|
||||
"XPRV_ENCRYPTION_KEY must be set (generate: python -c "
|
||||
where + " (generate: python -c "
|
||||
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
|
||||
)
|
||||
if problems:
|
||||
|
||||
@@ -114,6 +114,9 @@ class Round(Base):
|
||||
# the reserve address and is what an RBF bump shrinks, so this is never rewritten by a bump).
|
||||
bump_reserve_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
|
||||
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
# B-82 (DAT-68): set when the verification before the payout finds the *winner's* bet gone
|
||||
# from the chain; while it is set no payout is built. Cleared by POST /admin/resume.
|
||||
payout_blocked_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
# B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's
|
||||
# 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with
|
||||
# `server_seed_hash` (sha256 of the secret, published from then on); the plaintext
|
||||
@@ -125,6 +128,14 @@ class Round(Base):
|
||||
# blocks the chain must have from `draw_height` before its hash is read, copied from the
|
||||
# config at open.
|
||||
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
|
||||
# B-95 (DAT-61, RND-05): the rest of what governs this round, copied from round_config in the
|
||||
# transaction that opens it and never changed — the cost of a bet, where the commission goes,
|
||||
# and the cap on the pool (0 = none). Read live, a mid-round edit gave players of the same
|
||||
# round different stakes and redirected a round's commission. The fee *rate* stays live: it
|
||||
# belongs to a transaction, not to the rules of the round.
|
||||
bet_amount_sats: Mapped[int] = mapped_column(BigInteger, default=1_000_000_000, server_default="1000000000")
|
||||
fee_address: Mapped[str] = mapped_column(String(128), default="", server_default="")
|
||||
max_round_pool_sats: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0")
|
||||
server_seed_enc: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
server_seed: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
server_seed_hash: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
@@ -184,6 +195,12 @@ class RoundConfig(Base):
|
||||
# of that block is read; copied onto each round when it opens (DAT-61), so an edit
|
||||
# applies from the next round.
|
||||
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
|
||||
# B-82 (DAT-62, DEP-03): confirmations after which an output paying a user is credited
|
||||
# as a deposit (platform transactions keep 1, coinbase outputs 120).
|
||||
deposit_confirmations: Mapped[int] = mapped_column(default=6, server_default="6")
|
||||
# B-95 (DAT-27, DAT-62): cap on the sum of a round's bets, 0 = no cap; copied onto each round
|
||||
# when it opens. Past it a bet is refused with `round_full`.
|
||||
max_round_pool_sats: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0")
|
||||
fee_rate_sat_vb: Mapped[int] = mapped_column(default=1)
|
||||
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
|
||||
# Maintenance switch: when true, the round currently in progress still runs to
|
||||
|
||||
+85
-1
@@ -1,17 +1,33 @@
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import UtxoEvent
|
||||
from app.db.models import PendingTransaction, PendingTransactionTxid, RoundConfig, UtxoEvent
|
||||
from app.rounds.events import broadcaster
|
||||
from app.wallet.balance import recompute_balance
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# B-82 (DEP-03): confirmations a coinbase output needs before it can be spent, PLM's
|
||||
# COINBASE_MATURITY (palladiumcore/src/consensus/consensus.h). A coinbase paying a user's
|
||||
# address is credited no earlier than this, whatever deposit_confirmations says.
|
||||
COINBASE_MATURITY = 120
|
||||
|
||||
# Confirmations an output created by a transaction of the platform itself — the change of
|
||||
# a bet or a withdrawal, a payout — needs: the platform built it, nobody else could have
|
||||
# double-spent its inputs (DEP-03).
|
||||
PLATFORM_TRANSACTION_CONFIRMATIONS = 1
|
||||
|
||||
# Default of round_config.deposit_confirmations, used only when the config row does not
|
||||
# exist yet (get_round_config creates it with the same value).
|
||||
DEFAULT_DEPOSIT_CONFIRMATIONS = 6
|
||||
|
||||
|
||||
async def find_new_credit_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
|
||||
"""The subset of `entries` that would actually credit something: confirmed
|
||||
@@ -41,6 +57,74 @@ async def find_new_credit_candidates(session: AsyncSession, user_id: int, entrie
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DepositPolicy:
|
||||
"""What decides how many confirmations an incoming output needs (DEP-03), read from the
|
||||
database before any network call: the configured deposit threshold and which of the
|
||||
transactions involved are the platform's own."""
|
||||
|
||||
deposit_confirmations: int = DEFAULT_DEPOSIT_CONFIRMATIONS
|
||||
platform_txids: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
async def read_deposit_policy(session: AsyncSession, txids: set[str]) -> DepositPolicy:
|
||||
"""The deposit threshold and, among `txids`, the ones created by a platform transaction:
|
||||
any txid of a `pending_transactions` row, replaced RBF versions included (B-77)."""
|
||||
configured = await session.scalar(select(RoundConfig.deposit_confirmations))
|
||||
platform: set[str] = set()
|
||||
if txids:
|
||||
platform |= set(
|
||||
(await session.scalars(select(PendingTransactionTxid.txid).where(PendingTransactionTxid.txid.in_(txids))))
|
||||
)
|
||||
platform |= set(
|
||||
(await session.scalars(select(PendingTransaction.current_txid).where(PendingTransaction.current_txid.in_(txids))))
|
||||
)
|
||||
return DepositPolicy(
|
||||
deposit_confirmations=configured if configured else DEFAULT_DEPOSIT_CONFIRMATIONS, platform_txids=platform
|
||||
)
|
||||
|
||||
|
||||
async def classify_new_deposits(
|
||||
new_entries: list[dict],
|
||||
tip_height: int,
|
||||
policy: DepositPolicy,
|
||||
is_coinbase: Callable[[str], Awaitable[bool | None]],
|
||||
) -> tuple[list[tuple[dict, int]], list[dict]]:
|
||||
"""Split the new, confirmed, non-dust entries of `find_new_credit_candidates` into the
|
||||
ones that may now be credited — as `(entry, required_confirmations)`, the number the
|
||||
quorum is then asked to confirm (ELC-17) — and the ones still **waiting** for
|
||||
confirmations (B-82, DEP-03, DEP-14).
|
||||
|
||||
Confirmations are `tip_height - height + 1`. The threshold is 1 for an output of a
|
||||
platform transaction, 120 (`COINBASE_MATURITY`) for a coinbase output, and
|
||||
`deposit_confirmations` for anything else; a coinbase is only looked for once the plain
|
||||
threshold is met, since below it the output waits anyway. `is_coinbase` answers from the
|
||||
raw transaction (hash-checked by the caller) and may return None when it cannot tell, in
|
||||
which case the output waits and is asked again at the next refresh. No database session
|
||||
is involved: this runs between the read and the write phases, where the network is used.
|
||||
"""
|
||||
candidates: list[tuple[dict, int]] = []
|
||||
waiting: list[dict] = []
|
||||
for entry in new_entries:
|
||||
confirmations = tip_height - entry["height"] + 1
|
||||
if entry["tx_hash"] in policy.platform_txids:
|
||||
required = PLATFORM_TRANSACTION_CONFIRMATIONS
|
||||
else:
|
||||
required = policy.deposit_confirmations
|
||||
if required <= confirmations < COINBASE_MATURITY:
|
||||
# Plain threshold met, maturity not: only now is it worth knowing whether
|
||||
# this is a coinbase output (which would still be immature).
|
||||
coinbase = await is_coinbase(entry["tx_hash"])
|
||||
if coinbase is None or coinbase:
|
||||
waiting.append(entry)
|
||||
continue
|
||||
if confirmations < required:
|
||||
waiting.append(entry)
|
||||
continue
|
||||
candidates.append((entry, required))
|
||||
return candidates, waiting
|
||||
|
||||
|
||||
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
|
||||
"""Insert utxo_events for newly-confirmed entries from an Electrum
|
||||
`listunspent` response (idempotent on txid+vout), refresh the user's cached
|
||||
|
||||
@@ -7,6 +7,8 @@ import re
|
||||
import ssl
|
||||
from typing import NamedTuple
|
||||
|
||||
from app.wallet.plm_network import GENESIS_HASH
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -192,6 +194,20 @@ class ElectrumClient:
|
||||
raise
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
await self.request("server.version", ["plm-lottery", "1.4"])
|
||||
try:
|
||||
await self._check_genesis()
|
||||
except ElectrumError:
|
||||
self._read_task.cancel()
|
||||
self._writer.close()
|
||||
raise
|
||||
|
||||
async def _check_genesis(self) -> None:
|
||||
"""The server must be on PLM's chain, not just any chain with valid headers: PLM shares
|
||||
Bitcoin's genesis and powLimit, so only `server.features` tells them apart (B-99, ELC-30)."""
|
||||
features = await self.request("server.features")
|
||||
genesis = features.get("genesis_hash") if isinstance(features, dict) else None
|
||||
if genesis != GENESIS_HASH:
|
||||
raise ElectrumError(f"{self.host}:{self.port} is not on the PLM chain (genesis {genesis!r})")
|
||||
|
||||
def _check_pin(self) -> None:
|
||||
"""The server's DER certificate must hash to the pinned SHA-256 (B-84, ELC-03)."""
|
||||
|
||||
+265
-10
@@ -1,22 +1,25 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import User
|
||||
from app.deposits.service import (
|
||||
classify_new_deposits,
|
||||
credit_confirmed_utxos,
|
||||
find_new_credit_candidates,
|
||||
find_reinstate_candidates,
|
||||
find_utxos_missing_from,
|
||||
mark_utxos_spent_externally,
|
||||
read_deposit_policy,
|
||||
reinstate_reappeared_utxos,
|
||||
)
|
||||
from app.config import MIN_QUORUM_RESPONSES
|
||||
@@ -32,6 +35,7 @@ from app.rounds.draw import (
|
||||
)
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.pause import pause_automatically
|
||||
from app.wallet.hd import derive_user_address
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -68,10 +72,22 @@ _monotonic = time.monotonic
|
||||
# ordinary on any chain, two or more is worth a look before a new round opens.
|
||||
_REORG_PAUSE_DEPTH = 2
|
||||
|
||||
# B-90 (REG-15): how many derivation indexes past the highest assigned one are looked up for history
|
||||
# before registration opens.
|
||||
_RESTORE_GAP_WINDOW = 20
|
||||
|
||||
# `blockchain.block.headers` serves at most this many headers per request (ELC-12).
|
||||
_MAX_HEADERS_PER_REQUEST = 2016
|
||||
|
||||
|
||||
def _rotation_reason(exc: BaseException) -> str:
|
||||
if isinstance(exc, ServerStalledError):
|
||||
return "stalled_tip"
|
||||
if isinstance(exc, HeaderValidationError):
|
||||
return "invalid_header"
|
||||
return "error"
|
||||
|
||||
|
||||
class ServerStalledError(Exception):
|
||||
"""The active server stopped following the chain while others report a higher tip
|
||||
(B-86, ELC-21). Ends the session like a dropped connection, so run() rotates."""
|
||||
@@ -93,6 +109,11 @@ class ElectrumListener:
|
||||
servers down) still backs off instead of spinning.
|
||||
"""
|
||||
|
||||
# B-90 (REG-15): False until the check of the derivation indexes past the highest assigned
|
||||
# one has completed and found no address with history. A class attribute so a test that
|
||||
# is not about a restore can open registration once for every listener.
|
||||
registration_open = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_factory: Callable[[ElectrumEndpoint], ElectrumClient],
|
||||
@@ -106,6 +127,11 @@ class ElectrumListener:
|
||||
# B-84 (ELC-19): ALLOW_NO_QUORUM, development only — see _corroborate_majority.
|
||||
self._allow_no_quorum = allow_no_quorum
|
||||
self._endpoint_index = 0
|
||||
self._restore_checked = False
|
||||
# B-92 (API-47): why and when the listener last moved on to another server.
|
||||
self.last_rotation: dict | None = None
|
||||
self._end_reason: str | None = None
|
||||
self._refresh_locks: dict[int, asyncio.Lock] = {}
|
||||
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
|
||||
@@ -120,6 +146,27 @@ class ElectrumListener:
|
||||
# B-86: when the last header was accepted (monotonic seconds), None until one is.
|
||||
self._last_header_at: float | None = None
|
||||
self.client: ElectrumClient | None = None
|
||||
# B-82 (DEP-14): per user, the deposits seen at the last refresh that are confirmed
|
||||
# but still short of their threshold — what the displayed balance shows as pending.
|
||||
# Memory only: it moves no money and is rebuilt by the first refresh after a restart.
|
||||
self._pending_deposits: dict[int, list[dict]] = {}
|
||||
# txid -> is it a coinbase (DEP-03). A transaction never changes, so this is safe to
|
||||
# keep; bounded so a long-lived process does not grow it without limit.
|
||||
self._coinbase_cache: dict[str, bool] = {}
|
||||
# Users with a refresh already running because of a new block (see _consume_headers).
|
||||
self._block_refreshes: set[int] = set()
|
||||
|
||||
def last_header_age_seconds(self) -> int | None:
|
||||
"""B-92 (ELC-29): seconds since a header was last accepted, None before the first."""
|
||||
return None if self._last_header_at is None else int(_monotonic() - self._last_header_at)
|
||||
|
||||
@property
|
||||
def fallback_count(self) -> int:
|
||||
return max(len(self._endpoints) - 1, 0)
|
||||
|
||||
@property
|
||||
def quorum_enforced(self) -> bool:
|
||||
return not self._allow_no_quorum
|
||||
|
||||
@property
|
||||
def current_endpoint(self) -> ElectrumEndpoint | None:
|
||||
@@ -167,11 +214,13 @@ class ElectrumListener:
|
||||
logger.error("no Electrum endpoints configured; listener idle")
|
||||
return
|
||||
connected = False
|
||||
self._end_reason = None
|
||||
try:
|
||||
connected = await self._run_once(endpoint)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
self._end_reason = _rotation_reason(exc)
|
||||
logger.exception("Electrum session on %s failed", endpoint)
|
||||
finally:
|
||||
self.client = None
|
||||
@@ -180,6 +229,11 @@ class ElectrumListener:
|
||||
# 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)
|
||||
self.last_rotation = {
|
||||
"reason": self._end_reason or ("connection_closed" if connected else "unreachable"),
|
||||
"from": str(endpoint),
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
if connected:
|
||||
# We did reach a server, so the network is up — don't let an earlier
|
||||
@@ -256,21 +310,78 @@ class ElectrumListener:
|
||||
# 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)
|
||||
restore_task = None
|
||||
if not self._restore_checked:
|
||||
restore_task = asyncio.create_task(self._check_restore_gap())
|
||||
restore_task.add_done_callback(self._log_restore_check_failure)
|
||||
try:
|
||||
done, still_running = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
subscribe_task.cancel()
|
||||
if restore_task is not None:
|
||||
restore_task.cancel()
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
for task in done:
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
self._end_reason = _rotation_reason(exc)
|
||||
logger.warning("Electrum session on %s ending: %r", endpoint, exc)
|
||||
finally:
|
||||
self.client = None
|
||||
await client.close()
|
||||
return True
|
||||
|
||||
def _log_restore_check_failure(self, task: asyncio.Task) -> None:
|
||||
if not task.cancelled() and task.exception() is not None:
|
||||
logger.warning("restore-gap check not completed, registration stays closed: %r", task.exception())
|
||||
|
||||
async def _check_restore_gap(self) -> None:
|
||||
"""B-90 (REG-15): a database restored from a backup does not know the users who
|
||||
registered after it, and would hand their derivation indexes — their addresses,
|
||||
with whatever they received — to new users. So the `_RESTORE_GAP_WINDOW` indexes
|
||||
past the highest assigned one are looked up with `get_history` on the active
|
||||
server and on every other one; a single server reporting a transaction makes the
|
||||
address used. Registration opens only when none is; otherwise `restore_gap_detected`
|
||||
is written and it stays closed until a later start finds none (the operator
|
||||
occupies those indexes, OPS-28). If the active server cannot answer the check is
|
||||
simply tried again on the next session."""
|
||||
async with self._session_factory() as session:
|
||||
highest = (await session.execute(select(func.max(User.derivation_index)))).scalar_one_or_none()
|
||||
first = 0 if highest is None else highest + 1
|
||||
addresses = [(index, derive_user_address(index)) for index in range(first, first + _RESTORE_GAP_WINDOW)]
|
||||
|
||||
async def ask(client: ElectrumClient) -> list[bool]:
|
||||
histories = await asyncio.gather(
|
||||
*(client.request("blockchain.scripthash.get_history", [address_to_scripthash(a)]) for _, a in addresses)
|
||||
)
|
||||
return [bool(history) for history in histories]
|
||||
|
||||
active = await self._ask_active_server(ask)
|
||||
if active is None:
|
||||
raise RuntimeError("the active server did not answer the history lookups")
|
||||
others = await self._ask_other_servers(ask)
|
||||
answers = [active] + [answer for answer in others if answer is not None]
|
||||
used = [
|
||||
{"derivation_index": index, "address": address}
|
||||
for position, (index, address) in enumerate(addresses)
|
||||
if any(answer[position] for answer in answers)
|
||||
]
|
||||
self._restore_checked = True
|
||||
if not used:
|
||||
self.registration_open = True
|
||||
return
|
||||
logger.error(
|
||||
"restore gap: %s derivation index(es) past the highest assigned one already have history "
|
||||
"on chain — registration stays closed (OPS-28)",
|
||||
len(used),
|
||||
)
|
||||
async with self._session_factory() as session:
|
||||
await write_audit_log(
|
||||
session, "restore_gap_detected", {"highest_assigned_index": highest, "used": used}
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
def _log_subscribe_all_users_failure(self, task: asyncio.Task) -> None:
|
||||
if task.cancelled():
|
||||
return
|
||||
@@ -819,7 +930,9 @@ class ElectrumListener:
|
||||
|
||||
return await self._corroborate_majority(_ask, lambda agrees: agrees, f"outpoint {txid}:{vout}")
|
||||
|
||||
async def corroborate_utxo_credit(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
|
||||
async def corroborate_utxo_credit(
|
||||
self, scripthash: str, txid: str, vout: int, value: int, required_confirmations: int | None = None
|
||||
) -> bool:
|
||||
"""B-59: the mirror of corroborate_utxo_spent, for money going the other
|
||||
way. A candidate external spend could not reduce a balance without a
|
||||
quorum, but `value` and `height` for a *credit* were taken from the single
|
||||
@@ -841,8 +954,11 @@ class ElectrumListener:
|
||||
only credited once the quorum agrees.
|
||||
"""
|
||||
|
||||
# B-82 (ELC-17): the other server must also place the coin deep enough — at a height
|
||||
# that gives it `required_confirmations` counted on *our* tip, not on its own.
|
||||
max_height = None if required_confirmations is None else self.tip_height - required_confirmations + 1
|
||||
return await self._corroborate_unspent_confirmed(
|
||||
scripthash, txid, vout, value, f"credit of {value} sats at {txid}:{vout}"
|
||||
scripthash, txid, vout, value, f"credit of {value} sats at {txid}:{vout}", max_height=max_height
|
||||
)
|
||||
|
||||
async def corroborate_utxo_reinstatement(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
|
||||
@@ -856,10 +972,11 @@ class ElectrumListener:
|
||||
)
|
||||
|
||||
async def _corroborate_unspent_confirmed(
|
||||
self, scripthash: str, txid: str, vout: int, value: int, description: str
|
||||
self, scripthash: str, txid: str, vout: int, value: int, description: str, max_height: int | None = None
|
||||
) -> bool:
|
||||
"""Do the other servers list this outpoint as unspent, for the same amount, and
|
||||
confirmed (height > 0)? Shared by credits (B-59) and reinstatements (B-83)."""
|
||||
confirmed (height > 0, and no higher than `max_height` when one is given)? Shared by
|
||||
credits (B-59, B-82) and reinstatements (B-83)."""
|
||||
|
||||
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
|
||||
client = self._client_factory(endpoint)
|
||||
@@ -873,6 +990,7 @@ class ElectrumListener:
|
||||
and e.get("tx_pos") == vout
|
||||
and e.get("value") == value
|
||||
and (e.get("height") or 0) > 0
|
||||
and (max_height is None or (e.get("height") or 0) <= max_height)
|
||||
for e in entries
|
||||
)
|
||||
except Exception:
|
||||
@@ -882,6 +1000,59 @@ class ElectrumListener:
|
||||
|
||||
return await self._corroborate_majority(_ask, lambda agrees: agrees, description)
|
||||
|
||||
async def verify_pool_outpoints(
|
||||
self, scripthash: str, outpoints: list[tuple[str, int, int]]
|
||||
) -> dict[tuple[str, int], bool] | None:
|
||||
"""Which of a round's bet outputs (`(txid, vout, amount)`) are really there, at the
|
||||
round's pool address, according to the *other* servers (B-82, ELC-27, RND-39).
|
||||
|
||||
Every other server lists the address' unspent coins; each answer says, per outpoint,
|
||||
*present* (listed, same amount, confirmed), *absent* (not listed, or listed with
|
||||
another amount) or neither (listed but unconfirmed — a block that left the chain
|
||||
may well come back, which is no reason to throw a bet out). An outpoint is present
|
||||
or absent when a strict majority of the answers say so. The result maps every
|
||||
outpoint to True (present) or False (absent); it is None — nothing concluded — when
|
||||
fewer than the quorum minimum (ELC-14) of servers answered or an outpoint has no
|
||||
majority either way, so a participant is only ever excluded on the quorum's word and
|
||||
the caller simply tries again later. With ALLOW_NO_QUORUM=true and no other server
|
||||
configured the active server answers instead (development only)."""
|
||||
|
||||
async def _ask(client: ElectrumClient) -> dict[tuple[str, int], tuple[int, int]] | None:
|
||||
entries = await client.listunspent(scripthash)
|
||||
return {(e["tx_hash"], e["tx_pos"]): (e["value"], e.get("height") or 0) for e in entries}
|
||||
|
||||
answers = [a for a in await self._ask_other_servers(_ask) if a is not None]
|
||||
if not self._other_endpoints() and self._allow_no_quorum:
|
||||
answers = [a for a in [await self._ask_active_server(_ask)] if a is not None]
|
||||
elif len(answers) < max(self._minimum_other_answers(), 1):
|
||||
logger.warning(
|
||||
"could not verify the pool coins at %s: %s answer(s) from the other servers, %s needed",
|
||||
scripthash,
|
||||
len(answers),
|
||||
max(self._minimum_other_answers(), 1),
|
||||
)
|
||||
return None
|
||||
if not answers:
|
||||
return None
|
||||
|
||||
result: dict[tuple[str, int], bool] = {}
|
||||
for txid, vout, amount in outpoints:
|
||||
present = absent = 0
|
||||
for answer in answers:
|
||||
listed = answer.get((txid, vout))
|
||||
if listed is None or listed[0] != amount:
|
||||
absent += 1
|
||||
elif listed[1] > 0:
|
||||
present += 1
|
||||
if present * 2 > len(answers):
|
||||
result[(txid, vout)] = True
|
||||
elif absent * 2 > len(answers):
|
||||
result[(txid, vout)] = False
|
||||
else:
|
||||
logger.warning("the servers do not agree whether %s:%s is still on the chain", txid, vout)
|
||||
return None
|
||||
return result
|
||||
|
||||
# --- Confirmations proven by merkle branch (B-83, TX-22, ELC-26) ---------------------
|
||||
|
||||
async def _header_at(self, height: int) -> str | None:
|
||||
@@ -942,15 +1113,89 @@ class ElectrumListener:
|
||||
# (rounds/scheduler.py:_wait_for_next_block) — nudge dashboards to
|
||||
# refetch instead of waiting for their next poll.
|
||||
broadcaster.publish()
|
||||
self._refresh_waiting_deposits()
|
||||
|
||||
def _refresh_waiting_deposits(self) -> None:
|
||||
"""A deposit that is waiting for confirmations (DEP-14) does not change its address'
|
||||
status when the next block arrives — the history is the same, only the tip moved — so
|
||||
no scripthash notification will ever announce that it has matured. Refresh the users
|
||||
that have one at each new block, one task per user at a time, instead of leaving it to
|
||||
the 300s sweep (B-82, DEP-09)."""
|
||||
if self.client is None:
|
||||
return
|
||||
scripthash_of = {user_id: scripthash for scripthash, user_id in self._scripthash_to_user.items()}
|
||||
for user_id, waiting in list(self._pending_deposits.items()):
|
||||
scripthash = scripthash_of.get(user_id)
|
||||
if not waiting or scripthash is None or user_id in self._block_refreshes:
|
||||
continue
|
||||
self._block_refreshes.add(user_id)
|
||||
task = asyncio.create_task(self._refresh_after_block(user_id, scripthash))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
async def _refresh_after_block(self, user_id: int, scripthash: str) -> None:
|
||||
try:
|
||||
await self.refresh_user(user_id, scripthash)
|
||||
except Exception:
|
||||
logger.exception("refreshing user_id=%s after a new block failed", user_id)
|
||||
finally:
|
||||
self._block_refreshes.discard(user_id)
|
||||
|
||||
def pending_deposit_sats(self, user_id: int) -> int:
|
||||
"""Total of the user's deposits that are confirmed but short of their threshold
|
||||
(DEP-12 (b), DEP-14); 0 when there are none or nothing has been refreshed yet."""
|
||||
return sum(entry["value"] for entry in self._pending_deposits.get(user_id, []))
|
||||
|
||||
async def is_coinbase(self, txid: str) -> bool | None:
|
||||
"""Is `txid` a coinbase transaction (DEP-03)? Read from the raw transaction, which is
|
||||
accepted only if it hashes to `txid` — so the server cannot misdescribe it — and
|
||||
whose single input has the null outpoint. None when it could not be fetched or did
|
||||
not match; the caller then simply asks again at the next refresh."""
|
||||
cached = self._coinbase_cache.get(txid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if self.client is None:
|
||||
return None
|
||||
try:
|
||||
raw = await self.client.get_transaction(txid, verbose=False)
|
||||
tx = Transaction.parse(bytes.fromhex(raw))
|
||||
except Exception:
|
||||
logger.warning("could not read transaction %s to tell whether it is a coinbase", txid)
|
||||
return None
|
||||
if tx.txid().hex() != txid:
|
||||
logger.warning("transaction %s: the server returned one that hashes to %s", txid, tx.txid().hex())
|
||||
return None
|
||||
result = len(tx.vin) == 1 and tx.vin[0].txid == bytes(32) and tx.vin[0].vout == 0xFFFFFFFF
|
||||
if len(self._coinbase_cache) >= 10_000:
|
||||
self._coinbase_cache.clear()
|
||||
self._coinbase_cache[txid] = result
|
||||
return result
|
||||
|
||||
async def _consume_scripthash(self, queue: asyncio.Queue) -> None:
|
||||
while True:
|
||||
scripthash, _status = await queue.get()
|
||||
user_id = self._scripthash_to_user.get(scripthash)
|
||||
if user_id is not None:
|
||||
await self.refresh_user(user_id, scripthash)
|
||||
try:
|
||||
await self.refresh_user(user_id, scripthash)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
# B-96 (DEP-16): one user's failed refresh (a coin already recorded, a
|
||||
# corroboration error) ends neither the session nor the other users'
|
||||
# notifications; a dropped connection ends it through wait_closed(), and
|
||||
# this user is picked up again by the next refresh or the periodic sweep.
|
||||
logger.warning("refresh of user %s failed after a notification", user_id, exc_info=True)
|
||||
|
||||
async def refresh_user(self, user_id: int, scripthash: str) -> None:
|
||||
"""One refresh of a user at a time (B-96, DEP-15): a notification, the re-subscription
|
||||
and the periodic sweep can ask together, and two of them reading the same new coin would
|
||||
both try to credit it. The lock is the refresh's own, not the bet/withdrawal one, so a
|
||||
corroboration in progress never delays a bet. Other users refresh in parallel."""
|
||||
async with self._refresh_locks.setdefault(user_id, asyncio.Lock()):
|
||||
await self._refresh_user_locked(user_id, scripthash)
|
||||
|
||||
async def _refresh_user_locked(self, user_id: int, scripthash: str) -> None:
|
||||
"""Three phases, so no DB session is held across a network call (B-18),
|
||||
same shape as _trigger_payout: read what's needed, corroborate every
|
||||
balance-moving candidate against other servers — new credits (B-59) as
|
||||
@@ -960,7 +1205,8 @@ class ElectrumListener:
|
||||
entries = await self.client.listunspent(scripthash)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
credit_candidates = await find_new_credit_candidates(session, user_id, entries)
|
||||
new_entries = await find_new_credit_candidates(session, user_id, entries)
|
||||
policy = await read_deposit_policy(session, {entry["tx_hash"] for entry in new_entries})
|
||||
# B-83 (DEP-02): candidates only — nothing is written before the quorum agrees.
|
||||
reinstate_candidates = await find_reinstate_candidates(session, user_id, entries)
|
||||
candidates = [
|
||||
@@ -968,11 +1214,20 @@ class ElectrumListener:
|
||||
for row in await find_utxos_missing_from(session, user_id, entries)
|
||||
]
|
||||
|
||||
# B-82 (DEP-03, DEP-14): which of the new outputs have reached their threshold; the
|
||||
# others are remembered as pending deposits (memory only) and shown as such.
|
||||
credit_candidates, waiting_deposits = await classify_new_deposits(
|
||||
new_entries, self.tip_height, policy, self.is_coinbase
|
||||
)
|
||||
self._pending_deposits[user_id] = waiting_deposits
|
||||
if not waiting_deposits:
|
||||
self._pending_deposits.pop(user_id, None)
|
||||
|
||||
corroborated_credits = [
|
||||
entry
|
||||
for entry in credit_candidates
|
||||
for entry, required in credit_candidates
|
||||
if await self.corroborate_utxo_credit(
|
||||
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"]
|
||||
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"], required_confirmations=required
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
@@ -16,9 +17,16 @@ def setup_logging() -> None:
|
||||
handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
|
||||
handler.setFormatter(logging.Formatter(_FORMAT))
|
||||
|
||||
# B-92 (ARC-09): also on stdout, so `docker compose logs app` shows the app's own messages
|
||||
# (scheduler, listener, reconcilers) next to uvicorn's and the startup output. Only on the
|
||||
# root logger: uvicorn's loggers already print to the console by themselves.
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setFormatter(logging.Formatter(_FORMAT))
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
root.addHandler(handler)
|
||||
root.addHandler(console)
|
||||
|
||||
# "uvicorn.error"/"uvicorn.access" have propagate=False in uvicorn's default
|
||||
# logging config, so they never reach the root handler above and need their
|
||||
|
||||
+73
-1
@@ -3,8 +3,10 @@ import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.logging_config import setup_logging
|
||||
|
||||
@@ -28,10 +30,12 @@ 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.rounds.pause import pause_by_operator
|
||||
from app.tx.broadcast import RbfBumper
|
||||
from app.tx.confirmation import ConfirmationPoller
|
||||
from app.tx.locks import UserLocks
|
||||
from app.tx.reconcile import PendingTransactionReconciler
|
||||
from app.wallet.verify import verify_master_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,6 +57,12 @@ async def lifespan(app: FastAPI):
|
||||
settings.electrum_cert_pin,
|
||||
)
|
||||
validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum
|
||||
verify_master_key() # B-89: refuses to serve with a key that did not produce the stored addresses
|
||||
if settings.start_paused: # B-90 (OPS-27): before the scheduler exists, so no round can open first
|
||||
async with AsyncSessionLocal() as session:
|
||||
await pause_by_operator(session, "START_PAUSED")
|
||||
await session.commit()
|
||||
logger.warning("START_PAUSED=true: the lottery starts paused (resume it from /admin)")
|
||||
logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints))
|
||||
|
||||
listener = ElectrumListener(
|
||||
@@ -62,6 +72,7 @@ async def lifespan(app: FastAPI):
|
||||
app.state.user_locks = UserLocks()
|
||||
|
||||
scheduler = RoundScheduler(AsyncSessionLocal, listener)
|
||||
app.state.scheduler = scheduler # B-92: /health/ready reads its heartbeat
|
||||
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client, network=listener)
|
||||
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client, network=listener)
|
||||
# Resolves in-flight transactions against the chain — the piece that lets the
|
||||
@@ -109,6 +120,28 @@ app.include_router(qr_router)
|
||||
app.include_router(rounds_router)
|
||||
|
||||
|
||||
# B-94 (API-15, API-16): no error answers with a bare-string or list `detail`; the framework's
|
||||
# own 401/404/405 and request-validation failures take the structured shape too.
|
||||
_FRAMEWORK_CODES = {401: "not_authenticated", 404: "not_found", 405: "method_not_allowed"}
|
||||
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def structured_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
if isinstance(exc.detail, dict) and "code" in exc.detail:
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers)
|
||||
code = _FRAMEWORK_CODES.get(exc.status_code, f"http_{exc.status_code}")
|
||||
detail = ApiError(code, str(exc.detail)).as_detail()
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": detail}, headers=exc.headers)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def structured_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""`input` is left out on purpose: an over-long password must not come back in the response."""
|
||||
errors = [{"loc": list(e["loc"]), "msg": e["msg"], "type": e["type"]} for e in exc.errors()]
|
||||
detail = ApiError("invalid_request", "invalid request", errors=errors).as_detail()
|
||||
return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": detail})
|
||||
|
||||
|
||||
@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
|
||||
@@ -126,6 +159,35 @@ async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# B-92 (API-46, ELC-29, ARC-61): the header age is generous on purpose — with 120 s blocks, 360 s
|
||||
# of silence happens by chance about 5% of the time (e^-3), and a healthcheck must not flap on that.
|
||||
READY_MAX_HEADER_AGE_SECONDS = 1800
|
||||
READY_MAX_SCHEDULER_HEARTBEAT_SECONDS = 30
|
||||
|
||||
|
||||
@app.get("/health/ready", include_in_schema=False)
|
||||
async def health_ready() -> JSONResponse:
|
||||
listener = getattr(app.state, "electrum_listener", None)
|
||||
scheduler = getattr(app.state, "scheduler", None)
|
||||
header_age = listener.last_header_age_seconds() if listener is not None else None
|
||||
heartbeat_age = scheduler.heartbeat_age_seconds() if scheduler is not None else None
|
||||
checks = {
|
||||
"electrum_connected": listener is not None and listener.client is not None,
|
||||
"last_header_age_seconds": header_age,
|
||||
"scheduler_heartbeat_age_seconds": heartbeat_age,
|
||||
}
|
||||
ready = (
|
||||
checks["electrum_connected"]
|
||||
and header_age is not None
|
||||
and header_age < READY_MAX_HEADER_AGE_SECONDS
|
||||
and heartbeat_age is not None
|
||||
and heartbeat_age < READY_MAX_SCHEDULER_HEARTBEAT_SECONDS
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200 if ready else 503, content={"status": "ready" if ready else "not_ready", "checks": checks}
|
||||
)
|
||||
|
||||
|
||||
_NO_STORE_HEADERS = {"Cache-Control": "no-store"}
|
||||
|
||||
|
||||
@@ -149,4 +211,14 @@ 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")
|
||||
class NoCacheStaticFiles(StaticFiles):
|
||||
"""B-94 (API-09): the browser may keep a copy but revalidates it on every use, so after an
|
||||
update it does not run an old app.js against the new API."""
|
||||
|
||||
async def get_response(self, path, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
app.mount("/", NoCacheStaticFiles(directory="app/static", html=True), name="static")
|
||||
@@ -28,3 +28,18 @@ async def pause_automatically(session: AsyncSession, reason: str) -> None:
|
||||
config.paused = True
|
||||
config.paused_reason = reason
|
||||
await write_audit_log(session, "lottery_paused", {"reason": reason})
|
||||
|
||||
|
||||
async def pause_by_operator(session: AsyncSession, note: str | None = None):
|
||||
"""The operator's pause (`POST /admin/pause`, B-90's `START_PAUSED`): never overwrites
|
||||
an automatic reason already in force — that is the one that needs a look — and the
|
||||
audit row always says `operator`. Returns the config row; the caller commits."""
|
||||
config = await get_round_config(session)
|
||||
if not (config.paused and config.paused_reason in AUTOMATIC_REASONS):
|
||||
config.paused_reason = OPERATOR_REASON
|
||||
config.paused = True
|
||||
payload = {"reason": OPERATOR_REASON}
|
||||
if note:
|
||||
payload["note"] = note
|
||||
await write_audit_log(session, "lottery_paused", payload)
|
||||
return config
|
||||
+194
-9
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -22,6 +23,7 @@ from app.rounds.draw import (
|
||||
server_seed_commitment,
|
||||
)
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.pause import pause_automatically
|
||||
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
|
||||
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
|
||||
from app.tx.versions import record_version
|
||||
@@ -46,6 +48,9 @@ _PAYOUT_RETRY_INTERVAL_SECONDS = 60
|
||||
# corroboration failure. These only make an already-long wait *observable*, they
|
||||
# never cut it short.
|
||||
_DRAW_PROGRESS_LOG_INTERVAL_SECONDS = 60
|
||||
# B-96 (TX-29, AUD-46): how long past its deadline a round may sit in `closing` on an in-flight bet before
|
||||
# `closing_stalled` is written, and how often it repeats. The wait itself has no limit (ADR-51).
|
||||
CLOSING_STALL_THRESHOLD_SECONDS = 1800
|
||||
_DRAW_STALL_THRESHOLD_SECONDS = 360 # a few multiples of PLM's 120s block time
|
||||
|
||||
|
||||
@@ -58,9 +63,16 @@ class RoundScheduler:
|
||||
def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener):
|
||||
self._session_factory = session_factory
|
||||
self._listener = listener
|
||||
# B-92 (ARC-61): monotonic time of the last tick or draw-wait iteration, read by
|
||||
# /health/ready. The draw wait runs inside a tick and lasts several blocks, so it beats too.
|
||||
self.last_heartbeat: float | None = None
|
||||
|
||||
def heartbeat_age_seconds(self) -> int | None:
|
||||
return None if self.last_heartbeat is None else int(time.monotonic() - self.last_heartbeat)
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
self.last_heartbeat = time.monotonic()
|
||||
try:
|
||||
await self._tick()
|
||||
except asyncio.CancelledError:
|
||||
@@ -142,10 +154,50 @@ class RoundScheduler:
|
||||
)
|
||||
)
|
||||
if pending_count:
|
||||
await self._note_closing_stall(round_id, deadline)
|
||||
return # wait for in-flight bets to confirm before closing; stays "closing"
|
||||
|
||||
await self._close_and_draw(round_id)
|
||||
|
||||
async def _note_closing_stall(self, round_id: int, deadline: datetime) -> None:
|
||||
"""B-96 (TX-29, AUD-46): the wait in `closing` is unbounded by design, so it is made
|
||||
visible — one `closing_stalled` row per CLOSING_STALL_THRESHOLD_SECONDS once the round has
|
||||
been past its deadline for that long. The latest row is the clock of the next one (read
|
||||
from the log, not from memory, so a restart neither repeats nor anticipates it)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
seconds_past = (now - deadline).total_seconds()
|
||||
if seconds_past < CLOSING_STALL_THRESHOLD_SECONDS:
|
||||
return
|
||||
async with self._session_factory() as session:
|
||||
last = await session.scalar(
|
||||
select(AuditLog.created_at)
|
||||
.where(AuditLog.event_type == "closing_stalled", AuditLog.round_id == round_id)
|
||||
.order_by(AuditLog.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if last is not None and now < last.replace(tzinfo=timezone.utc) + timedelta(
|
||||
seconds=CLOSING_STALL_THRESHOLD_SECONDS
|
||||
):
|
||||
return
|
||||
txids = list(
|
||||
await session.scalars(
|
||||
select(RoundParticipant.bet_txid)
|
||||
.where(
|
||||
RoundParticipant.round_id == round_id,
|
||||
RoundParticipant.status.in_(("building", "broadcast")),
|
||||
)
|
||||
.order_by(RoundParticipant.id)
|
||||
)
|
||||
)
|
||||
await write_audit_log(
|
||||
session,
|
||||
"closing_stalled",
|
||||
{"in_flight": len(txids), "txids": txids, "seconds_past_deadline": int(seconds_past)},
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
logger.warning("round %s has waited %ss past its deadline for %s in-flight bet(s)", round_id, int(seconds_past), len(txids))
|
||||
|
||||
async def _close_and_draw(self, round_id: int) -> None:
|
||||
"""Close a `closing` round once nothing is in flight: either close it (no confirmed
|
||||
participant) or commit to the draw — fix the participant list, its fingerprint and the
|
||||
@@ -365,6 +417,7 @@ class RoundScheduler:
|
||||
reported_hashes: set[str | None] = set()
|
||||
|
||||
while True:
|
||||
self.last_heartbeat = time.monotonic()
|
||||
now = datetime.now(timezone.utc)
|
||||
tip = self._listener.tip_height
|
||||
if tip >= required_tip:
|
||||
@@ -437,6 +490,8 @@ class RoundScheduler:
|
||||
either way it's due immediately.
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
if await session.scalar(select(Round.payout_blocked_at).where(Round.id == round_id)) is not None:
|
||||
return # B-82 (DAT-68): held until an operator resumes the lottery
|
||||
last_failure_at = await session.scalar(
|
||||
select(AuditLog.created_at)
|
||||
.where(AuditLog.event_type == "payout_failed", AuditLog.round_id == round_id)
|
||||
@@ -477,6 +532,11 @@ class RoundScheduler:
|
||||
logger.warning("round %s payout deferred: database busy", round_id)
|
||||
return
|
||||
round_ = await session.get(Round, round_id)
|
||||
if round_.payout_blocked_at is not None:
|
||||
# B-82 (DAT-68): held since the winner's own bet left the chain. Nothing is built
|
||||
# until an operator resumes the lottery, which clears the mark (and the next
|
||||
# attempt verifies the coins again).
|
||||
return
|
||||
already_in_flight = await session.scalar(
|
||||
select(PendingTransaction).where(
|
||||
PendingTransaction.round_id == round_id,
|
||||
@@ -497,13 +557,25 @@ class RoundScheduler:
|
||||
return
|
||||
reserved_outpoints = await _reserved_payout_outpoints(session)
|
||||
config = await get_round_config(session)
|
||||
fee_address = config.fee_address
|
||||
fee_address = round_.fee_address # B-95 (DAT-61): the round's own, fixed when it opened
|
||||
fee_rate = config.fee_rate_sat_vb
|
||||
pool_amount_sats = round_.pool_amount_sats
|
||||
round_pool_address = round_.pool_address # B-80 ("" fails in derive_round_pool_key below)
|
||||
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
|
||||
# B-82 (RND-22, RND-39): the bets the payout may spend and must first verify — the
|
||||
# round's confirmed participants, each with the output that pays the pool.
|
||||
bet_rows = [
|
||||
(p.id, p.user_id, p.bet_txid, p.bet_vout, p.bet_amount_sats)
|
||||
for p in (
|
||||
await session.scalars(
|
||||
select(RoundParticipant)
|
||||
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed")
|
||||
.order_by(RoundParticipant.draw_position)
|
||||
)
|
||||
).all()
|
||||
]
|
||||
await session.commit() # get_round_config may have created the row
|
||||
|
||||
if not fee_address:
|
||||
@@ -517,8 +589,10 @@ class RoundScheduler:
|
||||
await self._log_payout_failure(round_id, winner_user_id, "winner user not found")
|
||||
return
|
||||
|
||||
winner_sats = winner_share(pool_amount_sats)
|
||||
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
|
||||
# A round opened before the per-round pool address (B-80) pays from the shared reserve
|
||||
# address, whose coins are not the bets of one round: it keeps the old way of choosing
|
||||
# them. Every other round is verified and paid from its own bets (RND-22, RND-39).
|
||||
legacy_pool = round_pool_address == derive_pool_address()
|
||||
|
||||
# --- Phase 2: build (network read only, no DB write yet) -----------------
|
||||
try:
|
||||
@@ -528,12 +602,33 @@ class RoundScheduler:
|
||||
pool_key = derive_round_pool_key(round_id, round_pool_address)
|
||||
pool_script_obj = script.p2wpkh(pool_key.to_public())
|
||||
pool_address = pool_script_obj.address(network=PLM_MAINNET)
|
||||
entries = await client.listunspent(address_to_scripthash(pool_address))
|
||||
utxos = [
|
||||
Utxo(e["tx_hash"], e["tx_pos"], e["value"])
|
||||
for e in entries
|
||||
if e["height"] > 0 and (e["tx_hash"], e["tx_pos"]) not in reserved_outpoints
|
||||
]
|
||||
if not legacy_pool:
|
||||
# B-82 (RND-39): with the quorum, before anything is built — bets that left the
|
||||
# chain drop out and the split is computed on what is really there.
|
||||
verified = await self._verify_round_pool(
|
||||
round_id, pool_address, winner_user_id, bet_rows, pool_amount_sats
|
||||
)
|
||||
if verified is None:
|
||||
return # held, unverifiable or database busy — already recorded
|
||||
bet_rows, pool_amount_sats = verified
|
||||
winner_sats = winner_share(pool_amount_sats)
|
||||
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
|
||||
if legacy_pool:
|
||||
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
|
||||
]
|
||||
else:
|
||||
# B-82 (RND-22): exactly the verified bet outputs of this round, nothing else
|
||||
# that happens to sit at the address, and none already claimed by an unresolved
|
||||
# payout.
|
||||
utxos = [
|
||||
Utxo(txid, vout, amount)
|
||||
for _id, _user_id, txid, vout, amount in bet_rows
|
||||
if (txid, vout) not in reserved_outpoints
|
||||
]
|
||||
|
||||
built = build_payout_transaction(
|
||||
signing_key=pool_key,
|
||||
@@ -634,6 +729,96 @@ class RoundScheduler:
|
||||
|
||||
logger.info("round %s payout broadcast: txid=%s", round_id, built.txid)
|
||||
|
||||
async def _verify_round_pool(
|
||||
self,
|
||||
round_id: int,
|
||||
pool_address: str,
|
||||
winner_user_id: int | None,
|
||||
bet_rows: list[tuple[int, int, str, int, int]],
|
||||
pool_amount_sats: int | None,
|
||||
) -> tuple[list[tuple[int, int, str, int, int]], int] | None:
|
||||
"""B-82 (RND-39, DAT-67, DAT-68, ELC-27): before a payout is built, check with the quorum
|
||||
that each confirmed participant's bet output is still at the round's pool address.
|
||||
|
||||
Returns the participants that are really there with the pool amount recomputed on them
|
||||
(the same pair when nothing was missing), or None when this attempt must stop: the
|
||||
quorum could not be reached (`payout_failed`, `pool verification unavailable`), the
|
||||
database was busy, or the **winner's own bet is gone** — then the payout is held
|
||||
(`payout_blocked`, the lottery pauses) and only an operator's resume tries again.
|
||||
|
||||
Participants whose bet is gone become `reorged_out` and leave the pool, one
|
||||
`participant_reorged_out` row each, in one transaction with the rewritten
|
||||
`pool_amount_sats` (which can only go down). The winner's own row is left `confirmed`
|
||||
in the held case, precisely so that the verification repeats after the resume."""
|
||||
verdicts = await self._listener.verify_pool_outpoints(
|
||||
address_to_scripthash(pool_address), [(txid, vout, amount) for _i, _u, txid, vout, amount in bet_rows]
|
||||
)
|
||||
if verdicts is None:
|
||||
logger.error("round %s payout deferred: the pool coins could not be verified with the quorum", round_id)
|
||||
await self._log_payout_failure(round_id, winner_user_id, "pool verification unavailable")
|
||||
return None
|
||||
gone = [row for row in bet_rows if not verdicts[(row[2], row[3])]]
|
||||
if not gone:
|
||||
return bet_rows, pool_amount_sats
|
||||
|
||||
winner_gone = next((row for row in gone if row[1] == winner_user_id), None)
|
||||
async with self._session_factory() as session:
|
||||
try:
|
||||
await begin_immediate(session) # B-88
|
||||
except OperationalError:
|
||||
logger.warning("round %s payout deferred: database busy while excluding reorged-out bets", round_id)
|
||||
return None
|
||||
round_ = await session.get(Round, round_id)
|
||||
excluded = [row for row in gone if row is not winner_gone]
|
||||
for participant_id, user_id, txid, vout, amount in excluded:
|
||||
participant = await session.get(RoundParticipant, participant_id)
|
||||
if participant is None or participant.status != "confirmed":
|
||||
continue
|
||||
participant.status = "reorged_out"
|
||||
await session.flush()
|
||||
remaining = (
|
||||
await session.scalars(
|
||||
select(RoundParticipant).where(
|
||||
RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
new_pool = sum(p.bet_amount_sats for p in remaining)
|
||||
if pool_amount_sats is not None:
|
||||
new_pool = min(new_pool, pool_amount_sats) # DAT-67: only ever reduced
|
||||
round_.pool_amount_sats = new_pool
|
||||
for _participant_id, user_id, txid, vout, amount in excluded:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"participant_reorged_out",
|
||||
{"txid": txid, "vout": vout, "amount_sats": amount, "pool_amount_sats": new_pool},
|
||||
user_id=user_id,
|
||||
round_id=round_id,
|
||||
)
|
||||
if winner_gone is not None:
|
||||
_pid, user_id, txid, vout, amount = winner_gone
|
||||
round_.payout_blocked_at = datetime.now(timezone.utc)
|
||||
await write_audit_log(
|
||||
session,
|
||||
"payout_blocked",
|
||||
{"txid": txid, "vout": vout, "amount_sats": amount},
|
||||
user_id=user_id,
|
||||
round_id=round_id,
|
||||
)
|
||||
logger.error(
|
||||
"round %s payout held: the winner's bet %s:%s is no longer on the chain; lottery paused",
|
||||
round_id,
|
||||
txid,
|
||||
vout,
|
||||
)
|
||||
await pause_automatically(session, "payout_blocked") # same transaction (AUD-44)
|
||||
await session.commit()
|
||||
broadcaster.publish()
|
||||
if winner_gone is not None:
|
||||
return None
|
||||
gone_ids = {row[0] for row in gone}
|
||||
return [row for row in bet_rows if row[0] not in gone_ids], new_pool
|
||||
|
||||
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
|
||||
|
||||
@@ -174,6 +174,11 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
|
||||
duration_seconds=config.round_duration_seconds,
|
||||
cooldown_seconds=config.round_cooldown_seconds,
|
||||
draw_confirmations=config.draw_confirmations, # B-73 (DAT-61)
|
||||
# B-95 (DAT-61, RND-05): everything else that governs the round, fixed now — a later
|
||||
# edit from /admin applies to the next round, never to this one.
|
||||
bet_amount_sats=config.bet_amount_sats,
|
||||
fee_address=config.fee_address.strip(),
|
||||
max_round_pool_sats=config.max_round_pool_sats,
|
||||
# B-73 (RND-34, DAT-64): the commitment, then the secret itself encrypted — the
|
||||
# plaintext column stays NULL until the reveal.
|
||||
**_new_round_secret(),
|
||||
|
||||
+17
-1
@@ -37,6 +37,7 @@
|
||||
<span id="chain-status-label">Connessione…</span>
|
||||
</span>
|
||||
<span class="chain-block mono" id="chain-block">Blocco —</span>
|
||||
<span class="chain-block mono" id="electrum-status">Electrum —</span>
|
||||
<button class="secondary" style="margin:8px 0 8px 14px" onclick="adminLogout()">Esci</button>
|
||||
</nav>
|
||||
|
||||
@@ -85,6 +86,10 @@
|
||||
<input id="admin-draw-animation" inputmode="numeric" placeholder="es. 20">
|
||||
<label for="admin-draw-confirmations">Conferme sul blocco di estrazione (1–20, dal round successivo)</label>
|
||||
<input id="admin-draw-confirmations" inputmode="numeric" placeholder="es. 3">
|
||||
<label for="admin-deposit-confirmations">Conferme per accreditare un deposito (1–100; le uscite coinbase ne richiedono comunque 120)</label>
|
||||
<input id="admin-deposit-confirmations" inputmode="numeric" placeholder="es. 6">
|
||||
<label for="admin-max-pool">Tetto al montepremi di un round (PLM, 0 = nessun tetto, dal round successivo)</label>
|
||||
<input id="admin-max-pool" inputmode="decimal" placeholder="es. 0">
|
||||
<label for="admin-fee-rate">Fee rate di rete (sat/vB)</label>
|
||||
<input id="admin-fee-rate" inputmode="numeric" placeholder="es. 1">
|
||||
<label for="admin-rbf-timeout">Timeout prima del fee-bump RBF (secondi)</label>
|
||||
@@ -149,7 +154,17 @@
|
||||
|
||||
<div class="view" id="view-audit">
|
||||
<h2 class="section-title">Audit log</h2>
|
||||
<p class="hint">Ultimi eventi registrati dal sistema (config, bet, payout, accessi a chiavi private, ecc.).</p>
|
||||
<p class="hint">Eventi registrati dal sistema (config, bet, payout, accessi a chiavi private, ecc.), dal più recente. I filtri si combinano; "Carica altri" prosegue dall'ultimo evento mostrato.</p>
|
||||
<div class="card">
|
||||
<div class="row-between" style="flex-wrap:wrap;gap:8px">
|
||||
<input id="audit-f-type" placeholder="Tipo (es. payout_failed)" style="width:200px" />
|
||||
<input id="audit-f-round" placeholder="Round" inputmode="numeric" style="width:90px" />
|
||||
<input id="audit-f-user" placeholder="Utente" inputmode="numeric" style="width:90px" />
|
||||
<input id="audit-f-from" type="datetime-local" title="Da" />
|
||||
<input id="audit-f-to" type="datetime-local" title="A" />
|
||||
<button class="secondary" style="width:auto;margin-top:0" onclick="loadAuditLog()">Filtra</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
@@ -160,6 +175,7 @@
|
||||
<tbody id="audit-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button id="audit-more" class="secondary hidden" style="width:auto" onclick="loadAuditLog(true)">Carica altri</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+59
-8
@@ -34,8 +34,8 @@ async function callAdmin(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken };
|
||||
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
// detail is a bare string on the admin endpoints, but the shared dependencies
|
||||
// (auth) answer with the structured {code, message} form of app/api/errors.py.
|
||||
// Every error answers with the structured {code, message, params} detail (API-16);
|
||||
// the bare-string fallback only covers a proxy's own error page.
|
||||
if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText);
|
||||
return data;
|
||||
}
|
||||
@@ -76,6 +76,26 @@ async function refreshChainStatus() {
|
||||
} catch (e) {
|
||||
// leave the last-known status on screen rather than blanking it out
|
||||
}
|
||||
refreshElectrumStatus();
|
||||
}
|
||||
|
||||
// B-92 (API-47): which server the platform is talking to and how fresh its last block is.
|
||||
async function refreshElectrumStatus() {
|
||||
const el = document.getElementById('electrum-status');
|
||||
if (!adminToken) return;
|
||||
try {
|
||||
const s = await callAdmin('GET', '/admin/electrum');
|
||||
const age = s.last_header_age_seconds;
|
||||
el.textContent = s.connected
|
||||
? `Electrum ${s.endpoint}${age != null ? ' · ultimo blocco ' + age + 's fa' : ''}`
|
||||
: 'Electrum non connesso';
|
||||
el.title = s.last_rotation
|
||||
? `Ultimo cambio server: ${s.last_rotation.from} (${s.last_rotation.reason}) alle ${fmtDate(s.last_rotation.at)}`
|
||||
: '';
|
||||
if (!s.quorum_enforced) el.title += ' · quorum non imposto (ALLOW_NO_QUORUM)';
|
||||
} catch (e) {
|
||||
// keep the last text
|
||||
}
|
||||
}
|
||||
|
||||
function startChainStatusPolling() {
|
||||
@@ -90,7 +110,7 @@ function stopChainStatusPolling() {
|
||||
}
|
||||
|
||||
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit', 'bugreports'];
|
||||
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports };
|
||||
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: () => loadAuditLog(), bugreports: loadBugReports };
|
||||
let currentAdminView = 'parametri';
|
||||
|
||||
function switchView(name) {
|
||||
@@ -151,6 +171,8 @@ async function adminLoadConfig() {
|
||||
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
|
||||
document.getElementById('admin-draw-animation').value = data.draw_animation_seconds;
|
||||
document.getElementById('admin-draw-confirmations').value = data.draw_confirmations;
|
||||
document.getElementById('admin-deposit-confirmations').value = data.deposit_confirmations;
|
||||
document.getElementById('admin-max-pool').value = data.max_round_pool_sats / SATS_PER_PLM;
|
||||
document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb;
|
||||
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
|
||||
renderMaintenanceState(data.paused);
|
||||
@@ -211,6 +233,8 @@ async function adminSave() {
|
||||
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
|
||||
draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10),
|
||||
draw_confirmations: parseInt(document.getElementById('admin-draw-confirmations').value, 10),
|
||||
deposit_confirmations: parseInt(document.getElementById('admin-deposit-confirmations').value, 10),
|
||||
max_round_pool_sats: Math.round(parseFloat(document.getElementById('admin-max-pool').value || '0') * SATS_PER_PLM),
|
||||
fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10),
|
||||
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
|
||||
};
|
||||
@@ -226,7 +250,14 @@ async function adminSave() {
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const users = await callAdmin('GET', '/admin/users');
|
||||
// B-94 (API-33): the list comes in pages of up to 500, by ascending id.
|
||||
const users = [];
|
||||
for (;;) {
|
||||
const afterId = users.length ? users[users.length - 1].id : 0;
|
||||
const page = await callAdmin('GET', `/admin/users?limit=500&after_id=${afterId}`);
|
||||
users.push(...page);
|
||||
if (page.length < 500) break;
|
||||
}
|
||||
const tbody = document.getElementById('users-tbody');
|
||||
tbody.innerHTML = users.map((u) => `
|
||||
<tr>
|
||||
@@ -334,11 +365,29 @@ async function loadPending() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuditLog() {
|
||||
let auditLastId = null;
|
||||
const AUDIT_PAGE = 200;
|
||||
|
||||
async function loadAuditLog(more = false) {
|
||||
try {
|
||||
const entries = await callAdmin('GET', '/admin/audit-log');
|
||||
// B-92 (API-37): filters combine; "more" continues from the last id shown.
|
||||
const params = new URLSearchParams({ limit: String(AUDIT_PAGE) });
|
||||
const type = document.getElementById('audit-f-type').value.trim();
|
||||
if (type) params.append('event_type', type);
|
||||
for (const [id, key] of [['audit-f-round', 'round_id'], ['audit-f-user', 'user_id']]) {
|
||||
const v = document.getElementById(id).value.trim();
|
||||
if (v) params.append(key, v);
|
||||
}
|
||||
for (const [id, key] of [['audit-f-from', 'from'], ['audit-f-to', 'to']]) {
|
||||
const v = document.getElementById(id).value;
|
||||
if (v) params.append(key, new Date(v).toISOString());
|
||||
}
|
||||
if (more === true && auditLastId !== null) params.append('before_id', String(auditLastId));
|
||||
const entries = await callAdmin('GET', '/admin/audit-log?' + params);
|
||||
auditLastId = entries.length ? entries[entries.length - 1].id : auditLastId;
|
||||
document.getElementById('audit-more').classList.toggle('hidden', entries.length < AUDIT_PAGE);
|
||||
const tbody = document.getElementById('audit-tbody');
|
||||
tbody.innerHTML = entries.map((e) => `
|
||||
const rows = entries.map((e) => `
|
||||
<tr>
|
||||
<td>${e.id}</td>
|
||||
<td>${escapeHtml(e.event_type)}</td>
|
||||
@@ -347,7 +396,9 @@ async function loadAuditLog() {
|
||||
<td>${e.round_id ?? '—'}</td>
|
||||
<td>${fmtDate(e.created_at)}</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="6" class="hint">Nessun evento registrato.</td></tr>';
|
||||
`).join('');
|
||||
if (more === true) tbody.insertAdjacentHTML('beforeend', rows);
|
||||
else tbody.innerHTML = rows || '<tr><td colspan="6" class="hint">Nessun evento registrato.</td></tr>';
|
||||
} catch (e) {
|
||||
toast('Errore nel caricamento audit log: ' + e.message, 'error');
|
||||
}
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ async function call(method, path, body) {
|
||||
// A token the server no longer accepts can't be recovered from by retrying:
|
||||
// without this every poll keeps failing against a dashboard that still looks
|
||||
// logged in, toasting "session expired" forever. Drop back to the login form.
|
||||
if (res.status === 401 && data.detail?.code === 'session_expired' && token) logout();
|
||||
if (res.status === 401 && ['session_expired', 'not_authenticated'].includes(data.detail?.code) && token) logout();
|
||||
throw new Error(apiErrorMessage(data.detail) || res.statusText);
|
||||
}
|
||||
return data;
|
||||
|
||||
+70
-14
@@ -87,7 +87,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': 'Not a win this time.',
|
||||
|
||||
'deposit.balanceTitle': 'Internal balance',
|
||||
'deposit.balanceHint': 'Updated after 1 network confirmation',
|
||||
'deposit.balanceHint': 'Updated once the network has confirmed your deposit (a few blocks)',
|
||||
'deposit.refreshBtn': 'Refresh',
|
||||
'deposit.refreshAria': 'Refresh balance',
|
||||
'deposit.addressTitle': 'Deposit address',
|
||||
@@ -142,7 +142,7 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Not connected to the network, please try again shortly.',
|
||||
'error.no_round_open': 'No round is open right now, please try again shortly.',
|
||||
'error.round_closing': 'The current round is closing, please try again shortly.',
|
||||
'error.round_full': 'This round has reached its maximum of {max_participants} players — wait for the next one, it opens shortly.',
|
||||
'error.round_full': 'This round is full (player or prize-pool limit reached) — wait for the next one, it opens shortly.',
|
||||
'error.already_betting': 'You already have an active bet in the current round.',
|
||||
'error.insufficient_balance': 'Insufficient balance.',
|
||||
'error.balance_leaves_no_change': 'Your balance is too close to the bet amount: {required_extra_plm} PLM more are needed so the transaction keeps a change output and can be fee-bumped if the network is slow.',
|
||||
@@ -161,6 +161,14 @@ const TRANSLATIONS = {
|
||||
'error.invalid_amount': 'Enter an amount greater than zero.',
|
||||
'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.',
|
||||
'error.service_busy': 'The service is busy right now and nothing was sent. Please try again in a moment.',
|
||||
'error.registration_unavailable': 'Registration is temporarily unavailable. Please try again later.',
|
||||
'error.not_authenticated': 'You are not signed in. Please sign in again.',
|
||||
'error.not_found': 'Not found.',
|
||||
'error.method_not_allowed': 'This action is not allowed.',
|
||||
'error.too_many_streams': 'Too many live-update connections right now; the page will keep refreshing on its own.',
|
||||
'error.invalid_admin_token': 'The admin token is missing or wrong.',
|
||||
'error.user_not_found': 'User not found.',
|
||||
'error.bug_report_not_found': 'Bug report not found.',
|
||||
'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.',
|
||||
@@ -258,7 +266,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': 'Non hai vinto questa volta.',
|
||||
|
||||
'deposit.balanceTitle': 'Saldo interno',
|
||||
'deposit.balanceHint': 'Aggiornato dopo 1 conferma sulla rete',
|
||||
'deposit.balanceHint': 'Aggiornato quando la rete ha confermato il deposito (alcuni blocchi)',
|
||||
'deposit.refreshBtn': 'Aggiorna',
|
||||
'deposit.refreshAria': 'Aggiorna saldo',
|
||||
'deposit.addressTitle': 'Indirizzo di deposito',
|
||||
@@ -310,7 +318,7 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Nessuna connessione alla rete, riprova tra poco.',
|
||||
'error.no_round_open': 'Nessun round aperto in questo momento, riprova tra poco.',
|
||||
'error.round_closing': 'Il round corrente si sta chiudendo, riprova tra poco.',
|
||||
'error.round_full': 'Questo round ha raggiunto il massimo di {max_participants} giocatori — aspetta il prossimo, si apre tra poco.',
|
||||
'error.round_full': 'Questo round è al completo (limite di giocatori o di montepremi raggiunto) — aspetta il prossimo, si apre tra poco.',
|
||||
'error.already_betting': 'Hai già una bet attiva nel round corrente.',
|
||||
'error.insufficient_balance': 'Saldo insufficiente.',
|
||||
'error.balance_leaves_no_change': 'Il tuo saldo è troppo vicino all\'importo della giocata: servono {required_extra_plm} PLM in più perché la transazione mantenga un resto e possa essere rilanciata con fee più alta se la rete è lenta.',
|
||||
@@ -329,6 +337,14 @@ const TRANSLATIONS = {
|
||||
'error.invalid_amount': 'Inserisci un importo maggiore di zero.',
|
||||
'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.',
|
||||
'error.service_busy': 'Il servizio è occupato in questo momento e non è stato inviato nulla. Riprova tra poco.',
|
||||
'error.registration_unavailable': 'La registrazione è temporaneamente non disponibile. Riprova più tardi.',
|
||||
'error.not_authenticated': 'Non hai effettuato l\'accesso. Accedi di nuovo.',
|
||||
'error.not_found': 'Non trovato.',
|
||||
'error.method_not_allowed': 'Questa azione non è consentita.',
|
||||
'error.too_many_streams': 'Troppe connessioni di aggiornamento in tempo reale; la pagina continuerà ad aggiornarsi da sola.',
|
||||
'error.invalid_admin_token': 'Il token admin manca o è errato.',
|
||||
'error.user_not_found': 'Utente non trovato.',
|
||||
'error.bug_report_not_found': 'Segnalazione non trovata.',
|
||||
'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.',
|
||||
@@ -426,7 +442,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': 'Esta vez no has ganado.',
|
||||
|
||||
'deposit.balanceTitle': 'Saldo interno',
|
||||
'deposit.balanceHint': 'Actualizado tras 1 confirmación en la red',
|
||||
'deposit.balanceHint': 'Se actualiza cuando la red confirma tu depósito (unos bloques)',
|
||||
'deposit.refreshBtn': 'Actualizar',
|
||||
'deposit.refreshAria': 'Actualizar saldo',
|
||||
'deposit.addressTitle': 'Dirección de depósito',
|
||||
@@ -478,7 +494,7 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Sin conexión con la red, inténtalo de nuevo en un momento.',
|
||||
'error.no_round_open': 'No hay ninguna ronda abierta ahora mismo, inténtalo de nuevo en un momento.',
|
||||
'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.',
|
||||
'error.round_full': 'Esta ronda ha alcanzado su máximo de {max_participants} jugadores: espera la siguiente, se abre en breve.',
|
||||
'error.round_full': 'Esta ronda está completa (límite de jugadores o de bote alcanzado): espera la siguiente, se abre en breve.',
|
||||
'error.already_betting': 'Ya tienes una apuesta activa en la ronda actual.',
|
||||
'error.insufficient_balance': 'Saldo insuficiente.',
|
||||
'error.balance_leaves_no_change': 'Tu saldo está demasiado cerca del importe de la apuesta: hacen falta {required_extra_plm} PLM más para que la transacción conserve un cambio y pueda relanzarse con más comisión si la red va lenta.',
|
||||
@@ -497,6 +513,14 @@ const TRANSLATIONS = {
|
||||
'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.service_busy': 'El servicio está ocupado en este momento y no se ha enviado nada. Inténtalo de nuevo en un momento.',
|
||||
'error.registration_unavailable': 'El registro no está disponible temporalmente. Inténtalo de nuevo más tarde.',
|
||||
'error.not_authenticated': 'No has iniciado sesión. Inicia sesión de nuevo.',
|
||||
'error.not_found': 'No encontrado.',
|
||||
'error.method_not_allowed': 'Esta acción no está permitida.',
|
||||
'error.too_many_streams': 'Demasiadas conexiones de actualización en tiempo real; la página seguirá actualizándose sola.',
|
||||
'error.invalid_admin_token': 'Falta el token de administrador o es incorrecto.',
|
||||
'error.user_not_found': 'Usuario no encontrado.',
|
||||
'error.bug_report_not_found': 'Informe de error no encontrado.',
|
||||
'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.',
|
||||
@@ -594,7 +618,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': "Pas de gain cette fois-ci.",
|
||||
|
||||
'deposit.balanceTitle': 'Solde interne',
|
||||
'deposit.balanceHint': 'Mis à jour après 1 confirmation sur le réseau',
|
||||
'deposit.balanceHint': 'Mis à jour une fois le dépôt confirmé par le réseau (quelques blocs)',
|
||||
'deposit.refreshBtn': 'Actualiser',
|
||||
'deposit.refreshAria': 'Actualiser le solde',
|
||||
'deposit.addressTitle': 'Adresse de dépôt',
|
||||
@@ -646,7 +670,7 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Pas de connexion au réseau, réessayez dans un instant.',
|
||||
'error.no_round_open': "Aucun round n'est ouvert pour le moment, réessayez dans un instant.",
|
||||
'error.round_closing': 'Le round en cours est en train de se fermer, réessayez dans un instant.',
|
||||
'error.round_full': 'Ce tour a atteint son maximum de {max_participants} joueurs — attendez le prochain, il ouvre dans un instant.',
|
||||
'error.round_full': 'Ce tour est complet (limite de joueurs ou de cagnotte atteinte) — attendez le prochain, il ouvre 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_leaves_no_change': 'Votre solde est trop proche du montant de la mise : il faut {required_extra_plm} PLM de plus pour que la transaction garde une monnaie de rendu et puisse être relancée avec des frais plus élevés si le réseau est lent.',
|
||||
@@ -665,6 +689,14 @@ const TRANSLATIONS = {
|
||||
'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.service_busy': 'Le service est occupé pour le moment et rien n\'a été envoyé. Veuillez réessayer dans un instant.',
|
||||
'error.registration_unavailable': 'L\'inscription est temporairement indisponible. Veuillez réessayer plus tard.',
|
||||
'error.not_authenticated': 'Vous n\'êtes pas connecté. Veuillez vous reconnecter.',
|
||||
'error.not_found': 'Introuvable.',
|
||||
'error.method_not_allowed': 'Cette action n\'est pas autorisée.',
|
||||
'error.too_many_streams': 'Trop de connexions de mise à jour en direct ; la page continuera de s\'actualiser seule.',
|
||||
'error.invalid_admin_token': 'Le jeton d\'administration est absent ou incorrect.',
|
||||
'error.user_not_found': 'Utilisateur introuvable.',
|
||||
'error.bug_report_not_found': 'Signalement introuvable.',
|
||||
'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.",
|
||||
@@ -762,7 +794,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': 'Diesmal kein Gewinn.',
|
||||
|
||||
'deposit.balanceTitle': 'Internes Guthaben',
|
||||
'deposit.balanceHint': 'Aktualisiert nach 1 Netzwerkbestätigung',
|
||||
'deposit.balanceHint': 'Aktualisiert, sobald das Netzwerk die Einzahlung bestätigt hat (einige Blöcke)',
|
||||
'deposit.refreshBtn': 'Aktualisieren',
|
||||
'deposit.refreshAria': 'Guthaben aktualisieren',
|
||||
'deposit.addressTitle': 'Einzahlungsadresse',
|
||||
@@ -814,7 +846,7 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Keine Verbindung zum Netzwerk, bitte versuche es gleich erneut.',
|
||||
'error.no_round_open': 'Derzeit ist keine Runde offen, bitte versuche es gleich erneut.',
|
||||
'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.',
|
||||
'error.round_full': 'Diese Runde hat ihr Maximum von {max_participants} Spielern erreicht — warten Sie auf die nächste, sie beginnt in Kürze.',
|
||||
'error.round_full': 'Diese Runde ist voll (Spieler- oder Preispool-Limit erreicht) — warten Sie auf die nächste, sie beginnt in Kürze.',
|
||||
'error.already_betting': 'Du hast bereits eine aktive Wette in der laufenden Runde.',
|
||||
'error.insufficient_balance': 'Nicht genügend Guthaben.',
|
||||
'error.balance_leaves_no_change': 'Ihr Guthaben liegt zu nah am Einsatzbetrag: Es werden {required_extra_plm} PLM mehr benötigt, damit die Transaktion einen Wechselgeld-Ausgang behält und bei langsamem Netz mit höherer Gebühr neu gesendet werden kann.',
|
||||
@@ -833,6 +865,14 @@ const TRANSLATIONS = {
|
||||
'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.service_busy': 'Der Dienst ist gerade ausgelastet und es wurde nichts gesendet. Bitte versuche es gleich noch einmal.',
|
||||
'error.registration_unavailable': 'Die Registrierung ist vorübergehend nicht verfügbar. Bitte versuche es später noch einmal.',
|
||||
'error.not_authenticated': 'Du bist nicht angemeldet. Bitte melde dich erneut an.',
|
||||
'error.not_found': 'Nicht gefunden.',
|
||||
'error.method_not_allowed': 'Diese Aktion ist nicht erlaubt.',
|
||||
'error.too_many_streams': 'Zu viele Live-Update-Verbindungen; die Seite aktualisiert sich weiterhin selbst.',
|
||||
'error.invalid_admin_token': 'Das Admin-Token fehlt oder ist falsch.',
|
||||
'error.user_not_found': 'Benutzer nicht gefunden.',
|
||||
'error.bug_report_not_found': 'Fehlermeldung nicht gefunden.',
|
||||
'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.',
|
||||
@@ -930,7 +970,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': 'На этот раз без выигрыша.',
|
||||
|
||||
'deposit.balanceTitle': 'Внутренний баланс',
|
||||
'deposit.balanceHint': 'Обновляется после 1 подтверждения в сети',
|
||||
'deposit.balanceHint': 'Обновляется, когда сеть подтвердит депозит (несколько блоков)',
|
||||
'deposit.refreshBtn': 'Обновить',
|
||||
'deposit.refreshAria': 'Обновить баланс',
|
||||
'deposit.addressTitle': 'Адрес для депозита',
|
||||
@@ -982,7 +1022,7 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Нет соединения с сетью, повторите попытку чуть позже.',
|
||||
'error.no_round_open': 'Сейчас нет открытого раунда, повторите попытку чуть позже.',
|
||||
'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.',
|
||||
'error.round_full': 'В этом раунде достигнут максимум участников ({max_participants}) — дождитесь следующего, он начнётся совсем скоро.',
|
||||
'error.round_full': 'Этот раунд заполнен (достигнут лимит игроков или призового фонда) — дождитесь следующего, он начнётся совсем скоро.',
|
||||
'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.',
|
||||
'error.insufficient_balance': 'Недостаточно средств.',
|
||||
'error.balance_leaves_no_change': 'Ваш баланс слишком близок к сумме ставки: нужно ещё {required_extra_plm} PLM, чтобы в транзакции остался выход сдачи и её можно было переотправить с более высокой комиссией, если сеть работает медленно.',
|
||||
@@ -1001,6 +1041,14 @@ const TRANSLATIONS = {
|
||||
'error.invalid_amount': 'Введите сумму больше нуля.',
|
||||
'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.',
|
||||
'error.service_busy': 'Сервис сейчас занят, ничего не было отправлено. Попробуйте ещё раз через минуту.',
|
||||
'error.registration_unavailable': 'Регистрация временно недоступна. Попробуйте позже.',
|
||||
'error.not_authenticated': 'Вы не вошли в систему. Войдите снова.',
|
||||
'error.not_found': 'Не найдено.',
|
||||
'error.method_not_allowed': 'Это действие не разрешено.',
|
||||
'error.too_many_streams': 'Слишком много соединений для живых обновлений; страница продолжит обновляться сама.',
|
||||
'error.invalid_admin_token': 'Токен администратора отсутствует или неверен.',
|
||||
'error.user_not_found': 'Пользователь не найден.',
|
||||
'error.bug_report_not_found': 'Сообщение об ошибке не найдено.',
|
||||
'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.',
|
||||
'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.',
|
||||
'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.',
|
||||
@@ -1098,7 +1146,7 @@ const TRANSLATIONS = {
|
||||
'result.lose': '这次没有中奖。',
|
||||
|
||||
'deposit.balanceTitle': '内部余额',
|
||||
'deposit.balanceHint': '在网络确认 1 次后更新',
|
||||
'deposit.balanceHint': '网络确认存款后更新(需要数个区块)',
|
||||
'deposit.refreshBtn': '刷新',
|
||||
'deposit.refreshAria': '刷新余额',
|
||||
'deposit.addressTitle': '存款地址',
|
||||
@@ -1150,7 +1198,7 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': '未连接到网络,请稍后重试。',
|
||||
'error.no_round_open': '当前没有开放的回合,请稍后重试。',
|
||||
'error.round_closing': '当前回合正在结束,请稍后重试。',
|
||||
'error.round_full': '本轮已达到 {max_participants} 名玩家的上限 —— 请等待下一轮,很快就会开始。',
|
||||
'error.round_full': '本轮已满(达到玩家人数或奖池上限)—— 请等待下一轮,很快就会开始。',
|
||||
'error.already_betting': '你在当前回合已有一笔有效下注。',
|
||||
'error.insufficient_balance': '余额不足。',
|
||||
'error.balance_leaves_no_change': '您的余额与投注金额过于接近:还需要 {required_extra_plm} PLM,交易才能保留找零输出,并在网络拥堵时提高手续费重新广播。',
|
||||
@@ -1169,6 +1217,14 @@ const TRANSLATIONS = {
|
||||
'error.invalid_amount': '请输入大于零的金额。',
|
||||
'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。',
|
||||
'error.service_busy': '服务当前繁忙,没有发送任何内容,请稍后重试。',
|
||||
'error.registration_unavailable': '注册暂时不可用,请稍后再试。',
|
||||
'error.not_authenticated': '您尚未登录,请重新登录。',
|
||||
'error.not_found': '未找到。',
|
||||
'error.method_not_allowed': '不允许此操作。',
|
||||
'error.too_many_streams': '实时更新连接过多;页面将自行继续刷新。',
|
||||
'error.invalid_admin_token': '管理员令牌缺失或错误。',
|
||||
'error.user_not_found': '未找到用户。',
|
||||
'error.bug_report_not_found': '未找到错误报告。',
|
||||
'error.amount_below_dust_limit': '金额过小,无法发送。',
|
||||
'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。',
|
||||
'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。',
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
<div class="dash-panel active" id="panel-deposit">
|
||||
<div class="card">
|
||||
<h2 data-i18n="deposit.balanceTitle">Saldo interno</h2>
|
||||
<p class="hint" data-i18n="deposit.balanceHint">Aggiornato dopo 1 conferma sulla rete</p>
|
||||
<p class="hint" data-i18n="deposit.balanceHint">Aggiornato quando la rete ha confermato il deposito (alcuni blocchi)</p>
|
||||
<div class="row-between">
|
||||
<div><span class="balance-value mono" id="dash-balance">—</span> <span class="balance-unit">PLM</span></div>
|
||||
<button class="secondary" onclick="refreshMe()" id="refresh-btn" data-i18n-aria-label="deposit.refreshAria" aria-label="Aggiorna saldo">
|
||||
|
||||
+93
-17
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -9,8 +10,10 @@ from embit.finalizer import finalize_psbt
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import (
|
||||
AuditLog,
|
||||
PendingTransaction,
|
||||
PendingTransactionTxid,
|
||||
Round,
|
||||
@@ -25,7 +28,7 @@ from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_
|
||||
from app.tx.versions import record_version
|
||||
from app.wallet.hd import derive_pool_address, derive_round_pool_key, derive_user_key
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS, MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,8 +42,17 @@ _FEE_RATE_INCREMENT = 1 # how much pending.fee_rate_sat_vb's *target* rises by
|
||||
_INCREMENTAL_RELAY_FEE_RATE_SAT_VB = 1
|
||||
|
||||
|
||||
# B-87 (TX-24, AUD-40): why a bump could not be made — the `reason` of the `rbf_bump_failed`
|
||||
# audit row the bumper writes.
|
||||
REASON_CHANGE_TOO_SMALL = "change_too_small"
|
||||
REASON_MAX_FEE_RATE = "max_fee_rate"
|
||||
REASON_REPLACEMENT_REJECTED = "replacement_rejected"
|
||||
|
||||
|
||||
class RbfError(Exception):
|
||||
pass
|
||||
def __init__(self, message: str, reason: str = REASON_CHANGE_TOO_SMALL):
|
||||
super().__init__(message)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool:
|
||||
@@ -145,13 +157,16 @@ async def bump_fee(
|
||||
matched is a conflict: the row is left alone, the candidate stays recorded, and
|
||||
the reconciler resolves it.
|
||||
|
||||
Only handles the common case: exactly one change output paying back to the
|
||||
tx's own sender address, large enough to absorb the increase. If there's no
|
||||
such output (e.g. an exact-amount bet with no change), this raises RbfError —
|
||||
bumping such a tx would require selecting additional inputs, which isn't
|
||||
implemented for the MVP; it needs manual operator intervention. Also raises
|
||||
RbfError, rather than bumping, once the row is already at MAX_FEE_RATE_SAT_VB
|
||||
(B-32), and when the network explicitly refuses the replacement (TX-24).
|
||||
Only handles one shape: exactly one output to take the increase from — the sender's
|
||||
own change, or a payout's bump reserve (B-79) — and when what would be left of it is
|
||||
below the dust limit the output is dropped and its whole value goes to the fee, which
|
||||
makes that replacement the last possible one (B-87, TX-11). With no such output, or
|
||||
when even its whole value is below BIP125's minimum increase, this raises RbfError —
|
||||
adding inputs would not help the case that used to hurt (a transaction that already
|
||||
spends everything the sender has). Also raises RbfError, rather than bumping, once the
|
||||
row is already at MAX_FEE_RATE_SAT_VB (B-32), and when the network explicitly refuses
|
||||
the replacement (TX-24). Each carries a `reason` for the `rbf_bump_failed` audit row
|
||||
(RbfBumper._record_failure).
|
||||
"""
|
||||
# --- Phase 1: read what's needed, close the session before any network call ---
|
||||
async with session_factory() as session:
|
||||
@@ -162,7 +177,8 @@ async def bump_fee(
|
||||
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"
|
||||
f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further",
|
||||
REASON_MAX_FEE_RATE,
|
||||
)
|
||||
|
||||
kind = pending.kind
|
||||
@@ -193,12 +209,28 @@ async def bump_fee(
|
||||
fee_delta = max(target_fee - old_fee, min_valid_delta)
|
||||
|
||||
change_index = _find_reducible_output(old_tx, kind, 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")
|
||||
if change_index is None:
|
||||
raise RbfError(f"pending_transaction {pending_id}: there is no output to take a fee bump from")
|
||||
reducible_value = old_tx.vout[change_index].value
|
||||
if reducible_value < min_valid_delta:
|
||||
# Not even giving the whole output to the fee reaches BIP125's minimum increase.
|
||||
raise RbfError(
|
||||
f"pending_transaction {pending_id}: its change ({reducible_value} sat) cannot even cover the "
|
||||
f"minimum relay increase ({min_valid_delta} sat)"
|
||||
)
|
||||
|
||||
new_vout = list(old_tx.vout)
|
||||
bumped_change = new_vout[change_index].value - fee_delta
|
||||
new_vout[change_index] = TransactionOutput(bumped_change, new_vout[change_index].script_pubkey)
|
||||
bumped_change = reducible_value - fee_delta
|
||||
if bumped_change >= DUST_LIMIT_SATS:
|
||||
new_vout[change_index] = TransactionOutput(bumped_change, new_vout[change_index].script_pubkey)
|
||||
else:
|
||||
# B-87 (TX-11): what would be left is below the dust limit, an output no node relays — a
|
||||
# 294 sat change shrinks to ~153 after the very first minimum bump, and every replacement
|
||||
# kept by it was refused, every 30 seconds, for ever. Drop the output instead and give its
|
||||
# whole value to the fee: the replacement is valid, and it is the last one this
|
||||
# transaction can have, since nothing is left to reduce.
|
||||
del new_vout[change_index]
|
||||
fee_delta = reducible_value
|
||||
|
||||
new_vin = [TransactionInput(v.txid, v.vout, sequence=RBF_SEQUENCE) for v in old_tx.vin]
|
||||
new_tx = Transaction(vin=new_vin, vout=new_vout)
|
||||
@@ -232,7 +264,10 @@ async def bump_fee(
|
||||
# The network explicitly refused the replacement and no server knows it: the row
|
||||
# is unchanged and keeps the version that is on the network (TX-24). The
|
||||
# candidate stays recorded — it was never relayed, so nobody will confirm it.
|
||||
raise RbfError(f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}")
|
||||
raise RbfError(
|
||||
f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}",
|
||||
REASON_REPLACEMENT_REJECTED,
|
||||
)
|
||||
# ACCEPTED, or UNCERTAIN: the replacement may well be on the network, so the row
|
||||
# follows it (TX-12); the old version stays recorded and is still recognised should
|
||||
# it be the one that confirms.
|
||||
@@ -259,7 +294,7 @@ async def bump_fee(
|
||||
replaced_by_txid=old_txid, # points backwards: what current_txid replaced
|
||||
current_txid=new_txid,
|
||||
raw_tx_hex=raw_hex,
|
||||
fee_rate_sat_vb=(old_fee + fee_delta) // vsize,
|
||||
fee_rate_sat_vb=(old_fee + fee_delta) // estimate_vsize(len(new_vin), len(new_vout)),
|
||||
attempt_count=PendingTransaction.attempt_count + 1,
|
||||
last_broadcast_at=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -367,6 +402,46 @@ class RbfBumper:
|
||||
logger.exception("RBF bump tick failed")
|
||||
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
|
||||
|
||||
async def _record_failure(self, pending_id: int, exc: RbfError) -> None:
|
||||
"""B-87 (TX-24, AUD-40, AUD-09): a bump that cannot be made is visible from /admin as one
|
||||
`rbf_bump_failed` row — in a transaction of its own, the row stays `pending` — and only
|
||||
**once per (current txid, reason)**: the same failure on the same transaction every 30
|
||||
seconds goes to the log alone, while a new txid (after a successful bump) or another
|
||||
reason writes a new row."""
|
||||
try:
|
||||
async with self._session_factory() as session:
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
if pending is None:
|
||||
return
|
||||
already = (
|
||||
await session.scalars(
|
||||
select(AuditLog.payload_json).where(
|
||||
AuditLog.event_type == "rbf_bump_failed",
|
||||
AuditLog.payload_json.contains(f'"txid": "{pending.current_txid}"'),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
for payload_json in already:
|
||||
payload = json.loads(payload_json)
|
||||
if payload.get("txid") == pending.current_txid and payload.get("reason") == exc.reason:
|
||||
return
|
||||
await write_audit_log(
|
||||
session,
|
||||
"rbf_bump_failed",
|
||||
{
|
||||
"pending_transaction_id": pending.id,
|
||||
"kind": pending.kind,
|
||||
"txid": pending.current_txid,
|
||||
"reason": exc.reason,
|
||||
"message": str(exc)[:200],
|
||||
},
|
||||
user_id=pending.user_id,
|
||||
round_id=pending.round_id,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("could not record the failed bump of pending_transaction %s", pending_id)
|
||||
|
||||
async def _tick(self, client: ElectrumClient) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
async with self._session_factory() as session:
|
||||
@@ -379,7 +454,8 @@ class RbfBumper:
|
||||
for pending_id in due_ids:
|
||||
try:
|
||||
await bump_fee(self._session_factory, client, pending_id, network=self._network)
|
||||
except RbfError:
|
||||
except RbfError as exc:
|
||||
logger.exception("could not bump pending_transaction %s", pending_id)
|
||||
await self._record_failure(pending_id, exc)
|
||||
except Exception:
|
||||
logger.exception("unexpected error bumping pending_transaction %s", pending_id)
|
||||
+40
-2
@@ -214,8 +214,9 @@ async def reconcile_once(
|
||||
await _abandon(session, row, payload)
|
||||
changed = True
|
||||
await session.commit()
|
||||
resolved += changed
|
||||
broadcaster.publish()
|
||||
if changed: # B-96 (TX-27): a row that was only examined moved nothing to refetch
|
||||
resolved += 1
|
||||
broadcaster.publish()
|
||||
|
||||
return resolved
|
||||
|
||||
@@ -360,6 +361,9 @@ 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"
|
||||
# B-96 (TX-16, AUD-30): besides pending_tx_recovered, the event the successful send would have
|
||||
# written — a recovered bet, withdrawal or payout is otherwise absent from the audit trail.
|
||||
sent_event: tuple[str, dict, int | None] | None = None
|
||||
if row.kind == "bet":
|
||||
participant = await session.scalar(
|
||||
select(RoundParticipant).where(
|
||||
@@ -368,10 +372,39 @@ async def _promote(session: AsyncSession, row: PendingTransaction) -> None:
|
||||
)
|
||||
if participant is not None and participant.status == "building":
|
||||
participant.status = "broadcast"
|
||||
sent_event = (
|
||||
"bet_placed",
|
||||
{"txid": row.current_txid, "amount_sats": participant.bet_amount_sats},
|
||||
row.user_id,
|
||||
)
|
||||
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"
|
||||
sent_event = (
|
||||
"withdrawal_sent",
|
||||
{
|
||||
"txid": row.current_txid,
|
||||
"amount_sent_sats": withdrawal.amount_sent_sats,
|
||||
"external_address": withdrawal.external_address,
|
||||
},
|
||||
row.user_id,
|
||||
)
|
||||
elif row.kind == "payout" and row.round_id is not None:
|
||||
round_ = await session.get(Round, row.round_id)
|
||||
if round_ is not None:
|
||||
sent_event = (
|
||||
"payout_sent",
|
||||
{
|
||||
"txid": row.current_txid,
|
||||
"winner_sats": round_.winner_amount_sats,
|
||||
"commission_sats": round_.fee_amount_sats,
|
||||
},
|
||||
round_.winner_user_id,
|
||||
)
|
||||
if sent_event is not None:
|
||||
event, payload, event_user_id = sent_event
|
||||
await write_audit_log(session, event, payload, user_id=event_user_id, round_id=row.round_id)
|
||||
await write_audit_log(
|
||||
session,
|
||||
"pending_tx_recovered",
|
||||
@@ -418,6 +451,11 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str)
|
||||
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
|
||||
# B-96 (TX-18, DAT-22): the amounts belong to the attempt that no longer exists; the
|
||||
# next attempt writes its own.
|
||||
round_.winner_amount_sats = None
|
||||
round_.fee_amount_sats = None
|
||||
round_.bump_reserve_sats = None
|
||||
logger.error(
|
||||
"round %s payout tx %s vanished — round needs operator attention", round_.id, row.current_txid
|
||||
)
|
||||
|
||||
@@ -18,7 +18,9 @@ async def recompute_balance(session: AsyncSession, user_id: int) -> int:
|
||||
return user.cached_balance_sats
|
||||
|
||||
|
||||
async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[int, bool]:
|
||||
async def compute_pending_balance(
|
||||
session: AsyncSession, user: User, pending_deposit_sats: int = 0
|
||||
) -> tuple[int, bool]:
|
||||
"""Balance including the user's own change still in flight.
|
||||
|
||||
Placing a bet or a withdrawal spends whatever UTXOs cover the amount — often
|
||||
@@ -47,6 +49,11 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
|
||||
the same sats are already inside cached_balance_sats — double-counting the
|
||||
change unless excluded below.
|
||||
|
||||
B-82 (DEP-12 (b), DEP-14): `pending_deposit_sats` is the total of the user's deposits
|
||||
that are confirmed but still short of their threshold (the listener keeps them, in
|
||||
memory); they are added to what is shown, and make `has_pending` true, exactly like
|
||||
change in flight — without ever being spendable.
|
||||
|
||||
Returns (pending_inclusive_balance_sats, has_pending) — has_pending tells the
|
||||
caller whether this differs from the confirmed-only balance at all.
|
||||
"""
|
||||
@@ -79,4 +86,4 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
|
||||
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
|
||||
pending_change_sats += out.value
|
||||
|
||||
return user.cached_balance_sats + pending_change_sats, bool(pending)
|
||||
return user.cached_balance_sats + pending_change_sats + pending_deposit_sats, bool(pending) or pending_deposit_sats > 0
|
||||
+2
-3
@@ -5,7 +5,7 @@ from embit.bip32 import HDKey
|
||||
from embit.ec import PrivateKey
|
||||
|
||||
from app.config import settings
|
||||
from app.wallet.keystore import decrypt_xprv, encrypt_xprv
|
||||
from app.wallet.keystore import decrypt_xprv, encrypt_xprv, write_secret_file
|
||||
from app.wallet.plm_network import ACCOUNT_PATH, PLM_MAINNET
|
||||
|
||||
_account_key: HDKey | None = None
|
||||
@@ -17,8 +17,7 @@ def generate_master_key(overwrite: bool = False) -> None:
|
||||
if os.path.exists(settings.master_key_path) and not overwrite:
|
||||
raise FileExistsError(f"{settings.master_key_path} already exists")
|
||||
root = HDKey.from_seed(os.urandom(32), version=PLM_MAINNET["xprv"])
|
||||
with open(settings.master_key_path, "wb") as f:
|
||||
f.write(encrypt_xprv(root.to_base58(version=PLM_MAINNET["xprv"])))
|
||||
write_secret_file(settings.master_key_path, encrypt_xprv(root.to_base58(version=PLM_MAINNET["xprv"])))
|
||||
|
||||
|
||||
def _load_account_key() -> HDKey:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.config import settings
|
||||
@@ -20,3 +22,12 @@ def encrypt_round_seed(server_seed_hex: str) -> str:
|
||||
|
||||
def decrypt_round_seed(token: str) -> str:
|
||||
return Fernet(settings.xprv_encryption_key).decrypt(token.encode()).decode()
|
||||
|
||||
|
||||
def write_secret_file(path: str, data: bytes) -> None:
|
||||
"""B-89 (SEC-50): created `0600` from the start (never through a wider mode) and
|
||||
rewritten with the same mode, so a key file is not readable by other users."""
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
os.fchmod(f.fileno(), 0o600)
|
||||
f.write(data)
|
||||
@@ -26,3 +26,7 @@ PLM_MAINNET = {
|
||||
|
||||
BIP44_COIN_TYPE = 746
|
||||
ACCOUNT_PATH = f"m/84h/{BIP44_COIN_TYPE}h/0h"
|
||||
|
||||
# PLM shares Bitcoin's genesis block (palladiumcore chainparams.cpp) and its powLimit, so a
|
||||
# Bitcoin Electrum server would pass every header check; the handshake asks for this instead.
|
||||
GENESIS_HASH = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
|
||||
+23
-13
@@ -30,8 +30,10 @@ DUST_LIMIT_SATS = 294
|
||||
# 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
|
||||
# eating further and further into the sender's change with no limit. B-100: half of the node's own
|
||||
# `sendrawtransaction` maxfeerate (0.1 PLM/kvB = 10 000 sat/vB, palladiumcore rpc/rawtransaction.cpp):
|
||||
# vsize is estimated upwards, so a rate sitting exactly on the node's limit would be refused.
|
||||
MAX_FEE_RATE_SAT_VB = 5_000
|
||||
|
||||
# Ceiling on how many UTXOs one *user* transaction (bet, withdrawal) may spend (B-48).
|
||||
# Every extra input costs ~68 vbytes of fee, and that fee comes out of the amount being
|
||||
@@ -125,7 +127,7 @@ def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
|
||||
|
||||
|
||||
def select_utxos(
|
||||
utxos: list[Utxo], target_sats: int, max_inputs: int = MAX_TX_INPUTS
|
||||
utxos: list[Utxo], target_sats: int, max_inputs: int = MAX_TX_INPUTS, min_change_sats: int = 0
|
||||
) -> 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
|
||||
@@ -140,7 +142,13 @@ def select_utxos(
|
||||
want different ones (B-52): MAX_TX_INPUTS protects a user from a fee that would
|
||||
eat into their own bet/withdrawal, while the payout drains a pool holding one
|
||||
UTXO per bet and needs MAX_PAYOUT_TX_INPUTS to be able to pay a full round at
|
||||
all."""
|
||||
all.
|
||||
|
||||
B-98 (BET-24): with `min_change_sats` selection goes on until the total covers
|
||||
`target + min_change_sats`, so a second coin is taken when the first covers the
|
||||
target exactly. Only when every allowed coin together cannot leave that change is
|
||||
the shorter selection returned (any total >= target), and the caller then refuses
|
||||
the bet or reduces the withdrawal as before."""
|
||||
# B-75: a UTXO below the dust limit is never offered, even if one is already on
|
||||
# record: it costs more to spend than it is worth.
|
||||
ordered = sorted(
|
||||
@@ -149,16 +157,18 @@ def select_utxos(
|
||||
selected: list[Utxo] = []
|
||||
total = 0
|
||||
for utxo in ordered:
|
||||
if len(selected) == max_inputs:
|
||||
raise InsufficientFundsError(
|
||||
f"balance too fragmented: more than {max_inputs} inputs would be needed",
|
||||
code="too_many_inputs",
|
||||
max_inputs=max_inputs,
|
||||
)
|
||||
if total >= target_sats + min_change_sats or len(selected) == max_inputs:
|
||||
break
|
||||
selected.append(utxo)
|
||||
total += utxo.amount_sats
|
||||
if total >= target_sats:
|
||||
return selected, total
|
||||
if total >= target_sats:
|
||||
return selected, total
|
||||
if len(selected) == max_inputs and len(ordered) > max_inputs:
|
||||
raise InsufficientFundsError(
|
||||
f"balance too fragmented: more than {max_inputs} inputs would be needed",
|
||||
code="too_many_inputs",
|
||||
max_inputs=max_inputs,
|
||||
)
|
||||
raise InsufficientFundsError("not enough confirmed balance to cover amount")
|
||||
|
||||
|
||||
@@ -202,7 +212,7 @@ def build_signed_transaction(
|
||||
never exactly equal the bet" is a documented invariant of the PLAY phase. The
|
||||
player needs a little more than the bet amount, which is what the error says.
|
||||
"""
|
||||
selected, total_in = select_utxos(utxos, amount_sats)
|
||||
selected, total_in = select_utxos(utxos, amount_sats, min_change_sats=DUST_LIMIT_SATS)
|
||||
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
|
||||
|
||||
change = total_in - amount_sats
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""B-89 (SEC-49, SEC-50): is the master key the one that produced what the database holds?
|
||||
|
||||
A different xprv, or a wrong encryption key, used to surface only at the first
|
||||
registration or payout — and a different xprv never did: it quietly moved the pool
|
||||
address. The check compares a fixed sample of stored addresses with what the key derives,
|
||||
reading the database with plain `sqlite3` (read-only), so the startup check and the
|
||||
key scripts share it without an event loop."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import NamedTuple
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from embit import script
|
||||
from embit.bip32 import HDKey
|
||||
|
||||
from app.config import ConfigError, settings
|
||||
from app.wallet.plm_network import ACCOUNT_PATH, PLM_MAINNET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SAMPLE_SIZE = 10
|
||||
|
||||
|
||||
class Sample(NamedTuple):
|
||||
users: list[tuple[int, str]] # (derivation_index, address)
|
||||
rounds: list[tuple[int, str]] # (id, pool_address) of the last rounds with one
|
||||
seeds: list[tuple[int, str, str]] # (id, server_seed_enc, server_seed_hash), unrevealed
|
||||
|
||||
|
||||
def database_file(url: str | None = None) -> str | None:
|
||||
"""The SQLite file `DATABASE_URL` points at, or None for anything else."""
|
||||
url = url or settings.database_url
|
||||
prefix = "sqlite+aiosqlite:///"
|
||||
return url[len(prefix):] if url.startswith(prefix) else None
|
||||
|
||||
|
||||
def load_sample(path: str | None = None) -> Sample:
|
||||
"""The lowest and highest `_SAMPLE_SIZE` users, the last rounds that have a pool
|
||||
address and every round whose secret is still unrevealed. A missing file or missing
|
||||
tables (a fresh instance) is an empty sample: there is nothing to compare."""
|
||||
path = path or database_file()
|
||||
empty = Sample([], [], [])
|
||||
if path is None:
|
||||
return empty
|
||||
try:
|
||||
db = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
except sqlite3.Error:
|
||||
return empty
|
||||
try:
|
||||
users = {
|
||||
index: address
|
||||
for order in ("ASC", "DESC")
|
||||
for index, address in db.execute(
|
||||
f"SELECT derivation_index, address FROM users ORDER BY derivation_index {order} LIMIT ?",
|
||||
(_SAMPLE_SIZE,),
|
||||
)
|
||||
}
|
||||
rounds = db.execute(
|
||||
"SELECT id, pool_address FROM rounds WHERE pool_address != '' ORDER BY id DESC LIMIT ?",
|
||||
(_SAMPLE_SIZE,),
|
||||
).fetchall()
|
||||
seeds = db.execute(
|
||||
"SELECT id, server_seed_enc, server_seed_hash FROM rounds "
|
||||
"WHERE server_seed_enc IS NOT NULL AND server_seed IS NULL"
|
||||
).fetchall()
|
||||
return Sample(sorted(users.items()), rounds, seeds)
|
||||
except sqlite3.OperationalError: # no such table
|
||||
return empty
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def account_key(xprv_base58: str) -> HDKey:
|
||||
return HDKey.from_base58(xprv_base58).derive(ACCOUNT_PATH)
|
||||
|
||||
|
||||
def _address(account: HDKey, path: str) -> str:
|
||||
return script.p2wpkh(account.derive(path).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
|
||||
def sample_problems(account: HDKey, sample: Sample, encryption_key: str | None = None) -> list[str]:
|
||||
"""One line per check that failed; never any key material. With `encryption_key`
|
||||
the unrevealed round secrets are checked as well (they depend on it, not on the xprv)."""
|
||||
problems = []
|
||||
for index, stored in sample.users:
|
||||
if _address(account, f"0/{index}") != stored:
|
||||
problems.append(f"user address at derivation index {index} differs from the one derived")
|
||||
if sample.rounds:
|
||||
reserve = _address(account, "1/0")
|
||||
for round_id, stored in sample.rounds:
|
||||
if stored not in (reserve, _address(account, f"2/{round_id}")):
|
||||
problems.append(f"pool address of round {round_id} is neither its own nor the reserve address")
|
||||
if encryption_key is not None:
|
||||
fernet = Fernet(encryption_key)
|
||||
for round_id, token, committed in sample.seeds:
|
||||
try:
|
||||
secret = fernet.decrypt(token.encode()).decode()
|
||||
except Exception:
|
||||
problems.append(f"secret of round {round_id} does not decrypt")
|
||||
continue
|
||||
if hashlib.sha256(bytes.fromhex(secret)).hexdigest() != committed:
|
||||
problems.append(f"secret of round {round_id} does not match its published hash")
|
||||
return problems
|
||||
|
||||
|
||||
def verify_master_key() -> None:
|
||||
"""Called from the lifespan, before any task starts: decrypt the key and compare the
|
||||
sample. Raises `ConfigError` (the process exits) on the first sign of a mismatch."""
|
||||
from app.wallet import hd
|
||||
|
||||
try:
|
||||
account = hd._load_account_key()
|
||||
except Exception as exc:
|
||||
raise ConfigError(
|
||||
f"the master key at {settings.master_key_path} cannot be loaded ({type(exc).__name__}): "
|
||||
"check MASTER_KEY_PATH and XPRV_ENCRYPTION_KEY_FILE"
|
||||
) from exc
|
||||
problems = sample_problems(account, load_sample(), settings.xprv_encryption_key)
|
||||
if problems:
|
||||
raise ConfigError(
|
||||
"the master key does not match the database — refusing to serve: " + "; ".join(problems)
|
||||
)
|
||||
logger.info("Master key verified against the database sample")
|
||||
@@ -108,7 +108,10 @@ async def request_withdrawal(
|
||||
# "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)
|
||||
# B-82 (DEP-13): the displayed balance includes deposits still short of their
|
||||
# threshold, so a withdrawal they would cover gets "wait for the confirmation".
|
||||
waiting_sats = network.pending_deposit_sats(user.id) if hasattr(network, "pending_deposit_sats") else 0
|
||||
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
|
||||
if has_pending and pending_inclusive_sats >= amount_sats:
|
||||
raise WithdrawalError(
|
||||
"balance_pending_confirmation",
|
||||
@@ -141,6 +144,16 @@ async def request_withdrawal(
|
||||
except InsufficientFundsError as exc:
|
||||
raise WithdrawalError(exc.code, str(exc), **exc.params) from exc
|
||||
|
||||
# B-96 (WDR-15): the reduction above can take a full-balance withdrawal a dust limit under
|
||||
# the minimum that was checked on the requested amount.
|
||||
moved_sats = built.recipient_sats + built.fee_sats
|
||||
if moved_sats < config.bet_amount_sats:
|
||||
raise WithdrawalError(
|
||||
"amount_below_minimum",
|
||||
f"amount below the minimum of {config.bet_amount_sats} sats",
|
||||
minimum_sats=config.bet_amount_sats,
|
||||
)
|
||||
|
||||
# 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}
|
||||
|
||||
+51
-1
@@ -1,20 +1,54 @@
|
||||
services:
|
||||
# B-91 (SEC-51): one-shot, as root, so the bind-mounted ./data belongs to the non-root
|
||||
# user the app runs as (uid 1000) and is closed to everyone else — also the first time
|
||||
# after an older root-owned install, with no sudo on the host.
|
||||
data-permissions:
|
||||
build: .
|
||||
image: plm-lottery-app:local
|
||||
user: "0:0"
|
||||
entrypoint: ["/bin/sh", "-c", "chown -R 1000:1000 /data && chmod -R go-rwx /data"]
|
||||
volumes:
|
||||
- ./data:/data
|
||||
restart: "no"
|
||||
|
||||
app:
|
||||
build: .
|
||||
image: plm-lottery-app:local
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=sqlite+aiosqlite:////app/db_data/plm_lottery.db
|
||||
- MASTER_KEY_PATH=/app/key_data/master.xprv.enc
|
||||
- XPRV_ENCRYPTION_KEY_FILE=/run/secrets/xprv_encryption_key
|
||||
# B-92 (ARC-02): uvicorn trusts X-Forwarded-For from Caddy's fixed address only, so the access
|
||||
# log records the client's IP instead of Caddy's.
|
||||
- FORWARDED_ALLOW_IPS=172.28.0.10
|
||||
secrets:
|
||||
- xprv_encryption_key
|
||||
volumes:
|
||||
- ./data/db:/app/db_data
|
||||
- ./data/keys:/app/key_data
|
||||
- ./data/logs:/app/logs
|
||||
expose:
|
||||
- "8123"
|
||||
# B-92 (ARC-61): ready = Electrum connected, a recent header, a live scheduler. Docker does not
|
||||
# restart an unhealthy container (the process exiting does); the state is for monitoring.
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request as u; u.urlopen('http://127.0.0.1:8123/health/ready', timeout=4)"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
depends_on:
|
||||
data-permissions:
|
||||
condition: service_completed_successfully
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
image: caddy:2-alpine@sha256:d8542f48d34a9cf4e4c11a478865229840e87e4c96ea3f439101f31a5d35f75f
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
@@ -25,9 +59,25 @@ services:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
networks:
|
||||
default:
|
||||
ipv4_address: 172.28.0.10
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
# A fixed subnet and a fixed address for Caddy, so uvicorn can trust exactly that one proxy (ARC-02).
|
||||
networks:
|
||||
default:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/24
|
||||
|
||||
# The Fernet key that protects the master key lives in its own file, outside ./data (a backup of
|
||||
# the data does not carry both) and in no environment variable (SEC-53).
|
||||
secrets:
|
||||
xprv_encryption_key:
|
||||
file: ./secrets/xprv_encryption.key
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-01 — La piattaforma è custodiale: tutte le chiavi sono generate e tenute dal server
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-91)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Custodia, chiavi e indirizzi
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -23,5 +23,3 @@ git. L'utente non controlla nulla finché non preleva.
|
||||
|
||||
*Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv` · `app/wallet/hd.py:generate_master_key` ·
|
||||
*Test:* `tests/unit/test_hd.py`
|
||||
*Stato:* da implementare (B-91) · *Oggi:* la chiave di cifratura è la
|
||||
variabile d'ambiente `XPRV_ENCRYPTION_KEY`, visibile con `docker inspect`.
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-06 — I depositi richiedono `deposit_confirmations` conferme (6 per default, configurabile in `round_config`), le uscite coinbase 120; ogni altra transazione ne richiede 1
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-82)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Conferme e fiducia nella rete
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -34,7 +34,5 @@ decisione esplicita.
|
||||
è contenuto da [ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md) e accettato in [LIM-24]. Regole in
|
||||
[registrazione-e-depositi.md](../registrazione-e-depositi.md#deposito) (B-82).
|
||||
|
||||
*Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates` ·
|
||||
*Test:* `tests/unit/test_confirmation.py::test_unconfirmed_tx_is_left_pending`, `tests/unit/test_deposits.py::test_unconfirmed_entry_is_ignored`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* ogni moneta, deposito o coinbase
|
||||
compresi, si accredita a 1 conferma.
|
||||
*Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates` · `app/deposits/service.py:classify_new_deposits` ·
|
||||
*Test:* `tests/unit/test_confirmation.py::test_unconfirmed_tx_is_left_pending`, `tests/unit/test_deposits.py::test_unconfirmed_entry_is_ignored`, `tests/unit/test_deposit_confirmations.py::test_a_deposit_is_credited_exactly_at_six_confirmations`, `tests/unit/test_deposit_confirmations.py::test_an_immature_coinbase_is_not_credited_until_it_has_120_confirmations`, `tests/unit/test_deposit_confirmations.py::test_an_output_of_a_platform_transaction_is_credited_at_one_confirmation`
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-10 — I parametri del round sono fotografati all'apertura
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-95)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Configurazione e ambiente
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
**I parametri del round sono fotografati all'apertura.**
|
||||
Durata, pausa successiva, costo della scommessa (`bet_amount_sats`),
|
||||
`fee_address` e `draw_confirmations` vengono copiati sulla riga del round
|
||||
`fee_address`, `draw_confirmations` e tetto al montepremi (`max_round_pool_sats`) vengono copiati sulla riga del round
|
||||
quando si apre; una modifica vale dal round successivo, mai per quello in
|
||||
corso. La pausa fra due round è quella annunciata dal round appena chiuso.
|
||||
Il fee rate resta letto dal vivo: riguarda la singola transazione, non le
|
||||
@@ -22,6 +22,4 @@ regole del round.
|
||||
[ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#0--apertura) (B-95).
|
||||
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:round_deadline` ·
|
||||
*Test:* `tests/unit/test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing`, `tests/unit/test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`, `tests/unit/test_rounds_service.py::test_cooldown_comes_from_the_round_that_closed`
|
||||
*Stato:* da implementare (B-95) · *Oggi:* sono fotografati solo durata e
|
||||
pausa; costo, `fee_address` e fee rate sono letti dal vivo ([LIM-16]).
|
||||
*Test:* `tests/unit/test_round_snapshot.py::test_a_round_copies_cost_fee_address_and_cap_when_it_opens`, `tests/unit/test_round_snapshot.py::test_an_edit_from_admin_never_reaches_the_round_in_progress_but_the_next_one`, `tests/unit/test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing`, `tests/unit/test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`, `tests/unit/test_rounds_service.py::test_cooldown_comes_from_the_round_that_closed`
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-11 — Il server rifiuta di partire con segreti inadeguati
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-91)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Configurazione e ambiente
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -24,8 +24,6 @@ chiuso a tutti. Il controllo è nel lifespan, non in un validatore di
|
||||
|
||||
*Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` ·
|
||||
*Test:* `tests/unit/test_config.py`
|
||||
*Stato:* da implementare (B-91) per la parte sulla chiave · *Oggi:* la
|
||||
chiave Fernet arriva da `XPRV_ENCRYPTION_KEY` (variabile d'ambiente); dopo
|
||||
B-91 arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
|
||||
La chiave Fernet arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
|
||||
[sicurezza.md](../sicurezza.md#sec-53) SEC-53) e il rifiuto riguarda un file
|
||||
mancante o vuoto.
|
||||
@@ -11,8 +11,7 @@ probabilità uguale per tutti** indipendentemente dall'importo effettivo.
|
||||
Nessun deposito minimo.
|
||||
- *Conseguenze:* l'estrazione sceglie fra partecipanti, non pesa gli
|
||||
importi. Il costo è fotografato all'apertura del round ([ADR-10](ADR-10-parametri-round-fotografati-all-apertura.md)), così
|
||||
tutti i partecipanti di un round pagano la stessa posta (B-95; oggi è letto
|
||||
dal vivo, [LIM-16]).
|
||||
tutti i partecipanti di un round pagano la stessa posta (B-95).
|
||||
|
||||
*Codice:* `app/bets/service.py:place_bet` · `app/rounds/draw.py:draw_winner` ·
|
||||
*Test:* `tests/unit/test_bets.py::test_place_bet_rejects_second_bet_same_round`
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-19 — Ogni transazione utente conserva un'uscita di resto verso l'utente, e un'accelerazione che porterebbe quel resto sotto `DUST_LIMIT_SATS` (294 sat) lo elimina
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-87)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Economia del gioco
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -31,7 +31,4 @@ e di [TX-11](../transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-
|
||||
utente: la sua uscita riducibile è la riserva di [ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md).
|
||||
|
||||
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused`
|
||||
*Stato:* da implementare (B-87) · *Oggi:* `bump_fee` richiede solo che il
|
||||
resto superi l'aumento, e può lasciare un resto sotto soglia che nessun nodo
|
||||
inoltra.
|
||||
*Test:* `tests/unit/test_rbf_dust.py::test_a_minimum_change_is_dropped_into_the_fee_by_the_first_bump`, `tests/unit/test_rbf_dust.py::test_the_dust_boundary_of_what_a_bump_leaves`, `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused`
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-41 — Limitazione dei tentativi solo su login e registrazione
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-94)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -17,12 +17,10 @@ account creati davvero. I contatori sono limitati in numero.
|
||||
limita la produzione.
|
||||
- *Conseguenze:* nessun limite su scommesse, prelievi, admin ([LIM-07]).
|
||||
Il cambio password (limitato e registrato come `password_changed`) e le
|
||||
segnalazioni di bug (limitate per IP) si aggiungono con B-94; ogni campo
|
||||
segnalazioni di bug (limitate per IP) sono limitati (B-94); ogni campo
|
||||
delle credenziali ha una lunghezza massima di 256 caratteri
|
||||
([sicurezza.md](../sicurezza.md#limiti-di-frequenza), B-94).
|
||||
|
||||
*Codice:* `app/auth/rate_limit.py:AuthRateLimiters` · `app/auth/rate_limit.py:RateLimiter` · `app/auth/rate_limit.py:RollingQuota` · `app/auth/routes.py:login` · `app/api/routes/users.py:change_password` · `app/api/routes/bug_reports.py:create_bug_report` ·
|
||||
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* cambio password e segnalazioni di
|
||||
bug non hanno limiti né lunghezze massime, e il cambio password non scrive
|
||||
audit.
|
||||
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_abuse_limits.py`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-50 — Frontend statici, senza framework né build
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-94)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Interfacce, API e lingue
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -15,6 +15,3 @@ aggiornamento non lascia una pagina nuova con uno script vecchio;
|
||||
[architettura.md](../architettura.md#allimport), B-94).
|
||||
|
||||
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` · `app/static/app.js` · `app/static/admin.js`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* solo le quattro pagine HTML hanno
|
||||
`Cache-Control` (`no-store`); JS e CSS, serviti da `StaticFiles`, non ne
|
||||
hanno.
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-52 — Le riorganizzazioni della catena si contengono invece di ignorarle
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-82)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Conferme e fiducia nella rete
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -43,6 +43,4 @@ e [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento)
|
||||
([LIM-24]).
|
||||
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._record_reorg` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/rounds/service.py:open_new_round_if_needed` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* il pagamento non verifica le monete
|
||||
del round: le prime due misure (rilevare, fermare) sono attive.
|
||||
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_pool_verification.py`
|
||||
+35
-74
@@ -50,9 +50,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
| `GET` | `/admin/pending-transactions` | admin | [API-38](#get-adminpending-transactions) |
|
||||
| `GET` | `/admin/bug-reports` | admin | [API-39](#get-adminbug-reports) |
|
||||
| `POST` | `/admin/bug-reports/{report_id}/status` | admin | [API-40](#post-adminbug-reportsreport_idstatus) |
|
||||
| `GET` | `/admin/electrum` | admin | [API-47](#get-adminelectrum) — *da implementare (B-86, B-92)* |
|
||||
| `GET` | `/admin/electrum` | admin | [API-47](#get-adminelectrum) |
|
||||
| `GET` | `/health` | nessuna | [API-41](#get-health) |
|
||||
| `GET` | `/health/ready` | nessuna | [API-46](#get-healthready) — *da implementare (B-92)* |
|
||||
| `GET` | `/health/ready` | nessuna | [API-46](#get-healthready) |
|
||||
| `GET` | `/`, `/admin`, `/guida`, `/report-bug` | nessuna | [API-42](#pagine-e-file-statici) |
|
||||
| `GET` | qualunque altro percorso (file statici) | nessuna | [API-43](#pagine-e-file-statici) |
|
||||
|
||||
@@ -117,9 +117,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
modo: torna alla schermata di login.
|
||||
*Codice:* `app/auth/dependencies.py:get_current_user` ·
|
||||
*Test:* `tests/unit/test_users.py`, `tests/unit/test_bug_reports.py`
|
||||
*Stato:* da implementare (B-94), per l'header assente · *Oggi:* l'assenza
|
||||
dell'header è intercettata da FastAPI prima del codice applicativo e
|
||||
risponde con il corpo non strutturato `{"detail": "Not authenticated"}`.
|
||||
|
||||
- **[API-07]** Autenticazione **facoltativa**: senza header, con un header che
|
||||
non inizia con `Bearer `, con un token invalido o superato, la richiesta è
|
||||
@@ -137,8 +134,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
(vedi [sicurezza.md](sicurezza.md)).
|
||||
*Codice:* `app/api/routes/admin.py:require_admin` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* `403` con il corpo non
|
||||
strutturato `{"detail": "invalid admin token"}`.
|
||||
|
||||
- **[API-09]** Intestazioni di cache:
|
||||
- le quattro pagine (`/`, `/admin`, `/guida`, `/report-bug`) rispondono con
|
||||
@@ -156,7 +151,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
`app/main.py:user_guide`, `app/main.py:report_bug_page`,
|
||||
`app/api/routes/rounds.py:round_stream`, `app/api/routes/qr.py:address_qr`,
|
||||
`app/main.py` (`app.mount`)
|
||||
*Stato:* da implementare (B-94), per i file statici · *Oggi:* i file
|
||||
serviti dal mount non hanno `Cache-Control`, solo `ETag`/`Last-Modified`,
|
||||
e il browser può riusarli senza rivalidarli.
|
||||
|
||||
@@ -169,8 +163,8 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
*Test:* `tests/unit/test_docs_disabled.py`
|
||||
|
||||
- **[API-11]** Dove un controllo dipende dall'indirizzo IP del chiamante (limiti
|
||||
di login e registrazione, tetto per IP del flusso SSE e, da implementare con
|
||||
B-94, quota per IP di `POST /bug-reports`), l'IP è l'**ultimo**
|
||||
di login e registrazione, tetto per IP del flusso SSE e quota per IP di
|
||||
`POST /bug-reports`), l'IP è l'**ultimo**
|
||||
elemento di `X-Forwarded-For` (quello scritto dal proxy fidato), o in sua
|
||||
assenza l'indirizzo della connessione, o `"unknown"`. Il motivo e il ruolo di
|
||||
Caddy sono in [sicurezza.md](sicurezza.md).
|
||||
@@ -238,11 +232,10 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
con `type: "json_invalid"`). Il valore ricevuto (`input`) **non** è
|
||||
ripetuto nella risposta, così una password troppo lunga non torna indietro
|
||||
in chiaro.
|
||||
*Codice:* `app/main.py` (gestore di `RequestValidationError`, da scrivere),
|
||||
*Codice:* `app/main.py` (gestore di `RequestValidationError`),
|
||||
`app/static/app.js:apiErrorMessage` ·
|
||||
*Test:* `tests/unit/test_users.py`, `tests/unit/test_admin.py`,
|
||||
`tests/unit/test_bug_reports.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* `422` nel formato **nativo** di
|
||||
FastAPI: `detail` è una **lista** di oggetti `{type, loc, msg, input, ctx?}`,
|
||||
senza `code`; il frontend riconosce la lista e mostra il messaggio generico
|
||||
tradotto `error.invalid_request`.
|
||||
@@ -269,9 +262,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
`app/api/routes/admin.py:update_bug_report_status`,
|
||||
`app/api/routes/rounds.py:round_stream`, `app/main.py` ·
|
||||
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* tutte le risposte della tabella
|
||||
hanno `detail` come stringa inglese (colonna "Oggi"), e il frontend la
|
||||
mostra così com'è.
|
||||
|
||||
- **[API-17]** Il client traduce `code` con la chiave `error.<code>` di
|
||||
`app/static/i18n.js`, passando `params` all'interpolazione e aggiungendo per
|
||||
@@ -305,12 +295,12 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
usato, **senza distinzione tra maiuscole e minuscole**, anche se
|
||||
l'altro account si è registrato nello stesso istante);
|
||||
`409 derivation_index_conflict` (dopo 5 tentativi non è stato possibile
|
||||
assegnare un indice di derivazione libero); `503 registration_suspended`
|
||||
assegnare un indice di derivazione libero); `503 registration_unavailable`
|
||||
(all'avvio sono stati trovati movimenti sulla catena per indici di
|
||||
derivazione successivi all'ultimo assegnato, cioè il database potrebbe
|
||||
essere un backup più vecchio della realtà; la registrazione resta
|
||||
sospesa finché l'operatore non riconcilia,
|
||||
[operativita.md](operativita.md) B-90 — *da implementare*).
|
||||
[operativita.md](operativita.md) B-90).
|
||||
- **Limite di frequenza:** al massimo 5 account creati per IP in una finestra
|
||||
mobile di un'ora; la quota è verificata **prima** dell'hash Argon2 e
|
||||
consumata solo da una registrazione riuscita (un nome già preso o un
|
||||
@@ -324,8 +314,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
[registrazione-e-depositi.md](registrazione-e-depositi.md).
|
||||
*Codice:* `app/auth/routes.py:register`, `app/auth/routes.py:RegisterRequest` ·
|
||||
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-92, B-90) · *Oggi:* non scrive audit, e non
|
||||
esiste la sospensione `registration_suspended`.
|
||||
|
||||
#### POST /auth/login
|
||||
|
||||
@@ -335,7 +323,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
massimo 256 caratteri (oltre: `422 invalid_request`, prima di qualunque
|
||||
hash Argon2 e senza consumare i contatori di fallimento). Lo username è
|
||||
confrontato senza distinzione tra maiuscole e minuscole.
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun vincolo di lunghezza.
|
||||
- **Risposta `200`:** `{"access_token": <JWT>, "address": <indirizzo>}`.
|
||||
- **Errori:** `422`; `429 rate_limited` (`params.retry_after_seconds`,
|
||||
arrotondato per eccesso al secondo); `401 invalid_credentials` —
|
||||
@@ -400,9 +387,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
*Codice:* `app/api/routes/users.py:change_password`,
|
||||
`app/auth/rate_limit.py:RateLimiter` ·
|
||||
*Test:* `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessuna lunghezza massima, nessun
|
||||
limite di frequenza (la password attuale si può provare senza limiti) e
|
||||
nessuna voce di audit.
|
||||
|
||||
#### GET /users/me/last-round-result
|
||||
|
||||
@@ -467,10 +451,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
casi (successo, rifiuto, esito ambiguo) e all'apertura del round.
|
||||
*Codice:* `app/api/routes/bets.py:create_bet`,
|
||||
`app/bets/service.py:place_bet` ·
|
||||
*Test:* `tests/unit/test_bets.py`
|
||||
*Stato:* da implementare (B-95, B-96) · *Oggi:* un costo coperto solo da resto non confermato
|
||||
risponde `insufficient_balance`; non c'è tetto al montepremi; il costo è
|
||||
quello configurato al momento della scommessa, non quello del round.
|
||||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py`
|
||||
|
||||
#### POST /withdrawals
|
||||
|
||||
@@ -516,8 +497,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`,
|
||||
`app/withdrawals/service.py:request_withdrawal` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py`
|
||||
*Stato:* da implementare (B-96) · *Oggi:* l'importo ridotto di un prelievo
|
||||
dell'intero saldo non è riconfrontato con il minimo.
|
||||
|
||||
### Round
|
||||
|
||||
@@ -537,7 +516,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
| `opened_at` | stringa ISO o `null` | apertura del round |
|
||||
| `closes_at` | stringa ISO o `null` | scadenza per le scommesse: `opened_at` + la durata **fissata all'apertura del round** (non quella attuale di configurazione) |
|
||||
| `participant_count` | intero | partecipanti **confermati**, cioè quelli tra cui si estrae |
|
||||
| `bet_amount_sats` | intero | costo che pagherebbe una nuova scommessa: con un round attivo il costo **fissato all'apertura del round** (`rounds.bet_amount_sats`), senza round attivo quello configurato — *da implementare (B-95); oggi sempre quello configurato* |
|
||||
| `bet_amount_sats` | intero | costo che pagherebbe una nuova scommessa: con un round attivo il costo **fissato all'apertura del round** (`rounds.bet_amount_sats`), senza round attivo quello configurato |
|
||||
| `jackpot_sats` | intero | 70% (arrotondato per difetto) della somma degli importi effettivi delle scommesse confermate; limite superiore, perché la commissione del pagamento è ancora da sottrarre |
|
||||
| `pending_participant_count` | intero | tutti i partecipanti, confermati **e** in volo (totale, non differenza) |
|
||||
| `pending_jackpot_sats` | intero | 70% della somma di tutte le scommesse, confermate e in volo |
|
||||
@@ -548,6 +527,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
| `draw_block_height` | intero o `null` | altezza del blocco usato per l'estrazione |
|
||||
| `draw_block_hash` | stringa o `null` | hash di quel blocco; è **uno** degli ingressi del seme, non il seme (vedi [API-45](#get-roundsround_idproof)) |
|
||||
| `draw_waiting_since` | stringa ISO o `null` | inizio dell'attesa del blocco; valorizzato **solo** mentre `status` è `drawing` |
|
||||
| `closing_waiting_since` | stringa ISO o `null` | da quando il round attende in `closing` le scommesse in volo ([TX-29](transazioni-e-riconciliazione.md#riconciliazione)); valorizzato **solo** mentre `status` è `closing` |
|
||||
| `server_seed_hash` | stringa o `null` | impegno del server sul segreto del round, pubblicato dall'apertura; `null` per un round aperto prima di B-73 |
|
||||
| `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` |
|
||||
| `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` |
|
||||
@@ -577,8 +557,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
`app/rounds/service.py:round_deadline`, `app/rounds/service.py:winner_share`,
|
||||
`app/rounds/service.py:rounds_can_open` ·
|
||||
*Test:* `tests/unit/test_rounds_route.py`
|
||||
*Stato:* da implementare (B-82, B-95), per i campi marcati · *Oggi:*
|
||||
`bet_amount_sats` è sempre il costo configurato.
|
||||
|
||||
#### GET /rounds/stream
|
||||
|
||||
@@ -613,7 +591,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
`app/rounds/events.py:RoundEventBroadcaster.subscribe`,
|
||||
`app/rounds/events.py:RoundEventBroadcaster.publish` ·
|
||||
*Test:* `tests/unit/test_round_events.py`
|
||||
*Stato:* da implementare (B-94, B-93) · *Oggi:* il `503` ha il corpo non
|
||||
*Stato:* da implementare (B-93), per la chiusura dei flussi all'arresto
|
||||
strutturato `{"detail": "too many concurrent update streams"}`, e un flusso
|
||||
aperto non viene chiuso all'arresto: uvicorn lo attende finché Docker non
|
||||
termina il processo.
|
||||
@@ -724,8 +702,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
`app/api/routes/bug_reports.py:BugReportCreate`,
|
||||
`app/auth/rate_limit.py:RollingQuota` ·
|
||||
*Test:* `tests/unit/test_bug_reports.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun limite di frequenza: un
|
||||
anonimo può inserire segnalazioni senza limite.
|
||||
|
||||
#### GET /bug-reports/mine
|
||||
|
||||
@@ -760,8 +736,6 @@ dei parametri operativi in
|
||||
"draw_confirmations", "max_round_pool_sats", "paused", "paused_reason"}`.
|
||||
*Codice:* `app/api/routes/admin.py:read_config` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-82, B-95) · *Oggi:* mancano
|
||||
`deposit_confirmations` e `max_round_pool_sats`.
|
||||
|
||||
#### PUT /admin/config
|
||||
|
||||
@@ -775,12 +749,12 @@ dei parametri operativi in
|
||||
| `bet_amount_sats` | > 0 e ≤ 10 000 000 000 000 (100 000 PLM) |
|
||||
| `round_duration_seconds` | 30 – 604 800 (7 giorni) |
|
||||
| `round_cooldown_seconds` | 0 – 86 400 |
|
||||
| `fee_rate_sat_vb` | 1 – 10 000 (`MAX_FEE_RATE_SAT_VB`) |
|
||||
| `fee_rate_sat_vb` | 1 – 5 000 (`MAX_FEE_RATE_SAT_VB`) |
|
||||
| `rbf_timeout_seconds` | 60 – 604 800 |
|
||||
| `draw_animation_seconds` | 0 – 600 |
|
||||
| `deposit_confirmations` | 1 – 100 — *da implementare (B-82)* |
|
||||
| `deposit_confirmations` | 1 – 100 |
|
||||
| `draw_confirmations` | 1 – 20 |
|
||||
| `max_round_pool_sats` | `0` (disattivato), oppure da `bet_amount_sats` a 4 000 000 000 000 000 — *da implementare (B-95)* |
|
||||
| `max_round_pool_sats` | `0` (disattivato), oppure da `bet_amount_sats` a 4 000 000 000 000 000 |
|
||||
|
||||
I limiti sono quelli di [modello-dati.md](modello-dati.md) (tabella
|
||||
`round_config`), che ne è il proprietario. `paused` e `paused_reason`
|
||||
@@ -794,18 +768,18 @@ dei parametri operativi in
|
||||
dà `422` con `detail = {"code": "config_not_payable", "message", "params":
|
||||
{"reason"}}`, dove `reason` è `winner_share_below_network_fee` o
|
||||
`commission_below_dust_limit`; il controllo usa i valori risultanti dopo
|
||||
la modifica.
|
||||
la modifica e, se cambia `fee_rate_sat_vb`, anche il costo con cui si è
|
||||
aperto il round in corso. Un tetto `max_round_pool_sats` diverso da 0 e
|
||||
minore del costo della scommessa (dopo la modifica) dà `422` con
|
||||
`detail.code = "max_round_pool_below_bet_amount"`.
|
||||
- **Effetti:** scrive `config_updated` con i valori prima/dopo dei soli
|
||||
campi davvero cambiati; nessuna scrittura se nulla cambia. Durata, pausa
|
||||
tra round, costo della scommessa, `fee_address` e `draw_confirmations` si
|
||||
applicano dal round successivo (per costo e `fee_address`: *da
|
||||
implementare*, B-95; oggi subito). Nessuna notifica SSE.
|
||||
applicano dal round successivo, come il tetto `max_round_pool_sats`. Nessuna
|
||||
notifica SSE.
|
||||
*Codice:* `app/api/routes/admin.py:update_config`,
|
||||
`app/api/routes/admin.py:RoundConfigUpdate` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-82, B-95), per i due campi nuovi e la copia
|
||||
all'apertura di costo ed `fee_address` · *Oggi:* sono accettati i primi
|
||||
otto campi.
|
||||
|
||||
#### POST /admin/pause e POST /admin/resume
|
||||
|
||||
@@ -834,9 +808,6 @@ dei parametri operativi in
|
||||
*Codice:* `app/api/routes/admin.py:pause_lottery`,
|
||||
`app/api/routes/admin.py:resume_lottery` ·
|
||||
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_pause_reason.py`
|
||||
*Stato:* da implementare (B-82), per l'azzeramento di `payout_blocked_at` ·
|
||||
*Oggi:* `rounds.payout_blocked_at` non esiste ancora; le pause automatiche
|
||||
esistenti sono solo `resurrected_tx`.
|
||||
|
||||
#### GET /admin/users
|
||||
|
||||
@@ -850,8 +821,6 @@ dei parametri operativi in
|
||||
"balance_sats", "created_at"}`; `balance_sats` è il saldo confermato.
|
||||
*Codice:* `app/api/routes/admin.py:list_users` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* restituisce **tutti** gli utenti
|
||||
in una sola risposta, senza paginazione né limite.
|
||||
|
||||
#### GET /admin/users/{user_id}/privkey
|
||||
|
||||
@@ -893,8 +862,7 @@ dei parametri operativi in
|
||||
completa di un round è in [API-45](#get-roundsround_idproof).
|
||||
*Codice:* `app/api/routes/admin.py:list_rounds` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-82), per `payout_blocked_at` · *Oggi:*
|
||||
la lista si ferma a `bump_reserve_sats`.
|
||||
|
||||
|
||||
#### GET /admin/audit-log
|
||||
|
||||
@@ -917,10 +885,7 @@ dei parametri operativi in
|
||||
- **Risposta `200`:** lista di `{"id", "event_type", "payload" (oggetto),
|
||||
"user_id", "round_id", "created_at"}`, per `id` decrescente.
|
||||
*Codice:* `app/api/routes/admin.py:list_audit_log` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-92), per filtri e paginazione · *Oggi:* accetta
|
||||
solo `limit`, quindi oltre le ultime 500 voci il registro non è
|
||||
consultabile dall'API.
|
||||
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_observability.py`
|
||||
|
||||
#### GET /admin/pending-transactions
|
||||
|
||||
@@ -949,9 +914,7 @@ dei parametri operativi in
|
||||
`last_rotation` (motivo e ora dell'ultimo cambio di server, ad esempio punta
|
||||
ferma, [connessione-electrum.md](connessione-electrum.md) ELC-21).
|
||||
*Codice:* `app/api/routes/admin.py`, `app/electrum/listener.py:ElectrumListener.current_endpoint` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-86, B-92) · *Oggi:* l'endpoint non esiste;
|
||||
`/admin` mostra solo l'altezza della catena letta da `GET /rounds/current`.
|
||||
*Test:* `tests/unit/test_observability.py`
|
||||
|
||||
#### GET /admin/bug-reports
|
||||
|
||||
@@ -995,7 +958,7 @@ dei parametri operativi in
|
||||
**tutte** queste condizioni sono vere, altrimenti `503` con lo stesso corpo e
|
||||
`"status": "not_ready"`:
|
||||
- la connessione Electrum è attiva (`listener.client` non nullo);
|
||||
- l'ultimo header è stato accettato da meno di 360 secondi
|
||||
- l'ultimo header è stato accettato da meno di 1800 secondi
|
||||
([connessione-electrum.md](connessione-electrum.md) ELC-29);
|
||||
- lo scheduler dei round ha battuto da meno di 30 secondi (il battito si
|
||||
aggiorna a ogni giro e a ogni iterazione dell'attesa del blocco di
|
||||
@@ -1006,9 +969,7 @@ dei parametri operativi in
|
||||
`scheduler_heartbeat_age_seconds` (intero o `null`). Nessuna autenticazione,
|
||||
nessun dato sensibile: non espone né il server attivo né l'altezza. È il
|
||||
`healthcheck` del container.
|
||||
*Codice:* `app/main.py:health` · *Test:* —
|
||||
*Stato:* da implementare (B-92) · *Oggi:* l'endpoint non esiste; esiste solo
|
||||
`GET /health` ([API-41]).
|
||||
*Codice:* `app/main.py:health` · *Test:* `tests/unit/test_observability.py`
|
||||
|
||||
#### Pagine e file statici
|
||||
|
||||
@@ -1039,7 +1000,7 @@ dei parametri operativi in
|
||||
|---|---|---|---|---|---|
|
||||
| `session_expired` | 401 | `app/auth/dependencies.py:get_current_user` | — | token illeggibile, scaduto, utente inesistente o token superato da un cambio/reset password | sì |
|
||||
| `invalid_credentials` | 401 | `app/auth/routes.py:login` | — | username inesistente o password sbagliata (risposta identica) | sì |
|
||||
| `rate_limited` | 429 | `app/auth/routes.py:register`, `app/auth/routes.py:login`; anche `app/api/routes/users.py:change_password` e `app/api/routes/bug_reports.py` *da implementare (B-94)* | `retry_after_seconds` | quota di registrazione, blocco per tentativi falliti, o quota di segnalazioni | sì |
|
||||
| `rate_limited` | 429 | `app/auth/routes.py:register`, `app/auth/routes.py:login`; anche `app/api/routes/users.py:change_password` e `app/api/routes/bug_reports.py` | `retry_after_seconds` | quota di registrazione, blocco per tentativi falliti, o quota di segnalazioni | sì |
|
||||
| `username_taken` | 409 | `app/auth/routes.py:register` | — | nome già usato (senza distinzione di maiuscole) | sì |
|
||||
| `derivation_index_conflict` | 409 | `app/auth/routes.py:register` | — | nessun indice di derivazione libero dopo 5 tentativi | sì |
|
||||
| `current_password_incorrect` | 401 | `app/api/routes/users.py:change_password` | — | password attuale errata | sì |
|
||||
@@ -1050,7 +1011,7 @@ dei parametri operativi in
|
||||
| `already_betting` | 400 | `app/bets/service.py:place_bet` | — | l'utente ha già una riga di partecipazione nel round | sì |
|
||||
| `round_full` | 400 | `app/bets/service.py:place_bet` | `max_participants` | il round ha già 400 partecipanti (in qualunque stato) | sì |
|
||||
| `insufficient_balance` | 400 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/wallet/psbt_builder.py:select_utxos` | `required_sats` (assente se sollevato da `select_utxos`) | le monete confermate e non spese non coprono l'importo | sì |
|
||||
| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal`; anche `app/bets/service.py:place_bet` *da implementare (B-96)* | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
|
||||
| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal`; anche `app/bets/service.py:place_bet` | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
|
||||
| `too_many_inputs` | 400 | `app/wallet/psbt_builder.py:select_utxos` | `max_inputs` | servirebbero più di 50 monete in ingresso | sì |
|
||||
| `balance_leaves_no_change` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` (solo scommesse) | `required_extra_sats` | dopo la scommessa il resto sarebbe sotto il limite di polvere (294 sat) | sì |
|
||||
| `amount_below_network_fee` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` | — | l'importo non copre la commissione di rete | sì |
|
||||
@@ -1062,15 +1023,15 @@ dei parametri operativi in
|
||||
| `internal_error` | 500 | `app/main.py:log_unhandled_exception` | — | eccezione non gestita | sì |
|
||||
| `withdrawal_to_platform_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni | sì |
|
||||
| `service_busy` | 503 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/auth/routes.py:register` | — | il database non ha concesso il lock di scrittura entro il timeout; nulla è stato trasmesso, si può riprovare | sì |
|
||||
| `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) — *da implementare* | no — da aggiungere |
|
||||
| `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `bug_report_not_found` | 404 | `app/api/routes/admin.py` | — | segnalazione inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) | sì |
|
||||
| `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente | sì |
|
||||
| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato | sì |
|
||||
| `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente | sì |
|
||||
| `bug_report_not_found` | 404 | `app/api/routes/admin.py` | — | segnalazione inesistente | sì |
|
||||
| `round_not_found` | 404 | `app/api/routes/rounds.py` (`GET /rounds/{id}/proof`) | — | round inesistente | sì |
|
||||
| `not_found` | 404 | gestore delle eccezioni HTTP in `app/main.py` | — | percorso inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `method_not_allowed` | 405 | gestore delle eccezioni HTTP in `app/main.py` | — | metodo non ammesso — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `too_many_streams` | 503 | `app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `not_found` | 404 | gestore delle eccezioni HTTP in `app/main.py` | — | percorso inesistente | sì |
|
||||
| `method_not_allowed` | 405 | gestore delle eccezioni HTTP in `app/main.py` | — | metodo non ammesso | sì |
|
||||
| `too_many_streams` | 503 | `app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto | sì |
|
||||
|
||||
Due codici di `app/wallet/psbt_builder.py` — `winner_share_below_network_fee`
|
||||
e `commission_below_dust_limit` — riguardano solo la costruzione del
|
||||
|
||||
+17
-41
@@ -19,7 +19,7 @@ flowchart LR
|
||||
C -- "HTTP app:8123<br/>(rete interna compose)" --> A["Container app<br/>1 processo uvicorn<br/>FastAPI + 6 task asyncio"]
|
||||
A -- "aiosqlite" --> D[("./data/db/plm_lottery.db<br/>SQLite, WAL")]
|
||||
A -- "legge" --> K["./data/keys/master.xprv.enc"]
|
||||
A -- "scrive" --> L["./data/logs/app.log<br/>(e stdout, B-92)"]
|
||||
A -- "scrive" --> L["./data/logs/app.log<br/>(e stdout)"]
|
||||
A -- "Electrum (TCP/SSL)<br/>una connessione attiva" --> E["Server Electrum PLM<br/>primario + riserve"]
|
||||
```
|
||||
|
||||
@@ -44,9 +44,8 @@ flowchart LR
|
||||
stato in memoria descritto in
|
||||
[Assunzioni di processo singolo](#assunzioni-di-processo-singolo) dipende da
|
||||
questo.
|
||||
*Codice:* `Dockerfile` (`CMD`) · `docker-compose.yml`
|
||||
*Stato:* da implementare (B-92) · *Oggi:* uvicorn non legge gli header del
|
||||
proxy e il log di accesso registra l'IP di Caddy.
|
||||
*Codice:* `Dockerfile` (`CMD`) · `docker-compose.yml` ·
|
||||
*Test:* `tests/unit/test_observability.py`
|
||||
- **[ARC-03]** All'avvio il container `app`, nell'ordine: rifiuta di partire
|
||||
se il file indicato da `MASTER_KEY_PATH` non esiste (stampando il comando
|
||||
per generarlo), poi esegue `alembic upgrade head`, poi avvia uvicorn con
|
||||
@@ -55,9 +54,8 @@ flowchart LR
|
||||
installate da un file di lock con gli hash
|
||||
([sicurezza.md](sicurezza.md#sec-51) SEC-51, SEC-52, SEC-53 (B-91)). I controlli che richiedono il codice
|
||||
applicativo (chiave, quorum, ripristino) sono nel lifespan ([ARC-60]).
|
||||
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py`
|
||||
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root,
|
||||
`python:3.12-slim` non è fissato per digest e le dipendenze sono `>=`.
|
||||
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py` · `requirements.lock` ·
|
||||
*Test:* `tests/unit/test_container_hardening.py`
|
||||
- **[ARC-04]** `app` è solo `expose`d sulla rete interna di compose (porta
|
||||
8123), mai pubblicato sull'host: l'unico ingresso è Caddy, che pubblica 80 e
|
||||
443. Su questo si basa la lettura dell'IP del client (vedi
|
||||
@@ -79,9 +77,9 @@ flowchart LR
|
||||
rilascio, così un rollback sa quale immagine rimettere
|
||||
([operativita.md](operativita.md) (B-93)).
|
||||
*Codice:* `docker-compose.yml`
|
||||
*Stato:* da implementare (B-91, B-93) · *Oggi:* la chiave di cifratura è la
|
||||
variabile `XPRV_ENCRYPTION_KEY` in `.env`, i permessi sono quelli di default
|
||||
(`.env` e file di chiave leggibili da tutti) e l'immagine non ha etichetta.
|
||||
*Test:* `tests/unit/test_container_hardening.py`
|
||||
*Stato:* da implementare (B-93), per l'etichetta per rilascio · *Oggi:*
|
||||
l'immagine di `app` ha l'etichetta fissa `plm-lottery-app:local`.
|
||||
|
||||
- **[ARC-61]** **Salute del container.** Il container `app` ha un
|
||||
`healthcheck` che interroga `http://127.0.0.1:8123/health/ready` ogni 30s
|
||||
@@ -95,9 +93,7 @@ flowchart LR
|
||||
([operativita.md](operativita.md#segnali-da-tenere-docchio)), e il riavvio
|
||||
resta affidato a `restart: unless-stopped` quando il processo esce.
|
||||
*Codice:* `docker-compose.yml` · `app/main.py:health` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-92) · *Oggi:* nessun `healthcheck`; esiste solo
|
||||
`/health`, che risponde sempre `ok` ([ARC-17]).
|
||||
*Test:* `tests/unit/test_observability.py`
|
||||
|
||||
### Database
|
||||
|
||||
@@ -139,10 +135,8 @@ flowchart LR
|
||||
`docker compose logs` li mostra senza entrare nel volume. La configurazione
|
||||
avviene all'import di `app/main.py`, prima di qualunque altro modulo
|
||||
applicativo.
|
||||
*Codice:* `app/logging_config.py:setup_logging`
|
||||
*Stato:* da implementare (B-92) · *Oggi:* i log dell'applicazione vanno solo
|
||||
nel file; su console arrivano solo quelli di uvicorn, per la sua
|
||||
configurazione predefinita.
|
||||
*Codice:* `app/logging_config.py:setup_logging` ·
|
||||
*Test:* `tests/unit/test_observability.py`
|
||||
|
||||
## Mappa del codice
|
||||
|
||||
@@ -343,8 +337,8 @@ sequenceDiagram
|
||||
L->>L: validate_runtime_secrets()
|
||||
L->>L: parse_endpoints(...)
|
||||
L->>L: quorum minimo (validate_quorum_servers)
|
||||
L->>L: verifica della master key (B-89, da implementare)
|
||||
L->>L: START_PAUSED (B-90, da implementare)
|
||||
L->>L: verifica della master key (B-89)
|
||||
L->>L: START_PAUSED (B-90)
|
||||
L->>L: app.state.electrum_listener, app.state.user_locks
|
||||
L->>L: create_task ×6
|
||||
Note over L: il server serve richieste<br/>(controllo dei buchi di indici alla prima connessione, B-90)
|
||||
@@ -398,7 +392,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
|
||||
`/app`.
|
||||
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` ·
|
||||
`app/main.py:user_guide` · `app/main.py:report_bug_page`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* `Cache-Control` c'è **solo** sulle
|
||||
quattro pagine HTML; i file serviti da `StaticFiles` (e `/index.html`
|
||||
chiesto per nome) non ne hanno, e il browser può usare uno script vecchio
|
||||
con una pagina nuova.
|
||||
@@ -415,8 +408,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
|
||||
veri.
|
||||
*Codice:* `app/config.py:validate_runtime_secrets` ·
|
||||
*Test:* `tests/unit/test_config.py`
|
||||
*Stato:* da implementare (B-91) · *Oggi:* si controlla che la variabile
|
||||
`XPRV_ENCRYPTION_KEY` non sia vuota.
|
||||
- **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST`,
|
||||
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`
|
||||
(`host:porta[:notls][:pin=<sha256>]` separati da virgola, [ARC-30]); un
|
||||
@@ -448,9 +439,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
|
||||
([operativita.md](operativita.md#ripristino), B-90).
|
||||
*Codice:* `app/main.py:lifespan` · `app/config.py:validate_runtime_secrets` · `app/config.py:validate_quorum_servers` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-89, B-90), per i controlli 2 e 3 · *Oggi:* dopo
|
||||
i segreti, gli endpoint e il quorum il lifespan crea subito i task; la
|
||||
chiave si decifra alla prima registrazione o al primo pagamento.
|
||||
- **[ARC-21]** Lo stato condiviso con le route vive su `app.state`:
|
||||
`electrum_listener` (l'`ElectrumListener`) e `user_locks` (un
|
||||
`UserLocks`), creati nel lifespan; `auth_rate_limiters` viene invece creato
|
||||
@@ -601,9 +589,7 @@ La configurazione è divisa in due posti con regole diverse.
|
||||
| `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) |
|
||||
|
||||
*Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py`
|
||||
*Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e
|
||||
`XPRV_ENCRYPTION_KEY_FILE` non esistono, la chiave Fernet è la variabile
|
||||
`XPRV_ENCRYPTION_KEY`.
|
||||
|
||||
- **[ARC-31]** **Parametri operativi e di business** (costo della scommessa,
|
||||
durata e pausa del round, animazione, commissione di rete, timeout RBF,
|
||||
indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei
|
||||
@@ -616,8 +602,6 @@ La configurazione è divisa in due posti con regole diverse.
|
||||
effetti in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
|
||||
*Codice:* `app/rounds/config.py:get_round_config` ·
|
||||
`app/db/models.py:RoundConfig`
|
||||
*Stato:* da implementare (B-82, B-95) · *Oggi:* `deposit_confirmations` e
|
||||
`max_round_pool_sats` non esistono.
|
||||
- **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili:
|
||||
la ripartizione 70/30, le conferme richieste per le transazioni della
|
||||
piattaforma (1) e per le uscite coinbase (120), i limiti di input e di
|
||||
@@ -626,8 +610,6 @@ La configurazione è divisa in due posti con regole diverse.
|
||||
dei depositi sono invece configurabili ([ARC-31]).
|
||||
*Codice:* `app/rounds/service.py:winner_share` ·
|
||||
`app/wallet/psbt_builder.py` · `app/tx/confirmation.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* 1 conferma per ogni moneta e
|
||||
transazione, depositi e coinbase compresi.
|
||||
|
||||
## Aggiornamenti in tempo reale (SSE)
|
||||
|
||||
@@ -694,9 +676,8 @@ flowchart LR
|
||||
`app/withdrawals/service.py:_release_failed_withdrawal`;
|
||||
- transazione confermata — `app/tx/confirmation.py:poll_once`;
|
||||
- riga promossa o abbandonata dal riconciliatore —
|
||||
`app/tx/reconcile.py:reconcile_once`. Oggi il riconciliatore pubblica per
|
||||
**ogni** riga che ricontrolla, anche quando non cambia nulla; la
|
||||
pubblicazione solo sui cambiamenti è in B-96;
|
||||
`app/tx/reconcile.py:reconcile_once`. Il riconciliatore pubblica solo
|
||||
per le righe che ha cambiato ([TX-27](transazioni-e-riconciliazione.md#riconciliazione));
|
||||
- deposito accreditato, UTXO segnata come spesa fuori piattaforma, UTXO
|
||||
ripristinata — `app/deposits/service.py:credit_confirmed_utxos`,
|
||||
`app/deposits/service.py:mark_utxos_spent_externally`,
|
||||
@@ -704,8 +685,6 @@ flowchart LR
|
||||
- nuovo header ricevuto — `app/electrum/listener.py:ElectrumListener._consume_headers`.
|
||||
|
||||
*Test:* `tests/unit/test_bets.py` (verifica la notifica di una scommessa)
|
||||
*Stato:* da implementare (B-96) · *Oggi:* il riconciliatore pubblica una
|
||||
notifica per ogni riga ricontrollata, anche senza cambiamenti.
|
||||
|
||||
## Lock per utente
|
||||
|
||||
@@ -787,13 +766,10 @@ flowchart LR
|
||||
bloccato o caduto si aggiornano **solo** riaprendo la sezione. La sezione
|
||||
Parametri non viene ricaricata dagli eventi. Il piano aggiunge al pannello
|
||||
lo stato della connessione Electrum (endpoint, età dell'ultimo header) e i
|
||||
filtri dell'audit log (B-86, B-92).
|
||||
filtri dell'audit log (B-92).
|
||||
*Codice:* `app/static/admin.js:startChainStatusPolling` ·
|
||||
`app/static/admin.js:connectAdminEvents` ·
|
||||
`app/static/admin.js:onAdminServerEvent`
|
||||
*Stato:* da implementare (B-86, B-92), solo per lo stato Electrum e i
|
||||
filtri dell'audit · *Oggi:* il pannello mostra solo l'altezza della catena,
|
||||
e l'audit log le ultime righe senza filtri.
|
||||
|
||||
### `/report-bug` e `/guida`
|
||||
|
||||
|
||||
+16
-54
@@ -44,9 +44,6 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
|
||||
`app/tx/broadcast.py:RbfBumper._tick`, `app/main.py:lifespan`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._tick`,
|
||||
`app/electrum/listener.py:ElectrumListener._record_reorg`
|
||||
*Stato:* da implementare (B-87, B-90, B-96), per tre degli eventi
|
||||
nuovi · *Oggi:* gli eventi osservativi sono solo i primi quattro e
|
||||
`chain_reorg_detected`.
|
||||
|
||||
- **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna
|
||||
`payload_json`. `created_at` è impostato dall'applicazione, non dal
|
||||
@@ -84,9 +81,6 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
|
||||
[api.md](api.md#get-adminaudit-log) (API-37), che ne è il proprietario.
|
||||
*Codice:* `app/api/routes/admin.py:list_audit_log` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-92), per filtri e paginazione · *Oggi:* accetta
|
||||
solo `limit`; le righe più vecchie delle ultime 500 si leggono solo
|
||||
direttamente dal database.
|
||||
|
||||
- **[AUD-08]** La sezione "Audit log" di `/admin` chiama l'endpoint senza
|
||||
parametri (quindi le ultime 200 righe) al login e ogni volta che si apre la
|
||||
@@ -114,10 +108,7 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
|
||||
`app/tx/broadcast.py:RbfBumper._tick`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._tick`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
*Test:* `tests/unit/test_scheduler.py` (solo `payout_failed`)
|
||||
*Stato:* da implementare (B-87, B-96), per le ultime due letture ·
|
||||
*Oggi:* le letture sono quelle di `payout_failed` e di
|
||||
`draw_baseline_tip_unknown`.
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_state_consistency.py`
|
||||
|
||||
## Catalogo degli eventi
|
||||
|
||||
@@ -187,8 +178,6 @@ flowchart LR
|
||||
riga. `user_id` no, `round_id` no. Stessa transazione della modifica.
|
||||
*Codice:* `app/api/routes/admin.py:update_config` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-82, B-95), per gli ultimi due campi ·
|
||||
*Oggi:* i campi sono i primi otto.
|
||||
|
||||
- **[AUD-11]** `lottery_paused` — la lotteria è stata messa in pausa, da
|
||||
chiunque. Payload `{"reason"}`, più `"note"` quando l'operatore l'ha
|
||||
@@ -215,11 +204,7 @@ flowchart LR
|
||||
`app/electrum/listener.py:ElectrumListener._record_reorg`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
|
||||
`app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-82, B-90) · *Oggi:* scritto da
|
||||
`POST /admin/pause` (`operator`), dal riconciliatore (`resurrected_tx`) e dal
|
||||
listener (`reorg`); `payout_blocked` e l'avvio con `START_PAUSED` non lo
|
||||
scrivono ancora.
|
||||
*Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`, `tests/unit/test_restore_gap.py`
|
||||
|
||||
- **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload
|
||||
`{"reason"}`: il `paused_reason` che la ripresa ha tolto (`null` se la
|
||||
@@ -229,9 +214,7 @@ flowchart LR
|
||||
`paused = false`, `paused_reason = NULL` e dell'azzeramento di
|
||||
`payout_blocked_at`.
|
||||
*Codice:* `app/api/routes/admin.py:resume_lottery` ·
|
||||
*Test:* `tests/unit/test_pause_reason.py`
|
||||
*Stato:* da implementare (B-82), per l'azzeramento di `payout_blocked_at` ·
|
||||
*Oggi:* `rounds.payout_blocked_at` non esiste ancora.
|
||||
*Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_admin.py::test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain`
|
||||
|
||||
- **[AUD-13]** `admin_privkey_accessed` — `GET /admin/users/{id}/privkey` ha
|
||||
esportato la chiave privata (WIF) di un utente. Payload `{"user_id"}`.
|
||||
@@ -270,9 +253,7 @@ flowchart LR
|
||||
`user_id` sì (il nuovo utente), `round_id` no. Stessa transazione che crea
|
||||
l'utente. È ciò che, dopo un ripristino, permette di ricostruire quali
|
||||
indici erano già assegnati. Nessuna logica rilegge la riga.
|
||||
*Codice:* `app/auth/routes.py:register` · *Test:* —
|
||||
*Stato:* da implementare (B-92) · *Oggi:* la registrazione non scrive alcun
|
||||
evento.
|
||||
*Codice:* `app/auth/routes.py:register` · *Test:* `tests/unit/test_observability.py`
|
||||
|
||||
- **[AUD-42]** `password_changed` — un utente ha cambiato la propria password
|
||||
con `POST /users/me/change-password`
|
||||
@@ -283,8 +264,6 @@ flowchart LR
|
||||
sbagliata, nuova troppo corta) non scrive nulla. Nessuna logica rilegge la
|
||||
riga.
|
||||
*Codice:* `app/api/routes/users.py:change_password` · *Test:* —
|
||||
*Stato:* da implementare (B-94) · *Oggi:* il cambio password non scrive
|
||||
alcun evento.
|
||||
|
||||
- **[AUD-43]** `restore_gap_detected` — il controllo all'avvio ha trovato
|
||||
cronologia sulla catena per almeno uno dei 20 indici di derivazione
|
||||
@@ -298,8 +277,7 @@ flowchart LR
|
||||
ogni avvio in cui il controllo trova indirizzi usati; transazione propria
|
||||
([AUD-03]). La sospensione delle registrazioni che ne segue non dipende da
|
||||
questa riga. Procedura in [operativita.md](operativita.md) (OPS-28).
|
||||
*Codice:* `app/main.py:lifespan` · *Test:* —
|
||||
*Stato:* da implementare (B-90) · *Oggi:* nessun controllo e nessun evento.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._check_restore_gap` · *Test:* `tests/unit/test_restore_gap.py`
|
||||
|
||||
### Denaro degli utenti
|
||||
|
||||
@@ -396,9 +374,7 @@ flowchart LR
|
||||
correnti, secondi trascorsi dalla scadenza. `user_id` no, `round_id` sì.
|
||||
Transazione propria ([AUD-03]). La riga più recente del round è l'orologio
|
||||
della ripetizione successiva, 1800 s dopo ([AUD-09]).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · *Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* l'attesa in `closing` non scrive
|
||||
alcun evento.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · *Test:* `tests/unit/test_state_consistency.py`
|
||||
|
||||
- **[AUD-37]** `draw_committed` — il round è entrato in `drawing`: elenco dei
|
||||
partecipanti e altezza del blocco di estrazione sono fissati
|
||||
@@ -491,7 +467,7 @@ flowchart LR
|
||||
scrive invece `payout_blocked` ([AUD-44]). L'elenco dei motivi è di
|
||||
[RND-25](ciclo-di-vita-del-round.md#4--pagamento); la verifica senza
|
||||
quorum di [RND-39](ciclo-di-vita-del-round.md#4--pagamento) termina anche
|
||||
lei con `payout_failed`, motivo `pool verification unavailable` (B-82). Cosa fare per ogni motivo è descritto in
|
||||
lei con `payout_failed`, motivo `pool verification unavailable`. Cosa fare per ogni motivo è descritto in
|
||||
[operativita.md](operativita.md).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._log_payout_failure` ·
|
||||
@@ -515,10 +491,8 @@ flowchart LR
|
||||
riscritto dopo l'esclusione. `user_id` sì (il partecipante), `round_id` sì.
|
||||
Stessa transazione del passaggio a `reorged_out` e della riscrittura di
|
||||
`pool_amount_sats`. Nessuna logica rilegge la riga.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-82) · *Oggi:* nessun partecipante viene mai
|
||||
escluso; il pagamento fallisce con `insufficient pool UTXOs`.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
|
||||
*Test:* `tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there`, `tests/unit/test_pool_verification.py::test_several_bets_gone_at_once_each_get_a_row_in_one_transaction`
|
||||
|
||||
- **[AUD-44]** `payout_blocked` — la verifica prima del pagamento ha trovato
|
||||
assente la scommessa **del vincitore**: il pagamento non si costruisce, il
|
||||
@@ -532,10 +506,8 @@ flowchart LR
|
||||
verifica si ripete: se la scommessa è ancora assente si scrive una nuova
|
||||
riga. Non è un `payout_failed` e non fa da orologio dei tentativi.
|
||||
Procedura in [operativita.md](operativita.md) (OPS-30).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-82) · *Oggi:* il pagamento fallisce con
|
||||
`insufficient pool UTXOs` a ogni tentativo, senza pausa.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
|
||||
*Test:* `tests/unit/test_pool_verification.py::test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery`, `tests/unit/test_pool_verification.py::test_after_the_resume_the_verification_repeats`
|
||||
|
||||
### Riconciliazione
|
||||
|
||||
@@ -550,9 +522,7 @@ flowchart LR
|
||||
([AUD-21]) o `payout_sent` ([AUD-29]) — con lo stesso payload
|
||||
([TX-16](transazioni-e-riconciliazione.md#riconciliazione)).
|
||||
*Codice:* `app/tx/reconcile.py:_promote` ·
|
||||
*Test:* nessun test verifica la riga di audit.
|
||||
*Stato:* da implementare (B-96), per il secondo evento · *Oggi:* si scrive
|
||||
solo `pending_tx_recovered`.
|
||||
*Test:* `tests/unit/test_state_consistency.py`
|
||||
|
||||
- **[AUD-31]** `pending_tx_abandoned` — una transazione `building` o `pending`
|
||||
è stata dichiarata persa **con una prova**
|
||||
@@ -596,10 +566,8 @@ flowchart LR
|
||||
nel registro una riga uguale ([AUD-09]); un nuovo fallimento con la stessa
|
||||
coppia va solo nei log, mentre un nuovo txid (dopo un aumento riuscito) o un
|
||||
motivo diverso scrivono una riga nuova.
|
||||
*Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-87) · *Oggi:* l'errore va solo nel log
|
||||
(`could not bump pending_transaction N`), ogni 30 secondi.
|
||||
*Codice:* `app/tx/broadcast.py:RbfBumper._record_failure`, `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* `tests/unit/test_rbf_dust.py::test_a_change_too_small_bump_writes_one_rbf_bump_failed_row_per_txid_and_reason`, `tests/unit/test_rbf_dust.py::test_the_maximum_fee_rate_is_reported`, `tests/unit/test_rbf_dust.py::test_a_replacement_the_network_refuses_is_reported_and_leaves_the_row_alone`, `tests/unit/test_rbf_dust.py::test_a_new_txid_or_a_new_reason_writes_a_new_row`
|
||||
|
||||
### Catena
|
||||
|
||||
@@ -642,9 +610,6 @@ flowchart LR
|
||||
`app/rounds/scheduler.py:RoundScheduler._tick`,
|
||||
`app/rounds/confirmation.py:_on_payout_confirmed`,
|
||||
`app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee`
|
||||
*Stato:* da implementare (B-87, B-92, B-94), per i tre fatti che escono
|
||||
dall'elenco · *Oggi:* gli RBF impossibili, la registrazione e il cambio
|
||||
password riuscito non producono alcuna riga.
|
||||
|
||||
## Log applicativo
|
||||
|
||||
@@ -667,11 +632,8 @@ va letto sul server.
|
||||
container, quindi `docker compose logs app` li mostra insieme all'output di
|
||||
avvio del container (controllo della chiave, migrazioni Alembic). La regola
|
||||
è di [ARC-09](architettura.md#log), che ne è il proprietario.
|
||||
*Codice:* `app/logging_config.py:setup_logging`
|
||||
*Stato:* da implementare (B-92) · *Oggi:* il logger radice ha solo
|
||||
l'handler su file: `docker compose logs app` mostra solo l'output di avvio
|
||||
del container e ciò che uvicorn scrive con la propria configurazione
|
||||
(avvio, richieste).
|
||||
*Codice:* `app/logging_config.py:setup_logging` ·
|
||||
*Test:* `tests/unit/test_observability.py`
|
||||
|
||||
- **[AUD-35]** Un'eccezione non gestita in una richiesta HTTP viene registrata
|
||||
per intero nel log (`Unhandled error on <metodo> <percorso>`) e al client
|
||||
|
||||
@@ -105,10 +105,7 @@ richiesta di scommessa (vedi
|
||||
Il round usa sempre i valori fotografati: una modifica da `/admin` vale dal
|
||||
round successivo, mai per quello in corso.
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:round_deadline` ·
|
||||
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py` (solo durata e pausa)
|
||||
*Stato:* da implementare (B-95) · *Oggi:* sono fotografate durata, pausa e
|
||||
`draw_confirmations`; costo della scommessa e `fee_address` sono letti dalla
|
||||
configurazione a ogni uso, `max_round_pool_sats` non esiste.
|
||||
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py` (durata e pausa), `tests/unit/test_round_snapshot.py`
|
||||
- **[RND-06]** Pause e configurazione non pagabile bloccano solo l'apertura
|
||||
del *prossimo* round: un round già in corso prosegue fino al pagamento con i
|
||||
valori fotografati all'apertura ([RND-05]), quindi una modifica di
|
||||
@@ -119,12 +116,9 @@ richiesta di scommessa (vedi
|
||||
`lottery_configured`) perché `/` mostri avvisi diversi. Le procedure sono in
|
||||
[guida-admin.md](guida-admin.md).
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/api/routes/rounds.py:current_round`, `app/api/routes/admin.py:RoundConfigUpdate` ·
|
||||
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_rounds_route.py`
|
||||
*Stato:* da implementare (B-95) · *Oggi:* il pagamento legge `fee_address`
|
||||
dalla configurazione a ogni tentativo: una modifica durante il round
|
||||
sposta la commissione del round in corso, e un `fee_address` vuoto (solo
|
||||
modificando il database a mano) blocca il pagamento con
|
||||
`no fee_address configured`.
|
||||
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_rounds_route.py`, `tests/unit/test_round_snapshot.py::test_the_payout_pays_the_fee_address_the_round_opened_with`, `tests/unit/test_round_snapshot.py::test_clearing_the_fee_address_mid_round_does_not_stop_its_payout`
|
||||
Un `fee_address` vuoto sulla riga del round (solo modificando il database a
|
||||
mano) blocca il pagamento con `no fee_address configured`.
|
||||
- **[RND-07]** L'apertura di un round invia una notifica SSE.
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`
|
||||
- **[RND-34]** **Impegno sul segreto**: all'apertura il server genera un
|
||||
@@ -410,11 +404,7 @@ del montepremi del round.
|
||||
([RND-38]). La ripartizione è fissata nel codice, non modificabile da
|
||||
`/admin`.
|
||||
*Codice:* `app/rounds/service.py:winner_share`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
*Test:* `tests/unit/test_payout_builder.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* la ripartizione si calcola
|
||||
su `pool_amount_sats` fissato all'estrazione, senza la verifica di
|
||||
[RND-39]; la riserva per l'RBF è già tolta dalla quota delle commissioni
|
||||
(B-79).
|
||||
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there`
|
||||
- **[RND-22]** Il pagamento spende le uscite delle scommesse dei partecipanti
|
||||
del round **verificate** ([RND-39]), all'indirizzo del montepremi del round
|
||||
([RND-35]), e nient'altro: altre monete arrivate a quell'indirizzo non
|
||||
@@ -427,12 +417,7 @@ del montepremi del round.
|
||||
dalla più grande fino a coprire il montepremi; non abbastanza monete dà
|
||||
`insufficient pool UTXOs`.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/wallet/psbt_builder.py:build_payout_transaction` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_payout_builder.py`, `tests/unit/test_round_pool_address.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* spende le monete confermate
|
||||
dell'indirizzo del round (per un round vecchio, di quello condiviso) lette
|
||||
dal solo server attivo, scelte dalla più grande fino a coprire il
|
||||
montepremi, e non solo le uscite delle scommesse dei partecipanti: un'altra
|
||||
moneta arrivata a quell'indirizzo verrebbe spesa.
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_payout_builder.py`, `tests/unit/test_round_pool_address.py`, `tests/unit/test_pool_verification.py::test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else`
|
||||
- **[RND-23]** La transazione ha un'uscita verso l'indirizzo del vincitore,
|
||||
una verso l'indirizzo delle commissioni fotografato all'apertura
|
||||
([RND-05]) e una verso l'indirizzo di riserva ([RND-38]). Se gli ingressi
|
||||
@@ -447,9 +432,6 @@ del montepremi del round.
|
||||
`commission_below_dust_limit`).
|
||||
*Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` ·
|
||||
*Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share`, `tests/unit/test_payout_builder.py::test_payout_folds_excess_input_value_into_the_reserve_output`, `tests/unit/test_payout_builder.py::test_payout_leaves_a_dust_sized_excess_to_the_network_fee`, `tests/unit/test_payout_builder.py::test_payout_with_one_participant_at_the_smallest_viable_pool`
|
||||
*Stato:* da implementare (B-95) · *Oggi:* `fee_address` è letto dalla
|
||||
configurazione corrente al momento del pagamento, non fotografato
|
||||
all'apertura.
|
||||
- **[RND-24]** **Prima si scrive, poi si invia**: prima dell'invio il round
|
||||
registra importo del vincitore (`winner_amount_sats`), commissioni
|
||||
(`fee_amount_sats`), riserva per l'RBF (`bump_reserve_sats`, [RND-38]) e
|
||||
@@ -469,7 +451,7 @@ del montepremi del round.
|
||||
`insufficient pool UTXOs`, il codice dell'errore di costruzione
|
||||
(`too_many_inputs`, `winner_share_below_network_fee`,
|
||||
`commission_below_dust_limit`), `payout build failed` per ogni altro errore
|
||||
di costruzione, `broadcast rejected`, e — *da implementare (B-82)* —
|
||||
di costruzione, `broadcast rejected`, e
|
||||
`pool verification unavailable` quando la verifica del montepremi prima del
|
||||
pagamento ([RND-39]) non ottiene abbastanza risposte dal quorum. Fanno eccezione due casi, che non
|
||||
scrivono `payout_failed`: un pagamento già in volo per il round ([RND-26]),
|
||||
@@ -524,18 +506,17 @@ del montepremi del round.
|
||||
`paying_out` senza altri tentativi finché l'operatore non toglie la pausa
|
||||
(`POST /admin/resume`); al tentativo successivo la verifica si ripete. Lo
|
||||
schema è in [modello-dati.md](modello-dati.md) (B-82), la procedura in
|
||||
[operativita.md](operativita.md) (B-82).
|
||||
[operativita.md](operativita.md) (B-82). La riga del vincitore **non**
|
||||
passa a `reorged_out` in questo caso: resta `confirmed`, così che la
|
||||
verifica dopo la ripresa la riesamini davvero; i partecipanti diversi dal
|
||||
vincitore la cui scommessa è assente sono invece esclusi nella stessa
|
||||
transazione.
|
||||
- Se il quorum non risponde o non concorda, non si conclude nulla: nessun
|
||||
partecipante cambia stato, il tentativo termina con `payout_failed` e si
|
||||
riprova secondo [RND-25].
|
||||
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-82) · *Oggi:* le monete sono lette dal solo
|
||||
server attivo e nessun partecipante viene mai escluso: se una scommessa
|
||||
sparisce dalla catena il pagamento fallisce con
|
||||
`insufficient pool UTXOs` a ogni tentativo, con i soldi di tutti gli altri
|
||||
bloccati.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:RoundScheduler._verify_round_pool`, `app/electrum/listener.py:ElectrumListener.verify_pool_outpoints` ·
|
||||
*Test:* `tests/unit/test_pool_verification.py`
|
||||
- **[RND-40]** **Configurazione pagabile**: `rounds_can_open` rifiuta una
|
||||
configurazione in cui un round non si potrebbe pagare, verificandola per
|
||||
**n = 1** e per **n = `MAX_PARTICIPANTS_PER_ROUND`** partecipanti (i due
|
||||
|
||||
@@ -62,6 +62,16 @@ Le difese sono cinque.
|
||||
di [ELC-14].
|
||||
*Codice:* `app/config.py:validate_quorum_servers`, `app/main.py:lifespan` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
- **[ELC-30]** Subito dopo `server.version` la connessione chiede
|
||||
`server.features`: il `genesis_hash` deve essere quello di PLM
|
||||
(`000000000019d668…ce26f`). PLM condivide genesis e `powLimit` con Bitcoin
|
||||
(`palladiumcore/src/chainparams.cpp`), quindi un server Electrum di Bitcoin
|
||||
supererebbe ogni controllo sugli header di [ELC-11]; solo il genesis lo
|
||||
distingue. Un genesis diverso o assente, o un errore alla richiesta, fa
|
||||
fallire la connessione come un server irraggiungibile ([ELC-02]); vale anche
|
||||
per le connessioni del quorum ([ELC-14]).
|
||||
*Codice:* `app/electrum/client.py:ElectrumClient._check_genesis`, `app/wallet/plm_network.py:GENESIS_HASH` ·
|
||||
*Test:* `tests/unit/test_electrum_client.py`
|
||||
|
||||
## 2. Richieste con limite di tempo
|
||||
|
||||
@@ -98,11 +108,7 @@ Le difese sono cinque.
|
||||
indirizzo non termina la sessione
|
||||
([DEP-16](registrazione-e-depositi.md#deposito)).
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once`, `app/electrum/client.py:ElectrumClient.wait_closed`, `app/electrum/listener.py:ElectrumListener._consume_scripthash` ·
|
||||
*Test:* `tests/unit/test_electrum_client.py`
|
||||
*Stato:* da implementare (B-96), per gli errori di aggiornamento di un
|
||||
utente · *Oggi:* qualunque eccezione in `refresh_user` chiamato da
|
||||
`_consume_scripthash` (ad esempio la collisione sull'outpoint di due
|
||||
aggiornamenti concorrenti) termina la sessione.
|
||||
*Test:* `tests/unit/test_electrum_client.py`, `tests/unit/test_state_consistency.py`
|
||||
- **[ELC-07]** Una connessione viene resa disponibile al resto del sistema
|
||||
**solo dopo** che il primo header è stato applicato: se il client c'è,
|
||||
l'altezza della catena è nota. Se il primo header è inutilizzabile (privo
|
||||
@@ -253,12 +259,10 @@ Ogni decisione che sposta denaro in base a una risposta della rete viene
|
||||
confermata dagli **altri** server configurati, mai dal solo server attivo
|
||||
(che è proprio quello che un attaccante controllerebbe).
|
||||
|
||||
Oggi non è ancora così per una sola decisione: la lettura delle monete del
|
||||
montepremi per il pagamento
|
||||
(`app/rounds/scheduler.py:RoundScheduler._trigger_payout`) si basa sul solo
|
||||
server attivo, ed è la riga [ELC-27]. Le conferme delle transazioni
|
||||
([ELC-26]), il reintegro di una moneta ricomparsa ([ELC-25]), l'abbandono
|
||||
([ELC-28]) e l'invio ([TX-04], [TX-21]) usano già gli altri server.
|
||||
Vale per ogni decisione di questo tipo: le monete del montepremi per il
|
||||
pagamento ([ELC-27]), le conferme delle transazioni ([ELC-26]), il reintegro
|
||||
di una moneta ricomparsa ([ELC-25]), l'abbandono ([ELC-28]) e l'invio
|
||||
([TX-04], [TX-21]) usano gli altri server.
|
||||
|
||||
- **[ELC-14]** La corroborazione interroga in parallelo tutti i server
|
||||
configurati tranne quello attivo, ciascuno con una connessione nuova e un
|
||||
@@ -286,17 +290,11 @@ server attivo, ed è la riga [ELC-27]. Le conferme delle transazioni
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.agreed_block_hash`,
|
||||
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent`,
|
||||
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit`,
|
||||
`app/electrum/listener.py:ElectrumListener.verify_pool_outpoints`,
|
||||
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_reinstatement`,
|
||||
`app/electrum/listener.py:ElectrumListener.confirmation_proven`,
|
||||
`app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_confirmation_proof.py`
|
||||
|
||||
Stato delle righe della tabella:
|
||||
|
||||
- [ELC-17] — *Stato:* da implementare (B-82) · *Oggi:* basta un'altezza
|
||||
maggiore di 0 (1 conferma).
|
||||
- [ELC-27] — *Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie
|
||||
fra le monete che il server attivo elenca per il montepremi.
|
||||
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_confirmation_proof.py`, `tests/unit/test_deposit_confirmations.py`, `tests/unit/test_pool_verification.py`
|
||||
|
||||
I saldi si muovono in entrambe le direzioni, quindi entrambe richiedono lo
|
||||
stesso quorum. Le conseguenze di una verifica non riuscita:
|
||||
@@ -311,7 +309,15 @@ stesso quorum. Le conseguenze di una verifica non riuscita:
|
||||
al controllo successivo;
|
||||
- un blocco di estrazione non verificato fa attendere e richiedere la stessa
|
||||
altezza, e lo si registra in audit (`draw_header_corroboration_failed`,
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione)).
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione));
|
||||
- le monete del montepremi non verificate fanno terminare il tentativo di
|
||||
pagamento con `payout_failed` (`pool verification unavailable`), senza
|
||||
escludere nessun partecipante ([RND-39](ciclo-di-vita-del-round.md#4--pagamento)).
|
||||
La verifica ([ELC-27]) chiede a ogni altro server l'elenco delle monete
|
||||
dell'indirizzo del round: per ogni uscita di scommessa una maggioranza
|
||||
stretta di chi risponde dice *presente* (stesso importo, confermata) o
|
||||
*assente* (non elencata, o con un altro importo); un'uscita elencata solo
|
||||
nel mempool, o senza maggioranza, non dà alcun verdetto e si riprova.
|
||||
|
||||
L'**invio** di una transazione non è una corroborazione ma usa gli stessi
|
||||
server: dopo che il server attivo l'ha accettata, la transazione grezza viene
|
||||
@@ -338,11 +344,11 @@ B-83).
|
||||
- **[ELC-29]** Il listener espone lo stato della connessione: se è connesso,
|
||||
il server attivo, l'altezza della punta e da quanti secondi è stato
|
||||
accettato l'ultimo header. Lo leggono `GET /health/ready` (non pronto se non
|
||||
connesso o se l'ultimo header ha più di 360 secondi,
|
||||
[api.md](api.md) B-92) e l'indicatore Electrum di `/admin`. L'altezza
|
||||
connesso o se l'ultimo header ha più di 1800 secondi (con blocchi da 120 s
|
||||
una soglia di 360 s scatterebbe a vuoto ogni ventina di blocchi,
|
||||
e^-3 ≈ 5%; la rotazione di [ELC-21] resta a 360 s perché agisce solo se un
|
||||
altro server ha davvero una punta più alta),
|
||||
[api.md](api.md) API-46) e l'indicatore Electrum di `/admin`. L'altezza
|
||||
corrente resta visibile anche nella barra di stato della catena di `/` (da
|
||||
`chain_tip_height` di `GET /rounds/current`).
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.current_endpoint`, `app/main.py:health` · *Test:* —
|
||||
*Stato:* da implementare (B-86, B-92) · *Oggi:* `/health` risponde sempre
|
||||
`ok`; l'età dell'ultimo header non è registrata da nessuna parte e `/admin`
|
||||
mostra solo l'altezza.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.current_endpoint`, `app/main.py:health` · *Test:* `tests/unit/test_observability.py`
|
||||
+4
-2
@@ -47,11 +47,13 @@ business — quelli si toccano solo da qui.
|
||||
|
||||
| Campo | Significato |
|
||||
|---|---|
|
||||
| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: su un'istanza nuova è vuoto, e finché resta vuoto **non si apre nessun round** (il payout non sarebbe costruibile, quindi il round accetterebbe scommesse per poi restare bloccato con i soldi già nel montepremi). Il pannello lo segnala con un avviso in cima ai Parametri, e la pagina utente mostra un banner "lotteria non ancora pronta". Una volta impostato non si può più svuotare dal pannello: un valore vuoto non è un indirizzo `plm1...` valido e viene rifiutato. **Attenzione:** oggi il payout legge questo campo al momento in cui viene costruito, quindi cambiarlo mentre un round è in corso sposta anche la commissione di quel round sul nuovo indirizzo. |
|
||||
| **Bet amount (PLM)** | Il costo fisso d'ingresso per round. È anche l'importo minimo prelevabile: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). |
|
||||
| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee); una modifica vale dal round successivo, mai per quello in corso. Obbligatorio: su un'istanza nuova è vuoto, e finché resta vuoto **non si apre nessun round** (il payout non sarebbe costruibile, quindi il round accetterebbe scommesse per poi restare bloccato con i soldi già nel montepremi). Il pannello lo segnala con un avviso in cima ai Parametri, e la pagina utente mostra un banner "lotteria non ancora pronta". Una volta impostato non si può più svuotare dal pannello: un valore vuoto non è un indirizzo `plm1...` valido e viene rifiutato. **Attenzione:** oggi il payout legge questo campo al momento in cui viene costruito, quindi cambiarlo mentre un round è in corso sposta anche la commissione di quel round sul nuovo indirizzo. |
|
||||
| **Bet amount (PLM)** | Il costo fisso d'ingresso per round, **dal round successivo**: il round in corso tiene il costo con cui si è aperto, così tutti i suoi giocatori pagano la stessa posta. È anche l'importo minimo prelevabile, e questo vale subito: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). Un valore con cui un round non si potrebbe pagare (commissione di rete, riserva e soglia di polvere non coperte) viene rifiutato. |
|
||||
| **Durata round (secondi)** | Quanto resta aperto un round prima di chiudersi ed estrarre il vincitore. Il taglio per le nuove giocate scatta esattamente allo scadere di questo tempo (verificato ad ogni bet, non dipende dal ciclo dello scheduler) — è un "semaforo giallo": nessuna nuova entrata, ma le bet già trasmesse prima dello scadere hanno comunque tempo di confermarsi prima che il round chiuda ed estragga. |
|
||||
| **Tetto al montepremi di un round (PLM)** | 0 = nessun tetto. Con un tetto, una scommessa è rifiutata (`round_full`) quando la somma delle scommesse del round lo ha raggiunto; il montepremi può quindi superarlo al massimo di una scommessa. Non può essere minore del costo della scommessa. Vale dal round successivo. |
|
||||
| **Pausa tra un round e il successivo (secondi)** | Cooldown dopo la chiusura di un round, prima che il successivo si apra — dà tempo ai giocatori di vedere l'esito. |
|
||||
| **Durata animazione estrazione (secondi)** | Tempo minimo per cui la dashboard di ogni utente mostra l'animazione "Estrazione in corso" dopo la chiusura del round, prima di rivelare il vincitore. È solo un minimo: il processo reale aspetta più blocchi in sequenza (conferma dell'ultima bet in sospeso, il blocco dell'estrazione con le sue conferme, conferma del payout — un blocco ogni 2 minuti in media), quindi l'animazione può durare più a lungo di questo valore, mai meno. |
|
||||
| **Conferme per accreditare un deposito** | Da 1 a 100 (predefinito 6): dopo quante conferme un deposito arrivato all'indirizzo di un utente viene accreditato e diventa spendibile. Un deposito è l'unico ingresso che un utente può spendere due volte con una catena privata, perciò il valore non va abbassato senza motivo. Vale subito per gli accrediti successivi; le uscite di mining (coinbase) ne richiedono comunque 120, e le transazioni costruite dalla piattaforma (resto di una bet, vincite) una sola. |
|
||||
| **Conferme sul blocco di estrazione** | Da 1 a 20 (predefinito 3): quanti blocchi deve avere la catena a partire dal blocco di estrazione prima che il suo hash scelga il vincitore. Più conferme rendono l'estrazione più robusta a una riorganizzazione della catena e allungano l'attesa di circa 2 minuti per blocco. Si applica dal round successivo. |
|
||||
| **Fee rate di rete (sat/vB)** | Fee per byte usata per costruire bet, payout e prelievi. |
|
||||
| **Timeout prima del fee-bump RBF (secondi)** | Dopo quanto tempo senza conferma una transazione viene ritrasmessa con fee più alta. |
|
||||
|
||||
@@ -102,15 +102,20 @@ L'avviso sparisce da solo appena l'operatore riprende la lotteria.
|
||||
confermata — così non sembra che il saldo sia crollato più del dovuto
|
||||
subito dopo un'operazione. Il colore indica lo stato:
|
||||
- **verde**: tutto confermato, il saldo mostrato è quello definitivo
|
||||
- **arancione**: c'è una bet o un prelievo ancora in attesa di conferma —
|
||||
il numero è corretto, ma non ancora "finale"
|
||||
- **arancione**: c'è una bet o un prelievo ancora in attesa di conferma,
|
||||
oppure un deposito che ha qualche conferma ma non ancora quelle richieste —
|
||||
il numero è corretto, ma non ancora "finale"; un deposito in attesa non è
|
||||
spendibile finché non viene accreditato
|
||||
- Il tuo **indirizzo di deposito**, con bottone per copiarlo negli appunti
|
||||
- Il **QR code** dello stesso indirizzo, comodo per inviare PLM da un altro
|
||||
wallet scansionandolo invece di copiare l'indirizzo a mano
|
||||
|
||||
Per depositare, invia PLM (mainnet reale) a quell'indirizzo da un wallet
|
||||
esterno. Il saldo si aggiorna da solo dopo la prima conferma (e quasi subito,
|
||||
grazie all'aggiornamento in tempo reale); premi "Aggiorna" se vuoi comunque
|
||||
esterno. Un deposito viene accreditato (e diventa spendibile) dopo
|
||||
**6 conferme** sulla rete (il valore è configurabile dall'operatore; un'uscita
|
||||
di mining ne richiede 120), cioè circa 12 minuti; nel frattempo compare in
|
||||
arancione come in attesa. Il saldo si aggiorna da solo, con l'aggiornamento in
|
||||
tempo reale; premi "Aggiorna" se vuoi comunque
|
||||
ricontrollarlo a mano.
|
||||
|
||||
### Bet
|
||||
|
||||
+50
-118
@@ -16,8 +16,6 @@ Un limite che il [piano delle correzioni](piano-correzioni.md) chiuderà porta
|
||||
atteso; quando la correzione entra nel codice, il limite si barra come
|
||||
`~~[LIM-nn]~~ Rimosso: chiuso da B-nn`.
|
||||
|
||||
Formato di ogni voce: descrizione, *Impatto*, *Mitigazione attuale*, *Per
|
||||
chiuderlo*, e il codice coinvolto.
|
||||
Formato di ogni voce: descrizione, *Impatto*, *Mitigazione attuale*, *Per
|
||||
chiuderlo*, e il codice coinvolto.
|
||||
|
||||
@@ -33,11 +31,11 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
| Limite | Blocca il non presidiato oggi? | Dopo il piano |
|
||||
|---|---|---|
|
||||
| ~~[LIM-01]~~ `drawing` non riprende dopo un errore o un riavvio | Non più (chiuso da B-81) | — |
|
||||
| [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** | Chiuso da B-79 e B-87 |
|
||||
| [LIM-02] RBF di una sola forma | Non più il pagamento senza resto (B-79) né il resto sotto soglia (B-87); resta una transazione che esaurisce resto o riserva | Visibile in `rbf_bump_failed`; resta in attesa senza abbandono |
|
||||
| [LIM-03] Attesa in `closing` senza limite di tempo | **Sì, in caso di congestione** | Resta senza limite di tempo per scelta ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)); con B-78 e B-87 finisce quando la rete decide |
|
||||
| [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** | Da chiudere con la prova su rete reale ([operativita.md](operativita.md#prova-su-rete-reale)) prima di aprire al pubblico |
|
||||
| [LIM-10] Riorganizzazioni non gestite | **Sì** — una scommessa sparita rende il round impagabile | Chiuso da B-82 e B-85 (la pausa e il registro sono attivi; resta il pagamento, B-82); resta [LIM-24] |
|
||||
| [LIM-11], [LIM-12] Quorum facoltativo e parziale | **Sì** — un solo server decide conferme e abbandoni | Chiusi da B-83 e B-84 (resta la lettura delle monete del montepremi, B-82) |
|
||||
| ~~[LIM-10]~~ Riorganizzazioni non gestite | Non più (chiuso da B-82 e B-85) | Resta [LIM-24] |
|
||||
| [LIM-11], ~~[LIM-12]~~ Quorum facoltativo e parziale | Non più (chiusi da B-82, B-83 e B-84) | Resta la scelta dell'operatore di server indipendenti ([LIM-11]) |
|
||||
| [LIM-13] Estrazione manipolabile | **Sì** — per un giocatore che mina | Chiuso da B-73; resta [LIM-25] |
|
||||
| [LIM-14] Header senza difficoltà minima | **Sì** — un header falso congela ogni estrazione | Chiuso da B-74 per il blocco; resta il costo basso di un header a difficoltà minima |
|
||||
| [LIM-06] Token admin unico | No per il funzionamento, ma è il rischio di sicurezza più ampio | Invariato |
|
||||
@@ -50,38 +48,31 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
stesso vincitore ([RND-36](ciclo-di-vita-del-round.md)).
|
||||
|
||||
- **[LIM-02]** **L'RBF gestisce una sola forma di transazione**: un'unica uscita
|
||||
di resto verso il mittente, abbastanza grande da assorbire l'aumento. Non c'è
|
||||
il ripiego «aggiungi un'altra moneta». Ogni aumento costa almeno 1 sat/vB
|
||||
sull'intera dimensione della transazione (141 sat per una transazione
|
||||
con un ingresso e due uscite) e si toglie dal resto; quando l'aumento supera
|
||||
il resto, `bump_fee` solleva `RbfError`. Inoltre `bump_fee` **non controlla
|
||||
che il resto ridotto resti sopra la soglia di polvere**: con un resto vicino
|
||||
al minimo di 294 sat ([ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md)) già il primo aumento produce un'uscita sotto
|
||||
soglia, che le regole di inoltro standard rifiutano. **Il pagamento del
|
||||
round, nel caso normale, non ha alcun resto** (spende esattamente il
|
||||
montepremi del round, e un resto sotto soglia va alla commissione), quindi
|
||||
non è accelerabile affatto.
|
||||
- *Impatto:* con la rete congestionata una transazione a 1 sat/vB può
|
||||
restare in mempool indefinitamente. Poiché il riconciliatore abbandona solo
|
||||
ciò che il server **non conosce**, una transazione ferma ma presente in
|
||||
mempool non viene mai abbandonata: una scommessa così blocca il round in
|
||||
`closing`, un pagamento così lo blocca in `paying_out`. `RbfBumper`
|
||||
registra un errore ogni 30s.
|
||||
- *Mitigazione attuale:* resto minimo garantito sulle transazioni utente;
|
||||
tetto al fee rate (`MAX_FEE_RATE_SAT_VB`); abbandono dopo 6 ore per ciò che
|
||||
sparisce dalla rete; ritentativo automatico del pagamento dopo un
|
||||
abbandono.
|
||||
- *Stato:* da chiudere con B-87 (la riserva del pagamento, B-79, c'è).
|
||||
- *Per chiuderlo:* ogni pagamento porta un'uscita di riserva che
|
||||
l'accelerazione riduce ([ADR-17](adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md), B-79); un aumento che lascerebbe un
|
||||
resto sotto `DUST_LIMIT_SATS` elimina il resto e ne versa il valore in
|
||||
commissione, e quella sostituzione è l'ultima ([ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md), B-87); ogni
|
||||
`RbfError` e ogni sostituzione rifiutata scrivono `rbf_bump_failed` in
|
||||
audit. Resta accettato, dopo il piano: nessun ripiego «aggiungi un'altra
|
||||
moneta» né CPFP; una transazione che ha esaurito resto o riserva e non
|
||||
conferma nemmeno al tetto del fee rate resta in attesa, visibile, e non
|
||||
viene abbandonata ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)). Il docstring di `bump_fee` cita ancora come
|
||||
esempio «una scommessa senza resto», caso che dopo B-62 non esiste più.
|
||||
riducibile — il resto verso il mittente per scommesse e prelievi, l'uscita
|
||||
di riserva per il pagamento ([RND-38](ciclo-di-vita-del-round.md#4--pagamento), B-79).
|
||||
Non c'è il ripiego «aggiungi un'altra moneta» né il CPFP. Ogni aumento costa
|
||||
almeno 1 sat/vB sull'intera dimensione della transazione (141 sat per una
|
||||
transazione con un ingresso e due uscite) e si toglie da quell'uscita; se
|
||||
quanto resta sarebbe sotto la soglia di polvere l'uscita viene eliminata e
|
||||
il suo valore va in commissione, e quella sostituzione è l'ultima (B-87,
|
||||
[ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md)); se
|
||||
nemmeno l'intera uscita raggiunge l'aumento minimo, o non esiste, `bump_fee`
|
||||
solleva `RbfError`.
|
||||
- *Impatto:* con la rete congestionata una transazione a 1 sat/vB che ha
|
||||
esaurito resto o riserva può restare in mempool indefinitamente. Poiché il
|
||||
riconciliatore abbandona solo ciò che il server **non conosce**, una
|
||||
transazione ferma ma presente in mempool non viene mai abbandonata: una
|
||||
scommessa così blocca il round in `closing`, un pagamento così lo blocca
|
||||
in `paying_out`.
|
||||
- *Mitigazione attuale:* la riserva del pagamento è dimensionata per circa
|
||||
10 sat/vB; resto minimo garantito sulle transazioni utente; tetto al fee
|
||||
rate (`MAX_FEE_RATE_SAT_VB`); ogni accelerazione impossibile o rifiutata
|
||||
scrive `rbf_bump_failed` (una volta per transazione e motivo); abbandono
|
||||
dopo 6 ore per ciò che sparisce dalla rete; ritentativo automatico del
|
||||
pagamento dopo un abbandono.
|
||||
- *Stato:* accettato dopo B-79 e B-87: una transazione che ha esaurito resto
|
||||
o riserva e non conferma nemmeno al tetto del fee rate resta in attesa,
|
||||
visibile, e non viene abbandonata ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)).
|
||||
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee` · `app/wallet/psbt_builder.py:build_payout_transaction` · `app/tx/reconcile.py:reconcile_once`
|
||||
|
||||
@@ -95,7 +86,7 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
nuovo round si apre nel frattempo.
|
||||
- *Mitigazione attuale:* lo stato è visibile (`closing`) e il pannello
|
||||
«Transazioni pendenti» mostra la scommessa che trattiene il round.
|
||||
- *Stato:* B-78 e B-77 chiusi; resta da chiudere B-87 (resto minimo), e per scelta l'assenza di un limite di tempo.
|
||||
- *Stato:* B-78, B-77 e B-87 chiusi; resta per scelta l'assenza di un limite di tempo.
|
||||
- *Per chiuderlo:* con B-78 l'attesa finisce quando la scommessa conferma
|
||||
(una qualsiasi delle sue versioni, B-77) o quando c'è la prova che non
|
||||
confermerà mai ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)), non dopo un tempo fisso; con B-87 la scommessa
|
||||
@@ -160,10 +151,10 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
blocco per-utente li serializza, non li limita); il cambio password
|
||||
permette di provare password attuali senza ritardi a chi possiede già un
|
||||
token valido; le segnalazioni anonime possono riempire il database.
|
||||
- *Stato:* da chiudere in parte con B-94 (cambio password limitato e
|
||||
registrato, segnalazioni di bug limitate per IP, lunghezza massima di 256
|
||||
caratteri sui campi delle credenziali). Scommesse, prelievi ed endpoint
|
||||
admin restano senza limite di frequenza anche dopo il piano.
|
||||
- *Stato:* chiuso in parte da B-94 (cambio password limitato e registrato,
|
||||
segnalazioni di bug limitate per IP, lunghezza massima di 256 caratteri sui
|
||||
campi delle credenziali). Scommesse, prelievi ed endpoint admin restano
|
||||
senza limite di frequenza.
|
||||
- *Per chiuderlo:* un limitatore per utente e per IP sugli endpoint
|
||||
interessati, sullo stesso modello di `RateLimiter`/`RollingQuota`.
|
||||
|
||||
@@ -174,9 +165,9 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
(le scrive solo il reset da parte dell'admin).
|
||||
- *Impatto:* dopo una compromissione non c'è traccia di quando e da dove sia
|
||||
avvenuto l'accesso.
|
||||
- *Stato:* da chiudere in parte con B-92 (`user_registered`, con indice di
|
||||
derivazione e indirizzo) e B-94 (`password_changed`). Login riusciti e
|
||||
falliti restano senza voce di audit dopo il piano.
|
||||
- *Stato:* chiuso in parte da B-92 (`user_registered`, con indice di
|
||||
derivazione e indirizzo) e da B-94 (`password_changed`). Login riusciti e
|
||||
falliti restano senza voce di audit.
|
||||
- *Per chiuderlo:* eventi di audit per questi casi (vedi
|
||||
[audit-log.md](audit-log.md)).
|
||||
|
||||
@@ -211,30 +202,11 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
|
||||
### Fiducia nella rete
|
||||
|
||||
- **[LIM-10]** **Riorganizzazioni non gestite, depositi a 1 conferma.** Un
|
||||
deposito accreditato a 1 conferma può sparire con una riorganizzazione; la
|
||||
punta della catena non regredisce mai, un header concorrente alla stessa
|
||||
altezza è ignorato e nessuna riorganizzazione viene rilevata.
|
||||
- *Impatto:* il caso grave è una scommessa **già confermata** che sparisce:
|
||||
un utente scommette con un deposito a 1 conferma e, se perde, sostituisce
|
||||
il deposito con una catena privata. La scommessa non è più in catena, ma il
|
||||
partecipante resta `confirmed` e contato, e il pagamento fallisce per
|
||||
sempre con «monete del montepremi insufficienti», con il denaro di tutti
|
||||
gli altri giocatori bloccato. Anche le uscite coinbase sono accreditate a 1
|
||||
conferma, mentre maturano a 120.
|
||||
- *Mitigazione attuale:* una moneta che sparisce dall'elenco del server viene,
|
||||
previo quorum, segnata come spesa all'esterno e tolta dal saldo; se
|
||||
riappare viene ripristinata. Una scommessa **non ancora inviata** costruita
|
||||
su una moneta scomparsa è rifiutata dalla rete e annullata; per una
|
||||
scommessa già confermata non esiste alcuna mitigazione (la versione
|
||||
precedente di questa voce diceva il contrario).
|
||||
- *Stato:* da chiudere con B-82; le riorganizzazioni sono già rilevate, registrate e (se profonde) in pausa (B-85).
|
||||
- *Per chiuderlo:* depositi a `deposit_confirmations` conferme e coinbase a
|
||||
120 ([ADR-06](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md)); riorganizzazioni rilevate, registrate e, se profonde,
|
||||
con pausa; monete del round verificate prima del pagamento, con i
|
||||
partecipanti spariti esclusi ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)). Resta accettato il 51% ([LIM-24]).
|
||||
|
||||
*Codice:* `app/deposits/service.py:find_utxos_missing_from` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/electrum/listener.py:ElectrumListener._apply_header`
|
||||
- ~~[LIM-10]~~ Rimosso: chiuso da B-82 e B-85. I depositi si accreditano a
|
||||
`deposit_confirmations` conferme (le coinbase a 120), le riorganizzazioni sono
|
||||
seguite, registrate e, da profondità 2, mettono in pausa; le monete del round
|
||||
si verificano con il quorum prima del pagamento e i partecipanti spariti
|
||||
restano fuori ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)). Resta accettato il 51% ([LIM-24]).
|
||||
|
||||
- **[LIM-11]** **I server del quorum li sceglie l'operatore.** Il server
|
||||
rifiuta di partire con meno di 2 server di riserva e ogni corroborazione
|
||||
@@ -253,26 +225,9 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority`
|
||||
|
||||
- **[LIM-12]** **Non tutte le letture di rete sono corroborate.** Passano dal
|
||||
solo server attivo: l'elenco delle monete del montepremi usato per il
|
||||
pagamento.
|
||||
- *Impatto:* un server ostile potrebbe far scegliere al pagamento monete che
|
||||
non esistono (il pagamento verrebbe rifiutato dalla rete) o nasconderne
|
||||
di vere (il pagamento fallirebbe e verrebbe ritentato).
|
||||
- *Mitigazione attuale:* l'abbandono richiede una prova ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)) e l'invio
|
||||
arriva a tutti i server; un invio basato su monete inesistenti è rifiutato
|
||||
dalla rete.
|
||||
- *Stato:* da chiudere con B-82 (monete del round prima del pagamento);
|
||||
conferme e ripristini sono chiusi da B-83.
|
||||
- *Per chiuderlo:* una conferma vale quando il quorum restituisce una prova
|
||||
merkle valida (`blockchain.transaction.get_merkle`) contro un header che
|
||||
possediamo; il ripristino richiede lo stesso quorum di un accredito;
|
||||
l'abbandono richiede la prova di [ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md); le monete del round si
|
||||
verificano con il quorum prima di costruire il pagamento ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)); ogni
|
||||
transazione accettata dal server attivo viene inviata anche a tutti i
|
||||
server di riserva.
|
||||
|
||||
*Codice:* `app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout`
|
||||
- ~~[LIM-12]~~ Rimosso: chiuso da B-83 e B-82. Conferme (prova merkle),
|
||||
reintegri, abbandoni e le monete del montepremi usate per il pagamento sono
|
||||
tutti decisi dal quorum degli altri server, mai dal solo server attivo.
|
||||
|
||||
- **[LIM-13]** **L'estrazione attuale è manipolabile da chi produce o
|
||||
trasmette i blocchi.**
|
||||
@@ -393,19 +348,10 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
|
||||
### Configurazione ed economia
|
||||
|
||||
- **[LIM-16]** **Solo i tempi del round sono fotografati all'apertura.** Costo
|
||||
della scommessa, fee rate e `fee_address` sono letti dal vivo.
|
||||
- *Impatto:* cambiare il costo a metà round fa pagare poste diverse a
|
||||
giocatori con la stessa probabilità, e cambia subito il prelievo minimo;
|
||||
cambiare `fee_address` a metà round devia la commissione del round in
|
||||
corso (la modifica è comunque registrata in audit).
|
||||
- *Stato:* da chiudere con B-95.
|
||||
- *Per chiuderlo:* fotografare costo, `fee_address` e `draw_confirmations`
|
||||
sulla riga del round, come già si fa per durata e pausa ([ADR-10](adr/ADR-10-parametri-round-fotografati-all-apertura.md)). Il fee
|
||||
rate resta letto dal vivo per scelta, e il prelievo minimo segue il costo
|
||||
corrente della configurazione ([ADR-20](adr/ADR-20-prelievo-minimo-costo-corrente-scommessa.md)).
|
||||
|
||||
*Codice:* `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/withdrawals/service.py:request_withdrawal`
|
||||
- ~~[LIM-16]~~ Rimosso: chiuso da B-95. Costo della scommessa, `fee_address` e
|
||||
tetto al montepremi sono fotografati sulla riga del round all'apertura, come
|
||||
durata e pausa ([ADR-10](adr/ADR-10-parametri-round-fotografati-all-apertura.md)); il fee rate resta letto dal vivo per scelta e il prelievo
|
||||
minimo segue il costo corrente della configurazione ([ADR-20](adr/ADR-20-prelievo-minimo-costo-corrente-scommessa.md)).
|
||||
|
||||
- **[LIM-17]** **Nessun consolidamento automatico del montepremi.** Monete
|
||||
avanzate e resti si accumulano sugli indirizzi del montepremi (fino a B-80
|
||||
@@ -468,21 +414,7 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
|
||||
*Codice:* `app/static/i18n.js:t`
|
||||
|
||||
- **[LIM-22]** **L'API di amministrazione e alcuni errori di contorno non usano
|
||||
il formato d'errore strutturato.** Gli endpoint `/admin/*` rispondono con un
|
||||
`detail` stringa (per esempio `"invalid admin token"`, `"user not found"`), e
|
||||
così il 503 del canale SSE oltre il tetto; è coerente con un pannello non
|
||||
tradotto, ma non è dichiarato altrove.
|
||||
- *Stato:* da chiudere con B-94.
|
||||
- *Per chiuderlo:* errori strutturati ovunque: `/admin/*`, il 503 del canale
|
||||
SSE e gli errori generati dal framework usano lo stesso formato
|
||||
`{code, message, params}` ([ADR-47](adr/ADR-47-l-api-parla-sola-lingua-risponde.md)).
|
||||
- ~~[LIM-22]~~ Rimosso: chiuso da B-94. Gli endpoint `/admin/*`, il `503` del canale SSE e gli errori del framework (`401`, `404`, `405`, `422`) rispondono con il formato strutturato `{code, message, params}` ([API-16](api.md#contratto-degli-errori), [ADR-47](adr/ADR-47-l-api-parla-sola-lingua-risponde.md)).
|
||||
|
||||
*Codice:* `app/api/routes/admin.py:require_admin` · `app/api/routes/rounds.py:round_stream`
|
||||
- ~~[LIM-23]~~ Rimosso: chiuso da B-94. `GET /admin/users` è paginato ([API-33](api.md)).
|
||||
|
||||
- **[LIM-23]** **`GET /admin/users` non ha limite né paginazione**, a
|
||||
differenza degli altri elenchi admin.
|
||||
- *Impatto:* con molti utenti la risposta cresce senza limite.
|
||||
- *Stato:* da chiudere con B-94 (paginazione dell'elenco utenti).
|
||||
|
||||
*Codice:* `app/api/routes/admin.py:list_users`
|
||||
+58
-86
@@ -53,8 +53,8 @@ leggono dalla rete, non da una tabella:
|
||||
| Riserva del pool (il "pool" unico di oggi) | `m/84'/746'/0'/1/0` | nessuna tabella; `app/wallet/hd.py:derive_pool_key` |
|
||||
| Montepremi di un round | `m/84'/746'/0'/2/<round_id>` | `rounds.pool_address` ([DAT-60]) |
|
||||
|
||||
Il diagramma descrive lo schema **atteso**; le colonne e la tabella marcate
|
||||
"da implementare" non esistono ancora (vedi le tabelle di dettaglio).
|
||||
Il diagramma descrive lo schema **atteso**; ciò che non esiste ancora è
|
||||
marcato *da implementare* nelle tabelle di dettaglio.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
@@ -98,15 +98,16 @@ erDiagram
|
||||
datetime closed_at
|
||||
int duration_seconds "snapshot"
|
||||
int cooldown_seconds "snapshot"
|
||||
bigint bet_amount_sats "snapshot (da implementare)"
|
||||
string fee_address "snapshot (da implementare)"
|
||||
int draw_confirmations "snapshot (da implementare)"
|
||||
bigint bet_amount_sats "snapshot"
|
||||
string fee_address "snapshot"
|
||||
bigint max_round_pool_sats "snapshot"
|
||||
int draw_confirmations "snapshot"
|
||||
string pool_address
|
||||
string server_seed_enc "cifrato fino alla rivelazione (da implementare)"
|
||||
string server_seed "in chiaro solo dopo la rivelazione (da implementare)"
|
||||
string server_seed_hash "da implementare"
|
||||
string participants_hash "da implementare"
|
||||
int draw_height "da implementare"
|
||||
string server_seed_enc "cifrato fino alla rivelazione"
|
||||
string server_seed "in chiaro solo dopo la rivelazione"
|
||||
string server_seed_hash
|
||||
string participants_hash
|
||||
int draw_height
|
||||
datetime drawing_started_at
|
||||
int draw_block_height
|
||||
string draw_block_hash
|
||||
@@ -117,7 +118,7 @@ erDiagram
|
||||
bigint fee_amount_sats
|
||||
bigint bump_reserve_sats
|
||||
string payout_txid
|
||||
datetime payout_blocked_at "da implementare"
|
||||
datetime payout_blocked_at
|
||||
}
|
||||
round_participants {
|
||||
int id PK
|
||||
@@ -125,10 +126,10 @@ erDiagram
|
||||
int user_id FK
|
||||
bigint bet_amount_sats
|
||||
string bet_txid
|
||||
int bet_vout "da implementare"
|
||||
int bet_vout
|
||||
datetime broadcast_at
|
||||
datetime confirmed_at
|
||||
int draw_position "UK con round_id (da implementare)"
|
||||
int draw_position "UK con round_id"
|
||||
string status "building broadcast confirmed reorged_out"
|
||||
}
|
||||
round_config {
|
||||
@@ -140,11 +141,11 @@ erDiagram
|
||||
int draw_animation_seconds
|
||||
int fee_rate_sat_vb
|
||||
int rbf_timeout_seconds
|
||||
int deposit_confirmations "da implementare"
|
||||
int draw_confirmations "da implementare"
|
||||
bigint max_round_pool_sats "da implementare"
|
||||
int deposit_confirmations
|
||||
int draw_confirmations
|
||||
bigint max_round_pool_sats
|
||||
bool paused
|
||||
string paused_reason "da implementare"
|
||||
string paused_reason
|
||||
datetime updated_at
|
||||
}
|
||||
pending_transactions {
|
||||
@@ -345,9 +346,7 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
|
||||
*Codice:* `app/deposits/service.py:find_new_credit_candidates` ·
|
||||
`app/deposits/service.py:credit_confirmed_utxos` ·
|
||||
`app/electrum/listener.py:ElectrumListener.refresh_user` ·
|
||||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* si accredita ogni output
|
||||
con `height > 0` (1 conferma), coinbase comprese.
|
||||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`, `tests/unit/test_deposit_confirmations.py`
|
||||
|
||||
- **[DAT-11]** Gli UTXO dei montepremi e della riserva **non** sono in
|
||||
`utxo_events`: non appartengono a un utente. Il pagamento di un round spende
|
||||
@@ -360,11 +359,10 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
|
||||
lo stesso motivo l'abbandono di un pagamento non ha input da liberare.
|
||||
*Codice:* `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
`app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie fra tutti gli
|
||||
UTXO confermati dell'indirizzo del round (per un round vecchio, del
|
||||
condiviso), lette dal solo server attivo, e non solo fra le uscite delle
|
||||
scommesse dei partecipanti.
|
||||
`app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_pool_verification.py::test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else`
|
||||
Un round aperto prima dell'indirizzo per round (il cui montepremi è
|
||||
l'indirizzo di riserva condiviso) conserva la scelta di prima: le monete
|
||||
confermate dell'indirizzo, lette dal server attivo.
|
||||
|
||||
- **[DAT-12]** `spent_txid` ha tre significati:
|
||||
- `NULL`: l'output è spendibile e conta nel saldo;
|
||||
@@ -429,8 +427,9 @@ restano nella tabella come storico.
|
||||
| `closed_at` | DateTime | sì | `NULL` | Chiusura delle scommesse, poi conferma del pagamento ([RND-28](ciclo-di-vita-del-round.md)) | `app/rounds/scheduler.py:RoundScheduler._tick`; `app/rounds/confirmation.py:_on_payout_confirmed` |
|
||||
| `duration_seconds` | Integer | no | `600` (anche `server_default`) | Durata di **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` |
|
||||
| `cooldown_seconds` | Integer | no | `30` (anche `server_default`) | Pausa dopo **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` |
|
||||
| `bet_amount_sats` | BigInteger | no | backfill da `round_config` nella migrazione | Costo della scommessa di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` |
|
||||
| `fee_address` | String(128) | no | backfill da `round_config` nella migrazione | Destinatario della commissione di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` |
|
||||
| `bet_amount_sats` | BigInteger | no | backfill da `round_config` nella migrazione | Costo della scommessa di **questo** round, copiato all'apertura ([DAT-61]) | `open_new_round_if_needed` |
|
||||
| `fee_address` | String(128) | no | backfill da `round_config` nella migrazione | Destinatario della commissione di **questo** round, copiato all'apertura ([DAT-61]) | `open_new_round_if_needed` |
|
||||
| `max_round_pool_sats` | BigInteger | no | `0` (`server_default`); backfill `0` | Tetto alla somma delle scommesse di **questo** round, copiato all'apertura ([DAT-61], [DAT-27]); `0` = nessun tetto | `open_new_round_if_needed` |
|
||||
| `draw_confirmations` | Integer | no | `3` (`server_default`) | Conferme richieste sul blocco di estrazione di **questo** round, copiate all'apertura ([DAT-61]) | `open_new_round_if_needed` |
|
||||
| `pool_address` | String(128) | no | `""` solo fino alla fine della transazione di apertura; backfill con l'indirizzo di riserva per i round esistenti | Indirizzo del montepremi di **questo** round ([DAT-60]) | `open_new_round_if_needed` |
|
||||
| `server_seed_enc` | Text | sì | `NULL` | Il segreto del round cifrato con Fernet, con la stessa chiave della master xprv; scritto all'apertura ([DAT-64]) | `open_new_round_if_needed` |
|
||||
@@ -443,12 +442,12 @@ restano nella tabella come storico.
|
||||
| `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_complete_draw` |
|
||||
| `seed_int` | String(128) | sì | `NULL` | Il seme in decimale (fino a 78 cifre, per questo è una stringa). Il valore intero di `sha256(server_seed ‖ block_hash ‖ participants_hash)`, con la codifica definita in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (RND-17) | `_complete_draw` |
|
||||
| `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_complete_draw` |
|
||||
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`, `_complete_draw`; `RoundScheduler._trigger_payout` (atteso, B-82) |
|
||||
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`, `_complete_draw`; `RoundScheduler._trigger_payout` (la ridefinisce la verifica, solo al ribasso) |
|
||||
| `winner_amount_sats` | BigInteger | sì | `NULL` | Quanto riceve il vincitore (70% meno la commissione di rete) | `RoundScheduler._trigger_payout` |
|
||||
| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` nello schema atteso | `_trigger_payout` |
|
||||
| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` | `_trigger_payout` |
|
||||
| `bump_reserve_sats` | BigInteger | sì | `NULL` | Output di riserva per l'RBF del pagamento, verso l'indirizzo di riserva ([DAT-73]) | `_trigger_payout`; `app/tx/broadcast.py:bump_fee` |
|
||||
| `payout_txid` | String(64) | sì | `NULL` | Txid corrente della transazione di pagamento | `_trigger_payout`; `_retarget_txid_references`; `app/tx/reconcile.py:_abandon` |
|
||||
| `payout_blocked_at` | DateTime | sì | `NULL` | Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) — *da implementare (B-82)* | `_trigger_payout`; `app/api/routes/admin.py:resume_lottery` |
|
||||
| `payout_blocked_at` | DateTime | sì | `NULL` | Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) | `_trigger_payout`; `app/api/routes/admin.py:resume_lottery` |
|
||||
|
||||
- ~~**[DAT-15]**~~ Assorbito da
|
||||
[RND-02](ciclo-di-vita-del-round.md): i valori di `status` e le transizioni
|
||||
@@ -536,11 +535,7 @@ restano nella tabella come storico.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
`app/tx/broadcast.py:_retarget_txid_references` · `app/tx/reconcile.py:_abandon` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`,
|
||||
`tests/unit/test_reconcile.py`
|
||||
*Stato:* da implementare (B-96) · *Oggi:* l'abbandono azzera solo
|
||||
`payout_txid` e lascia `winner_amount_sats`, `fee_amount_sats` e
|
||||
`bump_reserve_sats` del tentativo abbandonato (le riscrive il tentativo
|
||||
successivo).
|
||||
`tests/unit/test_reconcile.py`, `tests/unit/test_state_consistency.py`
|
||||
|
||||
- **[DAT-60]** Ogni round ha il proprio indirizzo del montepremi,
|
||||
`pool_address`, derivato all'apertura da `m/84'/746'/0'/2/<id>` e scritto
|
||||
@@ -563,28 +558,26 @@ restano nella tabella come storico.
|
||||
tabella con righe; il codice lo riscrive prima del commit e ogni consumatore
|
||||
rifiuta un indirizzo vuoto.
|
||||
|
||||
- **[DAT-61]** `bet_amount_sats`, `fee_address` e `draw_confirmations` di
|
||||
`rounds` sono una **copia** dei valori di `round_config` presa quando il
|
||||
- **[DAT-61]** `bet_amount_sats`, `fee_address`, `max_round_pool_sats` e
|
||||
`draw_confirmations` di `rounds` sono una **copia** dei valori di `round_config` presa quando il
|
||||
round si apre, nella stessa transazione dell'inserimento, e non cambiano
|
||||
più. Le scommesse del round costano `rounds.bet_amount_sats`, il suo
|
||||
pagamento paga la commissione a `rounds.fee_address`, la sua estrazione
|
||||
attende `rounds.draw_confirmations` conferme. La regola di dominio è in
|
||||
attende `rounds.draw_confirmations` conferme, le scommesse oltre il tetto
|
||||
`rounds.max_round_pool_sats` sono rifiutate. La regola di dominio è in
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-95). La
|
||||
migrazione riempie le righe esistenti con i valori attuali di
|
||||
`round_config` ([DAT-55]).
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed` ·
|
||||
`app/bets/service.py:place_bet` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* —
|
||||
*Stato:* da implementare (B-95) · *Oggi:* `draw_confirmations` è già copiato
|
||||
all'apertura (B-73); `place_bet` e `_trigger_payout`
|
||||
leggono `bet_amount_sats` e `fee_address` da `round_config` al momento
|
||||
dell'uso, quindi una modifica a metà round cambia il round in corso.
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
*Test:* `tests/unit/test_round_snapshot.py`, `tests/unit/test_rounds_service.py`, `tests/unit/test_migrations.py`
|
||||
|
||||
- **[DAT-64]** Il segreto del round sono 32 byte casuali generati con
|
||||
`secrets.token_bytes(32)`. All'inserimento del round si scrivono insieme
|
||||
`server_seed_hash` (`sha256` di quei 32 byte, esadecimale minuscolo) e
|
||||
`server_seed_enc`: il segreto **cifrato con Fernet** usando la stessa chiave
|
||||
che cifra la master xprv (`XPRV_ENCRYPTION_KEY`, dopo B-91 il file di
|
||||
che cifra la master xprv (il file di cifratura,
|
||||
[sicurezza.md](sicurezza.md#sec-53) SEC-53). Il segreto in chiaro non viene
|
||||
mai scritto nel database prima della rivelazione: `server_seed` resta
|
||||
`NULL` e si valorizza (esadecimale minuscolo, 64 caratteri) nella stessa
|
||||
@@ -622,10 +615,8 @@ restano nella tabella come storico.
|
||||
database**. Il valore scritto è sempre minore o uguale al precedente.
|
||||
La regola di dominio (quorum, ripartizione su ciò che c'è) è in
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-82).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* —
|
||||
*Stato:* da implementare (B-82) · *Oggi:* `pool_amount_sats` non cambia dopo
|
||||
l'estrazione, e un pagamento che non trova tutte le scommesse fallisce con
|
||||
"insufficient pool UTXOs" a ogni tentativo.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
|
||||
*Test:* `tests/unit/test_pool_verification.py`
|
||||
|
||||
- **[DAT-68]** `payout_blocked_at` si scrive quando la verifica di [DAT-67]
|
||||
trova assente la scommessa del **vincitore**: il round resta `paying_out`,
|
||||
@@ -634,8 +625,8 @@ restano nella tabella come storico.
|
||||
([DAT-69]). Torna `NULL` solo con `POST /admin/resume` ([api.md](api.md)),
|
||||
dopo di che la verifica si ripete al tentativo successivo.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
`app/api/routes/admin.py:resume_lottery` · *Test:* —
|
||||
*Stato:* da implementare (B-82) · *Oggi:* la colonna non esiste.
|
||||
`app/api/routes/admin.py:resume_lottery` ·
|
||||
*Test:* `tests/unit/test_pool_verification.py::test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery`, `tests/unit/test_pool_verification.py::test_after_the_resume_the_verification_repeats`, `tests/unit/test_admin.py::test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain`
|
||||
|
||||
- **[DAT-73]** Ogni pagamento ha un output di **riserva** verso l'indirizzo
|
||||
di riserva (`m/84'/746'/0'/1/0`), di valore
|
||||
@@ -691,7 +682,7 @@ Una scommessa: un utente in un round.
|
||||
broadcast --> confirmed: _on_bet_confirmed
|
||||
building --> [*]: cancellata (_release_failed_bet / _abandon)
|
||||
broadcast --> [*]: cancellata (_abandon)
|
||||
confirmed --> reorged_out: verifica prima del pagamento (da implementare)
|
||||
confirmed --> reorged_out: verifica prima del pagamento
|
||||
confirmed --> [*]
|
||||
reorged_out --> [*]
|
||||
```
|
||||
@@ -703,7 +694,7 @@ Una scommessa: un utente in un round.
|
||||
| `building`, `broadcast` | `confirmed` | `app/bets/confirmation.py:_on_bet_confirmed` |
|
||||
| `building` | cancellata | `app/bets/service.py:_release_failed_bet` (broadcast rifiutato esplicitamente) |
|
||||
| `building`, `broadcast` | cancellata | `app/tx/reconcile.py:_abandon` (abbandono con prova) |
|
||||
| `confirmed` | `reorged_out` | `app/rounds/scheduler.py:RoundScheduler._trigger_payout` (da implementare) |
|
||||
| `confirmed` | `reorged_out` | `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` (da `_trigger_payout`), per ogni partecipante tranne il vincitore ([RND-39](ciclo-di-vita-del-round.md#4--pagamento)) |
|
||||
|
||||
Se il compare-and-set di [DAT-16] fallisce, la riga `building` non arriva
|
||||
nemmeno al commit. Un broadcast dall'esito ambiguo (timeout, connessione
|
||||
@@ -713,10 +704,7 @@ Una scommessa: un utente in un round.
|
||||
`app/bets/service.py:_release_failed_bet` · `app/tx/reconcile.py:_promote` ·
|
||||
`app/tx/reconcile.py:_abandon` · `app/bets/confirmation.py:_on_bet_confirmed` ·
|
||||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_confirmation.py`,
|
||||
`tests/unit/test_reconcile.py`
|
||||
*Stato:* da implementare (B-82, B-76) · *Oggi:* `reorged_out` non esiste (un
|
||||
partecipante confermato la cui scommessa sparisce resta `confirmed`), e
|
||||
qualunque eccezione del broadcast cancella la riga come un rifiuto.
|
||||
`tests/unit/test_reconcile.py`, `tests/unit/test_pool_verification.py`
|
||||
|
||||
- **[DAT-25]** `broadcast_at` è l'istante della fase 1 di `place_bet`, appena
|
||||
prima del broadcast, e non viene mai riscritto (nemmeno da un bump RBF). Non
|
||||
@@ -754,21 +742,18 @@ Una scommessa: un utente in un round.
|
||||
moltiplicando il numero di partecipanti per il costo (B-11).
|
||||
*Codice:* `app/bets/service.py:place_bet` ·
|
||||
`app/wallet/psbt_builder.py:build_signed_transaction` ·
|
||||
`app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_bets.py`
|
||||
*Stato:* da implementare (B-95) · *Oggi:* il costo è
|
||||
`round_config.bet_amount_sats` letto al momento della scommessa.
|
||||
`app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py`
|
||||
|
||||
- **[DAT-27]** Il numero di righe di un round, contando tutti gli stati, non
|
||||
supera `MAX_PARTICIPANTS_PER_ROUND` (400): la 401ª scommessa è rifiutata con
|
||||
`round_full` prima che si muova denaro (B-52). Con
|
||||
`round_config.max_round_pool_sats` diverso da 0 ([DAT-62]), una scommessa è
|
||||
rifiutata con `round_full` anche quando la somma dei `bet_amount_sats` del
|
||||
round, contando tutti gli stati, ha già raggiunto quel tetto; la regola di
|
||||
`rounds.max_round_pool_sats` (copiato da `round_config` all'apertura,
|
||||
[DAT-61], [DAT-62]) diverso da 0, una scommessa è rifiutata con `round_full`
|
||||
anche quando la somma dei `bet_amount_sats` del round, contando tutti gli
|
||||
stati, ha già raggiunto quel tetto; la regola di
|
||||
dominio è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95).
|
||||
*Codice:* `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py` ·
|
||||
*Test:* `tests/unit/test_bets.py`
|
||||
*Stato:* da implementare (B-95), solo per il tetto sul montepremi · *Oggi:*
|
||||
esiste solo il limite dei 400 partecipanti.
|
||||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py`
|
||||
|
||||
### `round_config`
|
||||
|
||||
@@ -778,16 +763,16 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
|
||||
| Colonna | Tipo | Default | Limiti accettati da `PUT /admin/config` | Significato | Quando ha effetto |
|
||||
|---|---|---|---|---|---|
|
||||
| `id` | Integer PK | autoincrement | — | — | — |
|
||||
| `fee_address` | String(128), non null | nessun default nel modello; la riga nasce con `""` | indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Atteso: dal round **successivo**, copiato all'apertura ([DAT-61]). Oggi: subito, si legge a ogni costruzione del pagamento, quindi vale anche per il round in corso se il suo pagamento non è ancora stato costruito |
|
||||
| `bet_amount_sats` | BigInteger | `1_000_000_000` (10 PLM) | `> 0`, `≤ 100 000 PLM` | Costo della scommessa; anche il prelievo minimo | Atteso: per le scommesse dal round **successivo** ([DAT-61]), per il prelievo minimo subito. Oggi: subito, per le scommesse e i prelievi successivi; le scommesse già piazzate tengono il proprio importo ([DAT-26]) |
|
||||
| `fee_address` | String(128), non null | nessun default nel modello; la riga nasce con `""` | indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Dal round **successivo**, copiato all'apertura ([DAT-61]); il round in corso paga la commissione all'indirizzo con cui si è aperto |
|
||||
| `bet_amount_sats` | BigInteger | `1_000_000_000` (10 PLM) | `> 0`, `≤ 100 000 PLM` | Costo della scommessa; anche il prelievo minimo | Per le scommesse, dal round **successivo** ([DAT-61]); per il prelievo minimo, subito ([ADR-20](adr/ADR-20-prelievo-minimo-costo-corrente-scommessa.md)). Le scommesse già piazzate tengono il proprio importo ([DAT-26]) |
|
||||
| `round_duration_seconds` | Integer | `600` | 30 s – 7 giorni | Durata del round | Dal round **successivo** ([RND-05](ciclo-di-vita-del-round.md)) |
|
||||
| `round_cooldown_seconds` | Integer | `30` | 0 s – 24 h | Pausa dopo la chiusura | Dal round **successivo**: la pausa dopo il round in corso è quella copiata alla sua apertura ([RND-05](ciclo-di-vita-del-round.md)) |
|
||||
| `draw_animation_seconds` | Integer | `20` | 0 – 600 s | Durata minima dell'animazione dell'estrazione nel frontend; non condiziona l'estrazione vera | Subito (letto da `GET /rounds/current`) |
|
||||
| `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (10 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` |
|
||||
| `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (5 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` |
|
||||
| `rbf_timeout_seconds` | Integer | `900` | 60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) |
|
||||
| `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) — *da implementare (B-82)* | Subito, per gli accrediti successivi; gli output già accreditati restano |
|
||||
| `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) | Subito, per gli accrediti successivi; gli output già accreditati restano |
|
||||
| `draw_confirmations` | Integer | `3` | 1 – 20 | Conferme richieste sul blocco di estrazione ([DAT-62]) | Dal round **successivo**, copiato all'apertura ([DAT-61]) |
|
||||
| `max_round_pool_sats` | BigInteger | `0` (tetto disattivato) | `0`, oppure `≥ bet_amount_sats` e `≤ 4 000 000 000 000 000` (400 × 100 000 PLM) | Tetto alla somma delle scommesse di un round ([DAT-27], [DAT-62]) — *da implementare (B-95)* | Subito, anche per il round in corso: vale per le scommesse successive |
|
||||
| `max_round_pool_sats` | BigInteger | `0` (tetto disattivato) | `0`, oppure `≥ bet_amount_sats` e `≤ 4 000 000 000 000 000` (400 × 100 000 PLM) | Tetto alla somma delle scommesse di un round ([DAT-27], [DAT-62]) | Dal round **successivo**, copiato all'apertura ([DAT-61]): un round tiene il tetto con cui si è aperto |
|
||||
| `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa della lotteria | Subito per l'apertura del round successivo; il round in corso non è toccato |
|
||||
| `paused_reason` | String(16) | `NULL` | non modificabile da `PUT /admin/config` | Perché la lotteria è in pausa ([DAT-69]) | Insieme a `paused` |
|
||||
| `updated_at` | DateTime | `utcnow`, aggiornato a ogni modifica (`onupdate`) | — | Ultima modifica della riga | ORM |
|
||||
@@ -810,8 +795,7 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
|
||||
valore uguale a quello attuale non conta come modifica.
|
||||
*Codice:* `app/api/routes/admin.py:update_config` ·
|
||||
`app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-82, B-95), per le ultime due colonne ·
|
||||
*Oggi:* le colonne modificabili sono le prime otto.
|
||||
|
||||
|
||||
- **[DAT-30]** `paused` e `paused_reason` si modificano solo con
|
||||
`POST /admin/pause` e `POST /admin/resume`, che scrivono `lottery_paused` e
|
||||
@@ -819,8 +803,6 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
|
||||
[DAT-69], che scrivono la propria voce di audit.
|
||||
*Codice:* `app/api/routes/admin.py:pause_lottery` ·
|
||||
`app/api/routes/admin.py:resume_lottery` · *Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* le pause automatiche sono
|
||||
`resurrected_tx` (B-78) e `reorg` (B-85); quella di B-82 non esiste ancora.
|
||||
|
||||
- **[DAT-62]** `deposit_confirmations` (predefinito 6) è il numero di
|
||||
conferme dopo cui un output che paga un utente si accredita ([DAT-10]);
|
||||
@@ -834,10 +816,7 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
|
||||
[scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95). La migrazione le
|
||||
aggiunge con un `server_default` temporaneo pari al predefinito ([DAT-55]).
|
||||
*Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-82, B-95) · *Oggi:* `deposit_confirmations` e
|
||||
`max_round_pool_sats` non esistono: i depositi usano 1 conferma e non c'è
|
||||
tetto; `draw_confirmations` esiste.
|
||||
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_migrations.py`
|
||||
|
||||
- **[DAT-69]** `paused_reason` è `NULL` esattamente quando `paused` è falso;
|
||||
con `paused` vero vale uno di questi valori, e nessun altro:
|
||||
@@ -861,9 +840,6 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
|
||||
`app/electrum/listener.py:ElectrumListener._record_reorg` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
`app/tx/reconcile.py:reconcile_once` · *Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-82, B-90) · *Oggi:* si scrivono
|
||||
`operator` (`POST /admin/pause`), `resurrected_tx` (il riconciliatore) e
|
||||
`reorg` (il listener); `payout_blocked` e `START_PAUSED` non hanno ancora chi li scrive.
|
||||
|
||||
- **[DAT-31]** Con `fee_address` vuoto (o di soli spazi) non si apre nessun round
|
||||
(B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la
|
||||
@@ -1274,11 +1250,7 @@ letti con una scansione completa.
|
||||
*Codice:* `app/rounds/service.py:winner_share` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
`app/wallet/psbt_builder.py:build_payout_transaction` ·
|
||||
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie fra
|
||||
tutti gli UTXO confermati dell'indirizzo del round, senza la verifica di
|
||||
[DAT-67]; l'identità con la riserva vale già per i round pagati con tutti
|
||||
e soli i loro ingressi.
|
||||
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py`, `tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there`
|
||||
|
||||
- ~~**[DAT-47]**~~ Assorbito da [RND-12](ciclo-di-vita-del-round.md): l'attesa
|
||||
delle scommesse in volo prima dell'estrazione è comportamento del round. Il
|
||||
|
||||
+42
-47
@@ -44,9 +44,9 @@ flowchart LR
|
||||
Docker con nome `caddy_data` e `caddy_config`, non in `./data`.
|
||||
*Codice:* `docker-compose.yml`, `Caddyfile`
|
||||
|
||||
Oggi il container gira come root, e i file che scrive in `./data` (database,
|
||||
backup, chiave cifrata dagli script) appartengono a root sull'host. Dopo B-91
|
||||
gira come utente non root, con `./data` in `0700` e i file di chiave in
|
||||
Il container gira come utente non root (uid 1000, quello dell'utente dell'host:
|
||||
un servizio `data-permissions`, a ogni avvio, riporta `./data` a quel
|
||||
proprietario), con `./data` in `0700` e i file di chiave in
|
||||
`0600`, e la chiave di cifratura sta in un file a parte
|
||||
([architettura.md](architettura.md) ARC-03, ARC-05;
|
||||
[SEC-51](sicurezza.md#sec-51)); le procedure che scrivono in `./data`
|
||||
@@ -69,9 +69,6 @@ dovranno allora rispettare quel proprietario.
|
||||
`app/config.py:validate_runtime_secrets`,
|
||||
`app/electrum/client.py:parse_endpoints`, `app/wallet/hd.py:_load_account_key` ·
|
||||
*Test:* `tests/unit/test_config.py`, `tests/unit/test_electrum_client.py`
|
||||
*Stato:* da implementare (B-89) · *Oggi:* della master key si controlla
|
||||
solo che il file esista e che `XPRV_ENCRYPTION_KEY` non sia vuota; una
|
||||
chiave sbagliata emerge alla prima derivazione.
|
||||
|
||||
- **[OPS-03]** Il database SQLite lavora in modalità **WAL**: accanto a
|
||||
`plm_lottery.db` esistono `plm_lottery.db-wal` e `plm_lottery.db-shm`, e le
|
||||
@@ -86,7 +83,7 @@ dovranno allora rispettare quel proprietario.
|
||||
[Segnali da tenere d'occhio](#segnali-da-tenere-docchio)).
|
||||
*Codice:* `app/main.py:health`
|
||||
|
||||
Dopo B-92 esiste anche `GET /health/ready`, che risponde 503 se la
|
||||
Esiste anche `GET /health/ready`, che risponde 503 se la
|
||||
connessione Electrum manca, l'ultimo header è vecchio o lo scheduler non gira
|
||||
([api.md](api.md) API-46), ed è il `healthcheck` del container
|
||||
([architettura.md](architettura.md) ARC-61). Un container `unhealthy` **non**
|
||||
@@ -109,7 +106,7 @@ Il file da salvare è `./data/keys/master.xprv.enc`, quello che il container
|
||||
legge. Su questa installazione esiste anche una seconda copia,
|
||||
`./master.xprv.enc` nella radice del progetto, a cui punta il
|
||||
`MASTER_KEY_PATH` di `.env` (B-71): oggi decifrano alla stessa xprv, ma non è
|
||||
il file dell'app. Dopo B-91 la chiave di cifratura non sta più in `.env` ma
|
||||
il file dell'app. La chiave di cifratura non sta più in `.env` ma
|
||||
nel file `./secrets/xprv_encryption.key` ([SEC-53](sicurezza.md#sec-53),
|
||||
[architettura.md](architettura.md) ARC-05), fuori da `./data/`: si salva come
|
||||
oggi la variabile, separata dalla master key.
|
||||
@@ -165,7 +162,7 @@ denaro di oggi.
|
||||
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); c.execute('UPDATE round_config SET paused=1'); c.commit(); print('pausa impostata')"
|
||||
```
|
||||
La modifica non lascia una riga di audit: annotarla altrove.
|
||||
Dopo B-90 si usa invece `START_PAUSED=true` in `.env` ([OPS-27]), che
|
||||
Si usa invece `START_PAUSED=true` in `.env` ([OPS-27]), che
|
||||
scrive anche il motivo della pausa e la riga di audit.
|
||||
5. **Elencare, sempre ad app ferma, i round non chiusi e le transazioni in
|
||||
volo** del backup:
|
||||
@@ -238,8 +235,7 @@ denaro di oggi.
|
||||
Gli utenti registrati dopo il backup spariscono con il ripristino, e i loro
|
||||
indici vengono riassegnati ai prossimi iscritti: il nuovo utente riceve lo
|
||||
**stesso indirizzo** di un utente perso, e con esso i fondi che quello vi
|
||||
aveva depositato. Oggi il codice non ha alcuna protezione: l'unica è la
|
||||
procedura [OPS-28]. Dopo B-90 l'app controlla da sola gli indici successivi
|
||||
aveva depositato. L'app lo previene: controlla da sola gli indici successivi
|
||||
e sospende le registrazioni
|
||||
([REG-15](registrazione-e-depositi.md#registrazione)); la procedura resta il
|
||||
modo di rimuovere la sospensione. Gli utenti persi non si possono
|
||||
@@ -256,13 +252,11 @@ denaro di oggi.
|
||||
("Riprendi lotteria"). Con il valore predefinito `false` l'avvio non tocca
|
||||
la pausa.
|
||||
*Codice:* `app/config.py:Settings`, `app/main.py:lifespan`,
|
||||
`app/rounds/service.py:open_new_round_if_needed` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-90) · *Oggi:* la variabile non esiste; la pausa
|
||||
si imposta a mano nel database (passo 4 di [OPS-07]).
|
||||
`app/rounds/pause.py:pause_by_operator` ·
|
||||
*Test:* `tests/unit/test_restore_gap.py`
|
||||
|
||||
- **[OPS-28]** **Occupare gli indici di utenti persi.** Procedura, ad app
|
||||
ferma, valida oggi e dopo B-90:
|
||||
ferma:
|
||||
1. Trovare il massimo indice assegnato e ricavare gli indirizzi dei 20
|
||||
indici successivi:
|
||||
```bash
|
||||
@@ -286,7 +280,7 @@ denaro di oggi.
|
||||
conoscere il proprio indirizzo, gli si consegna il segnaposto
|
||||
corrispondente con un reset della password ([OPS-19]).
|
||||
|
||||
Dopo B-90 il controllo automatico di
|
||||
Il controllo automatico di
|
||||
[REG-15](registrazione-e-depositi.md#registrazione), al riavvio successivo,
|
||||
non trova più indirizzi usati oltre i segnaposto e le registrazioni
|
||||
riprendono.
|
||||
@@ -395,7 +389,7 @@ container (controllo della chiave, migrazioni) e ciò che uvicorn scrive per
|
||||
conto suo (avvio, richieste, errori di avvio). Vedi
|
||||
[audit-log.md](audit-log.md#log-applicativo).
|
||||
|
||||
Dopo B-92 il log applicativo va anche sull'output del container e si legge
|
||||
Il log applicativo va anche sull'output del container e si legge
|
||||
con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
|
||||
### Electrum irraggiungibile
|
||||
@@ -498,7 +492,9 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
|
||||
- **[OPS-14]**
|
||||
**Sintomi.** `GET /rounds/current` riporta `status: "closing"` a lungo, con
|
||||
`has_pending_bets` vero; nel log compare
|
||||
`has_pending_bets` vero e `closing_waiting_since` valorizzato; dopo 30 minuti oltre la
|
||||
scadenza il registro di audit riceve `closing_stalled` (con i txid ancora in volo), ripetuto
|
||||
ogni 30 minuti ([TX-29](transazioni-e-riconciliazione.md#riconciliazione)); nel log compare
|
||||
`round N: N bet(s) still in flight at close time, waiting` o nulla (il
|
||||
controllo principale non scrive). In "Transazioni pendenti" c'è almeno una
|
||||
riga `bet` di quel round in `building` o `pending`.
|
||||
@@ -561,7 +557,7 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
(`m/84'/746'/0'/1/0`, anche il montepremi dei round aperti prima di B-80) si
|
||||
ottiene con
|
||||
`docker compose exec app python -c "from app.wallet.hd import derive_pool_address; print(derive_pool_address())"`;
|
||||
se ne può controllare il saldo con un explorer. Dopo B-82 un'uscita di
|
||||
se ne può controllare il saldo con un explorer. Un'uscita di
|
||||
scommessa scomparsa dalla catena esclude quel partecipante invece di
|
||||
bloccare il pagamento; solo la scommessa scomparsa del vincitore lo blocca
|
||||
([OPS-30]).
|
||||
@@ -608,15 +604,18 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
### RBF impossibile
|
||||
|
||||
- **[OPS-17]**
|
||||
**Sintomi.** Nel log, ogni 30 secondi per la stessa riga,
|
||||
`could not bump pending_transaction N` con un `RbfError`: "no change output
|
||||
large enough to absorb a fee bump" oppure "already at the maximum fee rate".
|
||||
Nessuna riga di audit. `attempt_count` smette di salire. Un aumento che la
|
||||
rete rifiuta (per esempio perché il resto scenderebbe sotto la soglia di
|
||||
polvere) compare invece come errore di invio nello stesso messaggio.
|
||||
**Causa.** L'RBF riduce solo il resto che torna al mittente; se il resto non
|
||||
basta a coprire l'aumento, o la commissione è già al massimo (10.000 sat/vB),
|
||||
non si può fare. Un pagamento ha un'uscita di riserva
|
||||
**Sintomi.** Una riga `rbf_bump_failed` nel registro di audit (sezione
|
||||
Audit log del pannello, con `pending_transaction_id`, `txid` e `reason`):
|
||||
`change_too_small`, `max_fee_rate` o `replacement_rejected`. Se ne scrive una
|
||||
sola per transazione e motivo; nel log, ogni 30 secondi, resta
|
||||
`could not bump pending_transaction N`. `attempt_count` smette di salire.
|
||||
**Causa.** L'RBF riduce un'unica uscita, il resto che torna al mittente; se
|
||||
l'aumento la porterebbe sotto la soglia di polvere l'uscita viene eliminata
|
||||
e il suo valore va in commissione, e quella sostituzione è l'ultima
|
||||
(`change_too_small` ai tentativi successivi). Non si può fare nemmeno se
|
||||
l'uscita non esiste o non basta all'aumento minimo, o se la commissione è già
|
||||
al massimo (5.000 sat/vB, `max_fee_rate`); `replacement_rejected` è una
|
||||
sostituzione che la rete ha rifiutato esplicitamente. Un pagamento ha un'uscita di riserva
|
||||
(B-79) che l'aumento riduce; la riserva è dimensionata per arrivare a circa
|
||||
10 sat/vB, oltre la quale (o per un pagamento costruito prima di B-79, che
|
||||
non ce l'ha) la transazione non si può accelerare.
|
||||
@@ -628,9 +627,6 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
([OPS-23]); il sistema accrediterà l'uscita della nuova transazione come un
|
||||
normale deposito. Per un pagamento non c'è un'alternativa supportata: la
|
||||
chiave del montepremi non è esportabile ([OPS-24]).
|
||||
Dopo B-87 un aumento che lascerebbe un resto sotto la soglia di polvere
|
||||
toglie il resto invece di fallire, e ogni fallimento scrive
|
||||
`rbf_bump_failed`.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:RbfBumper._tick` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
|
||||
@@ -639,10 +635,12 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
- **[OPS-18]**
|
||||
**Sintomi.** Un utente segnala un deposito che non vede nel saldo.
|
||||
**Cause, in ordine di probabilità.**
|
||||
1. La transazione non ha ancora le conferme richieste: oggi 1, dopo B-82
|
||||
`deposit_confirmations` (predefinito 6) e 120 per un'uscita coinbase
|
||||
1. La transazione non ha ancora le conferme richieste:
|
||||
`deposit_confirmations` (predefinito 6, modificabile da `/admin`) e 120
|
||||
per un'uscita coinbase
|
||||
([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito)).
|
||||
Il deposito non viene mostrato nemmeno come "in attesa" finché non le ha.
|
||||
Nel frattempo compare come deposito *in attesa* nel saldo mostrato
|
||||
(ambra), non spendibile; si accredita da solo al blocco in cui matura.
|
||||
2. La corroborazione fallisce: nel log
|
||||
`N new UTXO(s) for user_id=… not corroborated by the other servers` e
|
||||
spesso `could not corroborate … with any of N other configured server(s)`.
|
||||
@@ -752,9 +750,9 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
copia cifrata con essa: la copia nella radice (`./master.xprv.enc`, B-71)
|
||||
e i backup precedenti del file. Il file scritto dal container appartiene a
|
||||
root con permessi `0644`: `sudo chmod 600 data/keys/master.xprv.enc`.
|
||||
Dopo B-89 gli script scrivono `0600` e rifiutano una xprv i cui
|
||||
Gli script scrivono `0600` e rifiutano una xprv i cui
|
||||
indirizzi non coincidono con quelli del database
|
||||
([SEC-50](sicurezza.md#sec-50)); dopo B-91 la chiave sta in un file e non
|
||||
([SEC-50](sicurezza.md#sec-50)); la chiave sta in un file e non
|
||||
in `.env` ([SEC-53](sicurezza.md#sec-53)).
|
||||
|
||||
**Se è trapelata la master key stessa** (file cifrato *e* chiave, oppure la
|
||||
@@ -790,9 +788,6 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
`app/tx/confirmation.py:poll_once`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-82, B-90) · *Oggi:* il sistema mette in
|
||||
pausa la lotteria per `resurrected_tx` (B-78) e `reorg` (B-85);
|
||||
`payout_blocked` non esiste ancora.
|
||||
|
||||
### Intervento manuale sui fondi
|
||||
|
||||
@@ -854,17 +849,17 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
| Log | `data/logs/app.log` | `all N Electrum server(s) unreachable`, `round scheduler tick failed`, `could not bump`, `invariant violated` |
|
||||
| Disco | `./data/db`, `./data/logs` | Il database cresce senza limite (registro di audit incluso); i log ruotano a 6 × 10 MB |
|
||||
| Container | `docker compose ps` | `app` che si riavvia in continuazione: rifiuto all'avvio ([OPS-02]) |
|
||||
| Prontezza (dopo B-92) | `GET /health/ready`, `docker compose ps` | 503 / `unhealthy` ([OPS-04]) |
|
||||
| Prontezza | `GET /health/ready`, `docker compose ps` | 503 / `unhealthy` ([OPS-04]) |
|
||||
| Catena e sistema (dopo B-78, B-82, B-85, B-87, B-90) | Audit: `chain_reorg_detected`, `pending_tx_resurrected`, `payout_blocked`, `participant_reorged_out`, `rbf_bump_failed`, `restore_gap_detected`; `paused_reason` diverso da `operator` | Qualunque occorrenza ([OPS-30], [OPS-28]) |
|
||||
|
||||
*Codice:* `app/api/routes/rounds.py`, `app/api/routes/admin.py`,
|
||||
`app/logging_config.py:setup_logging`
|
||||
|
||||
Dopo B-92 l'indicatore Electrum di `/admin` mostra lo stato della
|
||||
L'indicatore Electrum di `/admin` mostra lo stato della
|
||||
connessione ([ELC-29](connessione-electrum.md#stato-della-connessione)):
|
||||
connessa o no, server attivo, altezza, età dell'ultimo header. Oggi mostra
|
||||
solo l'ultima altezza nota ("Blocco #N"), che resta visibile anche a
|
||||
connessione caduta.
|
||||
connessa o no, server attivo, età dell'ultimo header e, al passaggio del
|
||||
mouse, l'ultimo cambio di server con il motivo; la barra mostra anche l'ultima
|
||||
altezza nota ("Blocco #N"), che resta visibile a connessione caduta.
|
||||
|
||||
## Prova su rete reale
|
||||
|
||||
@@ -892,7 +887,7 @@ proposito.
|
||||
| 6 | Riorganizzazione di 1 blocco | Solo su una rete di prova con due minatori, se disponibile | La punta segue il ramo nuovo senza chiudere la sessione; `chain_reorg_detected` con profondità 1; nessuna pausa. Con profondità 2: pausa con motivo `reorg` (B-85, B-74) |
|
||||
| 7 | Cronologia molto lunga | Indirizzo con risposta oltre 64 KiB (per esempio mille piccoli invii a un indirizzo di prova) | La connessione resta aperta (limite di 16 MiB); le uscite sotto `DUST_LIMIT_SATS` non vengono accreditate (B-75) |
|
||||
| 8 | Tutte le riserve giù | Bloccare con il firewall ogni server di `ELECTRUM_FALLBACK_SERVERS` | Accrediti rinviati, estrazione in attesa, nessuna spesa esterna registrata, nessun denaro mosso; al ritorno dei server tutto riprende. Un avvio con meno di 2 riserve è rifiutato (B-84) |
|
||||
| 9 | Server connesso ma fermo | Un server Electrum di prova che smette di seguire la catena | Dopo 360 secondi senza avanzamento il listener passa a un server con una punta più alta (B-86); `GET /health/ready` risponde 503 nel frattempo (B-92) |
|
||||
| 9 | Server connesso ma fermo | Un server Electrum di prova che smette di seguire la catena | Dopo 360 secondi senza avanzamento il listener passa a un server con una punta più alta (B-86); `GET /health/ready` risponde 503 solo oltre 1800 secondi senza header (B-92) |
|
||||
| 10 | Salto dell'orologio | Portare avanti l'orologio dell'host di 7 ore con transazioni `pending` | Nessuna transazione abbandonata per il solo tempo trascorso (B-78) |
|
||||
| 11 | Disco pieno | Riempire il filesystem di `./data/db` durante l'attività | Nessuna transazione trasmessa senza la sua riga salvata prima; scommesse e prelievi falliscono prima dell'invio; liberato lo spazio, il sistema riprende senza interventi sul database |
|
||||
| 12 | Ripristino da backup | Backup, poi un round completo e una registrazione, poi [OPS-07] | Avvio in pausa con `START_PAUSED=true`; `restore_gap_detected` e registrazioni rifiutate; il round rigiocato non viene pagato una seconda volta (B-90, B-80, B-82) |
|
||||
@@ -921,7 +916,7 @@ master key usare il container.
|
||||
`generate_master_key.py` non ha il ripiego su `./data/keys/` degli altri
|
||||
due: dal venv scrive esattamente dove dice `MASTER_KEY_PATH` di `.env`. I
|
||||
file scritti hanno i permessi predefiniti del sistema (di norma `0644`) e,
|
||||
nel container, appartengono a root. Dopo B-89 gli script scrivono `0600` e
|
||||
nel container, appartengono a root. Gli script scrivono `0600` e
|
||||
rifiutano una xprv diversa da quella degli indirizzi già nel database
|
||||
([SEC-50](sicurezza.md#sec-50)).
|
||||
*Codice:* `scripts/generate_master_key.py`, `app/wallet/hd.py:generate_master_key`,
|
||||
|
||||
@@ -97,16 +97,11 @@ L'utente sceglie username e password (`POST /auth/register`). Il server:
|
||||
ricostruire gli utenti mancanti (procedura in
|
||||
[operativita.md](operativita.md#ripristino), B-90). La registrazione è
|
||||
rifiutata allo stesso modo finché il controllo non è stato completato.
|
||||
*Codice:* `app/main.py:lifespan`, `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` · *Test:* —
|
||||
*Stato:* da implementare (B-90) · *Oggi:* nessun controllo; dopo un
|
||||
ripristino il prossimo utente riceve il primo indice non presente nel
|
||||
database, anche se il suo indirizzo ha già ricevuto fondi.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._check_restore_gap`, `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` · *Test:* `tests/unit/test_restore_gap.py`
|
||||
- **[REG-16]** Ogni registrazione riuscita scrive l'evento `user_registered`
|
||||
con l'indice di derivazione e l'indirizzo assegnati, nella stessa
|
||||
transazione che crea l'utente.
|
||||
*Codice:* `app/auth/routes.py:register` · *Test:* —
|
||||
*Stato:* da implementare (B-92) · *Oggi:* la registrazione non scrive alcun
|
||||
evento di audit.
|
||||
*Codice:* `app/auth/routes.py:register` · *Test:* `tests/unit/test_observability.py`
|
||||
|
||||
### Sessione e password
|
||||
|
||||
@@ -126,8 +121,6 @@ il freno sui tentativi di login falliti è
|
||||
prima di qualunque verifica.
|
||||
*Codice:* `app/auth/routes.py:login`, `app/auth/routes.py:LoginRequest` ·
|
||||
*Test:* `tests/unit/test_auth.py`
|
||||
*Stato:* da implementare (B-94), per la lunghezza massima · *Oggi:* i campi
|
||||
del login non hanno limiti di lunghezza.
|
||||
- ~~[REG-10]~~ Assorbito da [SEC-30](sicurezza.md#limiti-di-frequenza):
|
||||
il freno sui login falliti (chiavi, soglie, azzeramento) non ha nulla di
|
||||
specifico del dominio oltre a ciò che SEC-30 già dice.
|
||||
@@ -148,10 +141,7 @@ il freno sui tentativi di login falliti è
|
||||
login. Questo requisito è il proprietario del flusso; [sicurezza.md](sicurezza.md)
|
||||
e [api.md](api.md) vi rimandano.
|
||||
*Codice:* `app/api/routes/users.py:change_password` ·
|
||||
*Test:* `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-94), per lunghezza massima, freno ed evento
|
||||
`password_changed` · *Oggi:* nessun limite di lunghezza, nessun freno sui
|
||||
tentativi, nessun evento di audit.
|
||||
*Test:* `tests/unit/test_users.py`, `tests/unit/test_abuse_limits.py`
|
||||
- **[REG-13]** Non esiste recupero password: non viene raccolta alcuna email.
|
||||
L'unica strada è il reset dall'operatore
|
||||
(`POST /admin/users/{id}/reset-password`), che imposta una password casuale
|
||||
@@ -213,10 +203,7 @@ flowchart TB
|
||||
H -- "Sì, e il quorum<br/>la conferma non spesa" --> I["Reintegrata<br/>(utxo_external_spend_reinstated)"]
|
||||
```
|
||||
|
||||
*Comportamento atteso.* Oggi ogni moneta confermata si accredita a 1
|
||||
conferma, di qualunque importo e anche se coinbase; il reintegro non è
|
||||
corroborato; gli aggiornamenti dello stesso utente non sono in fila; le
|
||||
monete sotto la soglia non compaiono nel saldo mostrato.
|
||||
Gli aggiornamenti dello stesso utente sono messi in fila ([DEP-15]).
|
||||
|
||||
- **[DEP-01]** Non c'è un importo minimo di deposito oltre alla soglia di
|
||||
polvere: una moneta di valore inferiore a `DUST_LIMIT_SATS` (294 sat) non
|
||||
@@ -260,10 +247,8 @@ monete sotto la soglia non compaiono nel saldo mostrato.
|
||||
Una moneta accreditata e poi sparita per una riorganizzazione più profonda
|
||||
della soglia segue la strada delle monete spese altrove
|
||||
([DEP-06], [DEP-07]); non c'è altra logica di annullamento.
|
||||
*Codice:* `app/deposits/service.py:find_new_credit_candidates`, `app/electrum/listener.py:ElectrumListener.refresh_user` ·
|
||||
*Test:* `tests/unit/test_deposits.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* **una conferma basta** per ogni
|
||||
moneta, deposito o coinbase che sia.
|
||||
*Codice:* `app/deposits/service.py:find_new_credit_candidates`, `app/deposits/service.py:read_deposit_policy`, `app/deposits/service.py:classify_new_deposits`, `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/electrum/listener.py:ElectrumListener.is_coinbase` ·
|
||||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_deposit_confirmations.py`
|
||||
- **[DEP-04]** **Ogni accredito è corroborato.** Prima di aumentare un saldo,
|
||||
il server chiede agli *altri* server Electrum configurati se quella moneta
|
||||
esiste davvero, con quell'importo e con le conferme richieste
|
||||
@@ -312,11 +297,13 @@ monete sotto la soglia non compaiono nel saldo mostrato.
|
||||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_confirmation_proof.py`
|
||||
- **[DEP-09]** **Due vie, un solo codice.** Un aggiornamento parte quando il
|
||||
server notifica un cambiamento su un indirizzo osservato, alla
|
||||
(re)iscrizione di un indirizzo, e al giro periodico: tutte e tre usano la
|
||||
stessa funzione di aggiornamento, così i due percorsi non possono
|
||||
comportarsi diversamente.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/electrum/listener.py:ElectrumListener._subscribe_and_refresh`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
|
||||
*Test:* `tests/unit/test_deposit_reconcile.py`
|
||||
(re)iscrizione di un indirizzo, al giro periodico e, per gli utenti che
|
||||
hanno un deposito in attesa ([DEP-14]), a ogni nuovo blocco — la notifica
|
||||
dell'indirizzo non arriva quando un deposito matura, perché la sua
|
||||
cronologia non cambia —: tutti usano la stessa funzione di aggiornamento,
|
||||
così i percorsi non possono comportarsi diversamente.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/electrum/listener.py:ElectrumListener._subscribe_and_refresh`, `app/electrum/listener.py:ElectrumListener._refresh_waiting_deposits`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
|
||||
*Test:* `tests/unit/test_deposit_reconcile.py`, `tests/unit/test_deposit_confirmations.py::test_new_blocks_refresh_the_users_that_have_a_waiting_deposit`
|
||||
- **[DEP-10]** <a id="dep-10"></a>Il giro periodico (`DepositReconciler`)
|
||||
riaggiorna **tutti** gli utenti, non solo quelli che risultano non
|
||||
iscritti: serve a recuperare un'iscrizione persa silenziosamente. Prima di
|
||||
@@ -335,9 +322,7 @@ monete sotto la soglia non compaiono nel saldo mostrato.
|
||||
prelievi ([scommesse-e-prelievi.md](scommesse-e-prelievi.md)), così una
|
||||
corroborazione in corso non ritarda una scommessa. Utenti diversi si
|
||||
aggiornano in parallelo come oggi.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` · *Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* due aggiornamenti dello stesso
|
||||
utente possono girare insieme e tentare di accreditare la stessa moneta.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` · *Test:* `tests/unit/test_state_consistency.py`
|
||||
- **[DEP-16]** Un errore durante l'aggiornamento di un utente non chiude la
|
||||
connessione Electrum: viene registrato nei log e l'utente viene ripreso
|
||||
all'aggiornamento successivo. In particolare una violazione dell'unicità
|
||||
@@ -345,9 +330,7 @@ monete sotto la soglia non compaiono nel saldo mostrato.
|
||||
si annulla la transazione del database di quell'accredito e si prosegue.
|
||||
Fa eccezione solo la caduta della connessione stessa, che termina la
|
||||
sessione come in [ELC-06](connessione-electrum.md#3-caduta-rilevata).
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/deposits/service.py:credit_confirmed_utxos` · *Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* l'eccezione risale da
|
||||
`refresh_user` a `_consume_scripthash` e termina la sessione Electrum.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/deposits/service.py:credit_confirmed_utxos` · *Test:* `tests/unit/test_state_consistency.py`
|
||||
|
||||
## Il saldo mostrato
|
||||
|
||||
@@ -388,9 +371,7 @@ corso (`has_pending`).
|
||||
contata due volte. `has_pending` è vero finché esiste almeno una
|
||||
transazione di (a) o un deposito di (b).
|
||||
*Codice:* `app/wallet/balance.py:compute_pending_balance`, `app/api/routes/users.py:me` ·
|
||||
*Test:* `tests/unit/test_balance.py`
|
||||
*Stato:* da implementare (B-82), per la parte (b) · *Oggi:* conta solo la
|
||||
parte (a).
|
||||
*Test:* `tests/unit/test_balance.py`, `tests/unit/test_deposit_confirmations.py::test_the_displayed_balance_includes_waiting_deposits_but_the_spendable_one_does_not`, `tests/unit/test_users.py::test_me_shows_waiting_deposits_in_the_pending_balance_only`
|
||||
- **[DEP-14]** **Depositi in attesa.** Una moneta sopra la soglia di polvere,
|
||||
confermata (altezza maggiore di 0), non ancora registrata e con meno
|
||||
conferme della sua soglia ([DEP-03]) è un *deposito in attesa*. Il listener
|
||||
@@ -401,10 +382,8 @@ corso (`has_pending`).
|
||||
non sposta denaro e non è spendibile, quindi non è corroborato — e dopo un
|
||||
riavvio si ricostruisce alla prima reiscrizione ([ELC-18](connessione-electrum.md#riconnessione)).
|
||||
Le monete ancora in mempool non sono depositi in attesa.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/wallet/balance.py:compute_pending_balance` · *Test:* —
|
||||
*Stato:* da implementare (B-82) · *Oggi:* con la soglia a 1 conferma non
|
||||
esistono depositi in attesa; una moneta in mempool non compare in nessun
|
||||
saldo.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/electrum/listener.py:ElectrumListener.pending_deposit_sats`, `app/wallet/balance.py:compute_pending_balance` ·
|
||||
*Test:* `tests/unit/test_deposit_confirmations.py::test_a_deposit_is_credited_exactly_at_six_confirmations`, `tests/unit/test_deposit_confirmations.py::test_a_waiting_deposit_that_vanishes_stops_being_shown`
|
||||
- **[DEP-13]** Ogni spesa (scommessa, prelievo) usa solo monete accreditate e
|
||||
non spese: il saldo mostrato è informativo, non spendibile. Se un utente
|
||||
chiede un prelievo o una scommessa coperti dal saldo mostrato ma non ancora
|
||||
@@ -414,6 +393,3 @@ corso (`has_pending`).
|
||||
la scommessa [scommesse-e-prelievi.md](scommesse-e-prelievi.md), B-96).
|
||||
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/bets/service.py:place_bet` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py`
|
||||
*Stato:* da implementare (B-96), per la scommessa · *Oggi:* solo il
|
||||
prelievo distingue i due casi; una scommessa coperta solo dal saldo
|
||||
mostrato riceve `insufficient_balance`.
|
||||
@@ -38,9 +38,7 @@ Prima dei controlli valgono due condizioni di contorno:
|
||||
un'attesa sul blocco durante una riconnessione non fa usare una connessione
|
||||
già chiusa; il controllo di [BET-01] si fa su quel valore.
|
||||
*Codice:* `app/api/routes/bets.py:create_bet`, `app/tx/locks.py:UserLocks.acquire` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* `listener.client` viene letto e
|
||||
controllato prima di acquisire il blocco.
|
||||
*Test:* `tests/unit/test_state_consistency.py`
|
||||
|
||||
Una scommessa (`POST /bets`, senza corpo) passa solo se **tutte** queste
|
||||
condizioni sono vere, verificate nell'ordine della tabella; altrimenti viene
|
||||
@@ -53,8 +51,8 @@ cifra. Il **costo** è quello del round ([BET-21]):
|
||||
| **[BET-04]** | Il round è in stato `open` e la sua **scadenza** — apertura più la durata fotografata all'apertura (`opened_at + duration_seconds`), non lo stato nel database — non è ancora passata | `round_closing` |
|
||||
| **[BET-05]** | L'utente non ha già una scommessa in questo round (in qualunque stato) | `already_betting` |
|
||||
| **[BET-06]** | Il round ha meno di 400 partecipanti (`MAX_PARTICIPANTS_PER_ROUND`), contando anche quelli non ancora confermati | `round_full` (parametro `max_participants`) |
|
||||
| **[BET-20]** | Se il round ha un tetto al montepremi (`max_round_pool_sats` fotografato all'apertura, [RND-05](ciclo-di-vita-del-round.md#0--apertura)) diverso da 0, la somma degli importi netti dei partecipanti del round, contando anche quelli non ancora confermati, è minore del tetto. *Stato:* da implementare (B-95) · *Oggi:* il tetto non esiste | `round_full` |
|
||||
| **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`), come per il prelievo ([WDR-05](#prelievo)). *Stato:* da implementare (B-96) · *Oggi:* sempre `insufficient_balance`, anche quando il saldo mostrato all'utente copre la scommessa |
|
||||
| **[BET-20]** | Se il round ha un tetto al montepremi (`max_round_pool_sats` fotografato all'apertura, [RND-05](ciclo-di-vita-del-round.md#0--apertura)) diverso da 0, la somma degli importi netti dei partecipanti del round, contando anche quelli non ancora confermati, è minore del tetto. | `round_full` |
|
||||
| **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`), come per il prelievo ([WDR-05](#prelievo)). |
|
||||
| **[BET-08]** | Il costo si copre con al massimo 50 monete (`MAX_TX_INPUTS`), scelte dalla più grande fino a coprirlo | `too_many_inputs` (parametro `max_inputs`) |
|
||||
| **[BET-09]** | Le monete scelte ([BET-08]) superano il costo di almeno 294 sat (`DUST_LIMIT_SATS`), che restano come resto | `balance_leaves_no_change` (parametro `required_extra_sats`) |
|
||||
| **[BET-10]** | Il costo meno la commissione di rete resta positivo e di almeno 294 sat | `amount_below_network_fee`, `amount_below_dust_limit` |
|
||||
@@ -77,21 +75,14 @@ ancora `open` oltre la scadenza.
|
||||
[WDR-09] scatta solo quando nessuna combinazione di monete lascia un resto
|
||||
sufficiente.
|
||||
*Codice:* `app/wallet/psbt_builder.py:select_utxos` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-98) · *Oggi:* la selezione si ferma alla prima
|
||||
moneta che copre il costo, e il resto si misura solo su quelle monete: con
|
||||
10 + 1 PLM una scommessa da 10 PLM è rifiutata con `balance_leaves_no_change`
|
||||
e un prelievo da 10 PLM viene ridotto invece di usare la seconda moneta.
|
||||
*Test:* `tests/unit/test_psbt_builder.py`
|
||||
|
||||
- **[BET-21]** Il **costo** di una scommessa è il `bet_amount_sats`
|
||||
fotografato sul round alla sua apertura
|
||||
([RND-05](ciclo-di-vita-del-round.md#0--apertura)), non il valore corrente
|
||||
di `round_config`: una modifica da `/admin` vale dal round successivo.
|
||||
*Codice:* `app/bets/service.py:place_bet` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-95) · *Oggi:* il costo è letto da
|
||||
`round_config` a ogni scommessa, quindi una modifica vale subito anche per
|
||||
il round in corso.
|
||||
*Test:* `tests/unit/test_round_snapshot.py::test_every_bet_of_a_round_pays_the_roundd_cost_whatever_is_edited_meanwhile`
|
||||
- **[BET-11]** La scadenza viene verificata due volte: all'arrivo della
|
||||
richiesta ([BET-04]) e di nuovo dopo aver costruito e firmato la
|
||||
transazione. L'iscrizione al round viene poi salvata nella stessa
|
||||
@@ -248,9 +239,7 @@ esterno scelto dall'utente.
|
||||
avviene tenendo il **blocco per-utente**; il client della rete viene letto
|
||||
e controllato dopo aver acquisito il blocco, come in [BET-02].
|
||||
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/tx/locks.py:UserLocks.acquire` ·
|
||||
*Test:* — (nessun test verifica `network_unavailable`)
|
||||
*Stato:* da implementare (B-96) · *Oggi:* `listener.client` viene letto e
|
||||
controllato prima di acquisire il blocco.
|
||||
*Test:* `tests/unit/test_state_consistency.py`
|
||||
|
||||
Le condizioni sono verificate nell'ordine della tabella; un rifiuto risponde
|
||||
HTTP 400 prima che si muova qualunque cifra:
|
||||
@@ -297,10 +286,7 @@ solo se la rete rifiuta esplicitamente, RBF se non conferma in tempo.
|
||||
prelievo è rifiutato con `amount_below_minimum` (parametro `minimum_sats`)
|
||||
prima che si muova qualunque cifra.
|
||||
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* il minimo è verificato solo
|
||||
sull'importo richiesto, quindi un prelievo dell'intero saldo può inviare
|
||||
fino a 294 sat meno del minimo.
|
||||
*Test:* `tests/unit/test_state_consistency.py`
|
||||
- **[WDR-10]** Prima dell'invio, in un'unica transazione del database
|
||||
([TX-28](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia)),
|
||||
le monete spese vengono segnate come tali, il saldo ricalcolato, e si
|
||||
|
||||
+16
-18
@@ -1,13 +1,13 @@
|
||||
# Setup
|
||||
|
||||
Passaggi da eseguire una tantum per preparare un'istanza di PLM Lottery, prima di
|
||||
poterla avviare (in locale o via Docker). Per come avviarla poi ogni volta, vedi
|
||||
poterla avviare. Il server gira sempre in Docker; il venv serve solo a test, migrazioni da generare e script una tantum. Per come avviarla poi ogni volta, vedi
|
||||
[running-the-server.md](running-the-server.md).
|
||||
|
||||
## 1. Prerequisiti
|
||||
|
||||
- Python 3.12+ (serve solo per il workflow locale/venv — puoi saltarlo se usi solo Docker)
|
||||
- Docker + Docker Compose (serve solo per il workflow a container)
|
||||
- Docker + Docker Compose (il server gira solo così)
|
||||
- Python 3.12+ (solo per gli strumenti locali: test, `alembic revision` e gli script delle chiavi)
|
||||
- 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.
|
||||
@@ -31,7 +31,7 @@ cp .env.example .env
|
||||
|
||||
| Variabile | Scopo | Come generarla |
|
||||
|---|---|---|
|
||||
| `XPRV_ENCRYPTION_KEY` | Chiave simmetrica che cifra a riposo la master xprv del server. **Perdere questa chiave significa perdere per sempre l'accesso ai fondi di tutti gli utenti.** | `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` |
|
||||
| `XPRV_ENCRYPTION_KEY_FILE` | Percorso del file che contiene la chiave simmetrica che cifra a riposo la master xprv del server (`./secrets/xprv_encryption.key`, cartella `0700`, file `0600`; nel container è montato come secret). **Perdere questa chiave significa perdere per sempre l'accesso ai fondi di tutti gli utenti.** | `mkdir -p secrets && chmod 700 secrets && python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" > secrets/xprv_encryption.key && chmod 600 secrets/xprv_encryption.key` |
|
||||
| `JWT_SECRET` | Firma i token di sessione degli utenti. | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
||||
| `ADMIN_TOKEN` | Token bearer richiesto sugli endpoint admin (header `X-Admin-Token`). | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
||||
|
||||
@@ -61,7 +61,7 @@ certificato del 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
|
||||
`JWT_SECRET` e la chiave in `XPRV_ENCRYPTION_KEY_FILE` 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
|
||||
@@ -74,15 +74,13 @@ admin dopo l'avvio, non qui — vedi [guida-admin.md](guida-admin.md).
|
||||
|
||||
Il server deriva l'indirizzo di deposito di ogni utente (e l'indirizzo pool) da
|
||||
un'unica master xprv, generata una volta e cifrata a riposo con
|
||||
`XPRV_ENCRYPTION_KEY`. Questo passaggio va eseguito esattamente una volta per
|
||||
ogni deployment, dopo aver impostato `XPRV_ENCRYPTION_KEY` in `.env`:
|
||||
la chiave in `XPRV_ENCRYPTION_KEY_FILE`. Questo passaggio va eseguito esattamente una volta per
|
||||
ogni deployment, dopo aver creato quel file e impostato `XPRV_ENCRYPTION_KEY_FILE` in `.env`:
|
||||
|
||||
- **Locale/venv**: `PYTHONPATH=. python scripts/generate_master_key.py`
|
||||
- **Docker**: `docker compose run --rm app python scripts/generate_master_key.py`
|
||||
Dal venv: `PYTHONPATH=. python scripts/generate_master_key.py`, con `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, così lo script scrive esattamente il file che legge il container.
|
||||
|
||||
Questo scrive un file cifrato (`MASTER_KEY_PATH`, default `./master.xprv.enc` in
|
||||
locale o `./data/keys/master.xprv.enc` con Docker). **Fai il backup di questo
|
||||
file insieme a `XPRV_ENCRYPTION_KEY`** — uno dei due da solo è inutile, ma
|
||||
Questo scrive un file cifrato (`MASTER_KEY_PATH`, in Docker `./data/keys/master.xprv.enc`). **Fai il backup di questo
|
||||
file insieme a `secrets/xprv_encryption.key`** — uno dei due da solo è inutile, ma
|
||||
perderli entrambi insieme significa perdere i fondi di tutti gli utenti senza
|
||||
possibilità di recupero.
|
||||
|
||||
@@ -107,10 +105,10 @@ Entrambi vanno eseguiti localmente (o dentro il container via
|
||||
pannello admin: chi ottiene questa xprv ottiene il controllo dei fondi di
|
||||
tutti gli utenti e del pool.
|
||||
|
||||
## 4. Installare le dipendenze (solo workflow locale/venv)
|
||||
## 4. Installare gli strumenti locali (venv)
|
||||
|
||||
Salta questo passaggio se usi solo Docker — l'immagine installa le proprie
|
||||
dipendenze durante la build.
|
||||
Servono per i test e per gli script delle chiavi; il server non ne ha bisogno,
|
||||
perché l'immagine installa le proprie dipendenze (da `requirements.lock`).
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
@@ -120,9 +118,9 @@ pip install -e ".[dev]"
|
||||
|
||||
## 5. Applicare le migrazioni del database
|
||||
|
||||
- **Locale/venv**: `alembic upgrade head`
|
||||
- **Docker**: le migrazioni vengono eseguite automaticamente all'avvio del
|
||||
container (vedi il `CMD` del `Dockerfile`) — nessun passaggio manuale.
|
||||
Le migrazioni vengono eseguite automaticamente all'avvio del container (vedi il
|
||||
`CMD` del `Dockerfile`) — nessun passaggio manuale, e `alembic upgrade head` non
|
||||
si lancia mai a mano.
|
||||
|
||||
## 6. Impostare l'indirizzo delle fee
|
||||
|
||||
|
||||
+24
-83
@@ -90,8 +90,7 @@ Restano **accettati**, e descritti in
|
||||
riorganizzazioni con pausa sopra la profondità 1 e verifica delle monete
|
||||
del round prima del pagamento ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md), B-82,
|
||||
B-85). Il denaro che un attaccante riesce a sottrarre è perso per la
|
||||
piattaforma. *Oggi:* depositi a 1 conferma e nessuna verifica prima
|
||||
del pagamento (B-82); le riorganizzazioni sono rilevate e, da profondità 2, mettono in pausa.
|
||||
piattaforma.
|
||||
|
||||
## Inventario dei segreti
|
||||
|
||||
@@ -105,11 +104,7 @@ Restano **accettati**, e descritti in
|
||||
| Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore |
|
||||
| JWT dell'utente | Browser, `localStorage` (`plm_token`) | Emesso al login/registrazione | Sessione dell'utente fino alla scadenza (24h) o al cambio password | — | Cambio password o reset dall'operatore |
|
||||
|
||||
*Oggi* (B-91): la chiave di cifratura è la variabile d'ambiente
|
||||
`XPRV_ENCRYPTION_KEY` in `.env`, visibile a chiunque esegua `docker inspect`
|
||||
sul container; `.env` e i file di chiave hanno i permessi predefiniti del
|
||||
sistema (di norma `0644`, leggibili da tutti).
|
||||
`JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente anche dopo B-91: le
|
||||
`JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente (B-91): le
|
||||
vede con `docker inspect` solo chi ha accesso a Docker, che è già fidato come
|
||||
l'host.
|
||||
|
||||
@@ -119,10 +114,6 @@ l'host.
|
||||
`master.xprv.enc`, i database `*.db`, `data/` e `logs/` sono esclusi sia da
|
||||
git sia dall'immagine Docker.
|
||||
*Codice:* `app/config.py:Settings`, `.gitignore`, `.dockerignore`
|
||||
*Stato:* da implementare (B-91), per la chiave in un file e l'esclusione di
|
||||
`secrets/` · *Oggi:* anche la chiave di cifratura sta in `.env`
|
||||
(`XPRV_ENCRYPTION_KEY`), e né `.gitignore` né `.dockerignore` escludono
|
||||
`secrets/`.
|
||||
|
||||
- **[SEC-03]** Tutti i parametri operativi stanno nel database
|
||||
(`round_config`); in `.env` restano solo segreti, collegamenti
|
||||
@@ -132,8 +123,6 @@ l'host.
|
||||
riavvio. L'elenco completo è in
|
||||
[architettura.md](architettura.md#configurazione).
|
||||
*Codice:* `app/config.py:Settings`
|
||||
*Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e
|
||||
`XPRV_ENCRYPTION_KEY_FILE` non esistono.
|
||||
|
||||
`.env.example` documenta le chiavi necessarie e il comando per generarle;
|
||||
non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque
|
||||
@@ -183,10 +172,7 @@ flowchart TD
|
||||
`0600` (senza passare da permessi più larghi) e lo riscrivono con gli
|
||||
stessi permessi.
|
||||
*Codice:* `scripts/generate_master_key.py`, `scripts/encrypt_master_key.py`,
|
||||
`app/wallet/hd.py:generate_master_key` · *Test:* —
|
||||
*Stato:* da implementare (B-89) · *Oggi:* gli script accettano qualunque
|
||||
xprv senza guardare il database e scrivono il file con i permessi
|
||||
predefiniti del sistema (di norma `0644`).
|
||||
`app/wallet/hd.py:generate_master_key`, `app/wallet/verify.py` · *Test:* `tests/unit/test_master_key_check.py`
|
||||
|
||||
### Cifratura a riposo
|
||||
|
||||
@@ -209,10 +195,7 @@ sì che un backup dei dati non contenga entrambe.
|
||||
container, quindi `docker inspect` non la mostra, e non finisce in un
|
||||
backup di `./data/`.
|
||||
*Codice:* `app/config.py:Settings`, `app/wallet/keystore.py:decrypt_xprv`,
|
||||
`docker-compose.yml` · *Test:* —
|
||||
*Stato:* da implementare (B-91) · *Oggi:* la chiave è la variabile
|
||||
`XPRV_ENCRYPTION_KEY` di `.env`, passata al container con `env_file` e
|
||||
visibile con `docker inspect`.
|
||||
`docker-compose.yml` · *Test:* `tests/unit/test_container_hardening.py`, `tests/unit/test_config.py`
|
||||
|
||||
### Derivazione
|
||||
|
||||
@@ -238,10 +221,8 @@ sì che un backup dei dati non contenga entrambe.
|
||||
verificata ([SEC-49]). Da quel momento la chiave dell'account
|
||||
`m/84'/746'/0'` resta in memoria nel processo per tutta la sua durata; la
|
||||
root xprv non viene conservata.
|
||||
*Codice:* `app/wallet/hd.py:_load_account_key`, `app/main.py:lifespan`
|
||||
*Stato:* da implementare (B-89) · *Oggi:* la chiave è decifrata al primo
|
||||
uso (la prima derivazione, tipicamente una registrazione o il primo
|
||||
pagamento), non all'avvio.
|
||||
*Codice:* `app/wallet/hd.py:_load_account_key`, `app/main.py:lifespan` ·
|
||||
*Test:* `tests/unit/test_master_key_check.py`
|
||||
|
||||
### Recupero
|
||||
|
||||
@@ -272,8 +253,6 @@ riserva è [ELC-19](connessione-electrum.md#1-rotazione-tra-server) (vedi
|
||||
reali.
|
||||
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` ·
|
||||
*Test:* `tests/unit/test_config.py`
|
||||
*Stato:* da implementare (B-91), per la chiave letta da file · *Oggi:* si
|
||||
controlla che la variabile `XPRV_ENCRYPTION_KEY` non sia vuota.
|
||||
|
||||
- **[SEC-13]** Un `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello
|
||||
risulta semplicemente chiuso (vedi [SEC-24]).
|
||||
@@ -300,14 +279,9 @@ riserva è [ELC-19](connessione-electrum.md#1-rotazione-tra-server) (vedi
|
||||
decifri. Lo scopo è non partire con una master key diversa da quella che
|
||||
ha prodotto gli indirizzi salvati, che sposterebbe in silenzio il
|
||||
montepremi e farebbe firmare transazioni che la rete rifiuta.
|
||||
*Codice:* `app/main.py:lifespan`, `app/wallet/hd.py:_load_account_key`,
|
||||
`app/wallet/hd.py:derive_user_address`, `app/wallet/hd.py:derive_pool_address` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-89) · *Oggi:* all'avvio non si decifra nulla;
|
||||
una chiave di cifratura sbagliata o malformata emerge solo al primo uso
|
||||
della master key (prima registrazione o primo pagamento), e una xprv
|
||||
diversa da quella originale non emerge affatto: produce indirizzi nuovi,
|
||||
compreso quello del montepremi.
|
||||
*Codice:* `app/main.py:lifespan`, `app/wallet/verify.py:verify_master_key`,
|
||||
`app/wallet/verify.py:sample_problems` ·
|
||||
*Test:* `tests/unit/test_master_key_check.py`
|
||||
|
||||
## Autenticazione degli utenti
|
||||
|
||||
@@ -349,10 +323,7 @@ che ne è il proprietario; qui ci sono le regole di sicurezza.
|
||||
[REG-12](registrazione-e-depositi.md#sessione-e-password).
|
||||
*Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`,
|
||||
`app/auth/routes.py:LoginRequest`, `app/api/routes/users.py:ChangePasswordRequest` ·
|
||||
*Test:* `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-94), per il massimo su login e cambio password ·
|
||||
*Oggi:* il massimo di 256 vale solo per la password della registrazione;
|
||||
i campi del login e del cambio password non hanno limite di lunghezza.
|
||||
*Test:* `tests/unit/test_users.py`, `tests/unit/test_abuse_limits.py`
|
||||
|
||||
- ~~[SEC-18]~~ Assorbito da [REG-01](registrazione-e-depositi.md#registrazione)
|
||||
e [REG-02](registrazione-e-depositi.md#registrazione): formato dello
|
||||
@@ -400,9 +371,6 @@ che ne è il proprietario; qui ci sono le regole di sicurezza.
|
||||
*Codice:* `app/auth/dependencies.py:get_current_user`, `app/api/routes/users.py:change_password`,
|
||||
`app/api/routes/admin.py:reset_user_password` ·
|
||||
*Test:* `tests/unit/test_users.py`, `tests/unit/test_security.py`, `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94), per `not_authenticated` · *Oggi:*
|
||||
l'header assente risponde 401 con il corpo non strutturato di FastAPI
|
||||
`{"detail": "Not authenticated"}`.
|
||||
|
||||
- **[SEC-23]** Gli endpoint raggiungibili anche da non autenticati (ad esempio
|
||||
`GET /rounds/current`, `POST /bug-reports`) usano l'utente solo per
|
||||
@@ -424,8 +392,6 @@ endpoint `/admin/*`, che richiedono l'header `X-Admin-Token`.
|
||||
`invalid_admin_token` ([API-08](api.md#convenzioni-generali)).
|
||||
*Codice:* `app/api/routes/admin.py:require_admin` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94), per il codice · *Oggi:* 403 con il corpo
|
||||
non strutturato `{"detail": "invalid admin token"}`.
|
||||
|
||||
- **[SEC-25]** Il token è **unico e condiviso**: non esiste un'identità per
|
||||
singolo operatore, e l'audit log registra *cosa* è cambiato ma non *chi*
|
||||
@@ -483,8 +449,8 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
| Login per username | `user:<username minuscolo>` | Dal 5° fallimento: blocco di 2s, raddoppiato a ogni fallimento successivo, massimo 300s. Il contatore si azzera dopo 900s senza fallimenti, o con un login riuscito | `POST /auth/login` |
|
||||
| Login per IP | `ip:<ip>` | Dal 20° fallimento: stessa progressione (2s → 300s), stesso decadimento di 900s. **Non** si azzera con un login riuscito | `POST /auth/login` |
|
||||
| Registrazione per IP | `ip:<ip>` | Al massimo 5 account creati per IP in una finestra mobile di 3600s. Contano solo gli account creati davvero | `POST /auth/register` |
|
||||
| Cambio password per utente *(B-94, da implementare)* | `user:<id utente>`, in un limitatore separato da quello del login | Dal 5° fallimento della password attuale: stessa progressione del login (2s → 300s), stesso decadimento di 900s; un cambio riuscito azzera il contatore | `POST /users/me/change-password` |
|
||||
| Segnalazioni di bug per IP *(B-94, da implementare)* | `ip:<ip>` | Al massimo 10 segnalazioni per IP in una finestra mobile di 3600s, anche da autenticati. Contano solo le segnalazioni salvate davvero | `POST /bug-reports` |
|
||||
| Cambio password per utente *(B-94)* | `user:<id utente>`, in un limitatore separato da quello del login | Dal 5° fallimento della password attuale: stessa progressione del login (2s → 300s), stesso decadimento di 900s; un cambio riuscito azzera il contatore | `POST /users/me/change-password` |
|
||||
| Segnalazioni di bug per IP *(B-94)* | `ip:<ip>` | Al massimo 10 segnalazioni per IP in una finestra mobile di 3600s, anche da autenticati. Contano solo le segnalazioni salvate davvero | `POST /bug-reports` |
|
||||
| Stream SSE per IP | IP | Al massimo 5 stream contemporanei per IP: il 6° chiude il più vecchio dello stesso IP | `GET /rounds/stream` |
|
||||
| Stream SSE globale | — | Al massimo 500 stream contemporanei: oltre, 503 | `GET /rounds/stream` |
|
||||
|
||||
@@ -519,9 +485,7 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
[API-21](api.md).
|
||||
*Codice:* `app/api/routes/users.py:change_password`,
|
||||
`app/auth/rate_limit.py:RateLimiter`, `app/auth/rate_limit.py:AuthRateLimiters` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun freno: con un token valido
|
||||
la password attuale si può provare senza limiti.
|
||||
*Test:* `tests/unit/test_abuse_limits.py`
|
||||
|
||||
- **[SEC-55]** L'invio di segnalazioni di bug è una **quota** per IP
|
||||
([SEC-35]): al massimo 10 segnalazioni per IP ogni 3600s (finestra mobile,
|
||||
@@ -532,9 +496,7 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
vecchia ([API-28](api.md)).
|
||||
*Codice:* `app/api/routes/bug_reports.py:create_bug_report`,
|
||||
`app/auth/rate_limit.py:RollingQuota` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun limite: un anonimo può
|
||||
inserire segnalazioni senza limite, fino a riempire il database.
|
||||
*Test:* `tests/unit/test_abuse_limits.py`
|
||||
|
||||
- **[SEC-32]** La memoria dei limitatori è limitata: le voci esaurite vengono
|
||||
rimosse (al più ogni 60s, e alla lettura), e comunque non si superano 50.000
|
||||
@@ -549,10 +511,7 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
[API-16](api.md#contratto-degli-errori); il browser ripiega sul polling).
|
||||
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe`,
|
||||
`app/api/routes/rounds.py:round_stream` ·
|
||||
*Test:* `tests/unit/test_round_events.py`
|
||||
*Stato:* da implementare (B-94), per il codice `too_many_streams` ·
|
||||
*Oggi:* il 503 ha il corpo non strutturato
|
||||
`{"detail": "too many concurrent update streams"}`.
|
||||
*Test:* `tests/unit/test_round_events.py`, `tests/unit/test_abuse_limits.py`
|
||||
|
||||
- **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi,
|
||||
endpoint admin (tentativi sul token compresi), QR, `GET /rounds/current` e
|
||||
@@ -562,7 +521,6 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
che non è un limite di frequenza ([LIM-07](limiti-noti.md)).
|
||||
*Codice:* `app/api/routes/bets.py`, `app/api/routes/withdrawals.py`,
|
||||
`app/api/routes/admin.py:require_admin`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* anche il cambio password e
|
||||
l'invio di segnalazioni di bug sono senza limite ([SEC-54], [SEC-55]).
|
||||
|
||||
## Indirizzo IP del client
|
||||
@@ -640,11 +598,7 @@ entra nell'immagine.
|
||||
`0700`; `.env`, `master.xprv.enc` e `./secrets/xprv_encryption.key` hanno
|
||||
`0600`; tutti appartengono all'utente con cui gira il container. I file che
|
||||
l'app crea (database, backup, log) nascono con permessi `0600`.
|
||||
*Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* —
|
||||
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root, i
|
||||
file che scrive in `./data/` appartengono a root sull'host, e `.env` e i
|
||||
file di chiave hanno i permessi predefiniti del sistema (di norma `0644`,
|
||||
leggibili da ogni utente dell'host).
|
||||
*Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* `tests/unit/test_container_hardening.py`
|
||||
|
||||
- **[SEC-52]** Ciò che entra nelle immagini è fissato: le immagini di base
|
||||
(`python:3.12-slim` per `app`, `caddy:2-alpine` per `caddy`) sono indicate
|
||||
@@ -652,11 +606,7 @@ entra nell'immagine.
|
||||
di lock con l'hash di ogni pacchetto (`pip install --require-hashes`), non
|
||||
dagli intervalli `>=` di `pyproject.toml`. Aggiornare una dipendenza o
|
||||
un'immagine significa rigenerare il lock o cambiare il digest in un commit.
|
||||
*Codice:* `Dockerfile`, `docker-compose.yml`, `pyproject.toml` · *Test:* —
|
||||
*Stato:* da implementare (B-91) · *Oggi:* le immagini di base sono
|
||||
indicate per etichetta mobile e le dipendenze con `>=`: ogni build può
|
||||
installare versioni diverse, comprese versioni compromesse pubblicate dopo
|
||||
l'ultima build.
|
||||
*Codice:* `Dockerfile`, `docker-compose.yml`, `requirements.lock` · *Test:* `tests/unit/test_container_hardening.py`
|
||||
|
||||
La chiave di cifratura montata come file, e non come variabile d'ambiente, è
|
||||
[SEC-53]. Le procedure che scrivono in `./data/` devono rispettarne il
|
||||
@@ -701,7 +651,6 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
|
||||
chiamate all'API solo dalla stessa origine.
|
||||
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`,
|
||||
`app/main.py:user_guide`, `app/main.py:report_bug_page`, `app/main.py` (`app.mount`)
|
||||
*Stato:* da implementare (B-94), per i file statici · *Oggi:* i file
|
||||
serviti dal mount di `app/static/` non hanno `Cache-Control`, solo
|
||||
`ETag`/`Last-Modified`, e il browser può riusare uno script vecchio con
|
||||
una pagina nuova.
|
||||
@@ -720,11 +669,8 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
|
||||
validazione e framework compresi — ha il formato strutturato con un codice
|
||||
([API-16](api.md#contratto-degli-errori)) e non ripete il valore ricevuto,
|
||||
così una password troppo lunga non torna indietro in chiaro.
|
||||
*Codice:* `app/main.py:log_unhandled_exception`, `app/main.py`
|
||||
*Stato:* da implementare (B-94), per gli errori diversi dal 500 ·
|
||||
*Oggi:* 401 senza header, 403 admin, 404/405 del framework e 503 dell'SSE
|
||||
hanno `detail` come stringa, e il 422 di validazione ripete nel campo
|
||||
`input` il valore ricevuto.
|
||||
*Codice:* `app/main.py:log_unhandled_exception`, `app/main.py` ·
|
||||
*Test:* `tests/unit/test_abuse_limits.py`
|
||||
|
||||
Il pannello `/admin` non è collegato da `/` (né viceversa), ma è una scelta
|
||||
di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
|
||||
@@ -768,7 +714,6 @@ di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
|
||||
`app/api/routes/admin.py:list_pending_transactions`, `app/api/routes/admin.py:list_bug_reports`,
|
||||
`app/api/routes/admin.py:list_users` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94), per l'elenco utenti · *Oggi:*
|
||||
`GET /admin/users` restituisce tutti gli utenti in una sola risposta, senza
|
||||
limite né paginazione.
|
||||
|
||||
@@ -789,17 +734,15 @@ sicurezza.
|
||||
successivi al più alto assegnato e, se uno ha cronologia in catena, la
|
||||
registrazione è rifiutata (`registration_unavailable`) e si scrive
|
||||
`restore_gap_detected`
|
||||
([REG-15](registrazione-e-depositi.md#registrazione), B-90). *Oggi:*
|
||||
nessun controllo; l'unica difesa è la procedura
|
||||
[OPS-28](operativita.md#ripristino).
|
||||
([REG-15](registrazione-e-depositi.md#registrazione), B-90); la procedura
|
||||
[OPS-28](operativita.md#ripristino) toglie la sospensione.
|
||||
- **Round rigiocati.** Con `START_PAUSED=true` il server parte con la
|
||||
lotteria in pausa ([OPS-27](operativita.md#ripristino), B-90), e ogni round
|
||||
ha il proprio indirizzo del montepremi, le cui monete sono verificate con
|
||||
il quorum prima del pagamento
|
||||
([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-82): un round
|
||||
del backup già pagato risulta impagabile invece di essere pagato due volte
|
||||
con il denaro dei round successivi. *Oggi:* la pausa si imposta a mano nel
|
||||
database, e le monete del round non sono ancora verificate con il quorum.
|
||||
con il denaro dei round successivi.
|
||||
- **Chiave sbagliata.** Una master key che non corrisponde al database
|
||||
ripristinato ferma l'avvio ([SEC-49]).
|
||||
- **Commit persi.** Con `synchronous=FULL` un commit confermato al chiamante
|
||||
@@ -846,10 +789,8 @@ La struttura completa delle tabelle è in [modello-dati.md](modello-dati.md).
|
||||
`decrypt_master_key.py`, per scelta e dopo conferma. WIF e password
|
||||
generate dal reset compaiono solo nella risposta HTTP all'operatore.
|
||||
*Codice:* `app/logging_config.py:setup_logging`, `app/api/routes/admin.py:user_privkey`,
|
||||
`app/api/routes/admin.py:reset_user_password`
|
||||
*Stato:* da implementare (B-92), per stdout e IP del client · *Oggi:* il
|
||||
log va solo nel file, e il log di accesso registra l'indirizzo di Caddy,
|
||||
non quello del client ([ARC-02](architettura.md#panoramica-del-deployment)).
|
||||
`app/api/routes/admin.py:reset_user_password` ·
|
||||
*Test:* `tests/unit/test_observability.py`
|
||||
|
||||
## Rischi accettati
|
||||
|
||||
|
||||
+20
-19
@@ -193,8 +193,8 @@ limiti accettati: lo stato `drawing` non riprende dopo un riavvio (vedi
|
||||
| B-68 | CLAUDE.md e README descrivevano uno stato che il codice aveva superato | Bassa | `162ceed` Describe the code the docs actually ship with | `test_docs_current.py` | ARC |
|
||||
| B-69 | Commenti nel codice non più veri | Bassa | `666cb1a` Retire in-code comments that outlived what they described | `test_code_comments.py` | ARC |
|
||||
| B-70 | Il diagramma diceva che prelievo e scommessa in corso non potevano coesistere, ma il codice non lo impone (e non deve) | Bassa | `526a649` Say what bets and withdrawals actually exclude | `test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed`, `::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive` | WDR |
|
||||
| B-71 | `.env` punta `MASTER_KEY_PATH` a una seconda copia della chiave master (e `DATABASE_URL` a un secondo database) invece che ai file in `./data/` letti dal container | Bassa | **aperto** | — | OPS, SEC |
|
||||
| B-72 | `docs/setup.md` presenta ancora l'installazione come «in locale o via Docker», contro la regola «solo Docker» | Bassa | **aperto** | — | OPS |
|
||||
| B-71 | `.env` punta `MASTER_KEY_PATH` a una seconda copia della chiave master (e `DATABASE_URL` a un secondo database) invece che ai file in `./data/` letti dal container | Bassa | corretto con l'allineamento dei file e B-89 | `test_master_key_check.py` | OPS, SEC |
|
||||
| B-72 | `docs/setup.md` presenta ancora l'installazione come «in locale o via Docker», contro la regola «solo Docker» | Bassa | *da registrare* | — (documentazione) | OPS |
|
||||
|
||||
Note:
|
||||
|
||||
@@ -204,7 +204,7 @@ Note:
|
||||
insieme» di [scommesse-e-prelievi.md](scommesse-e-prelievi.md): quel titolo e
|
||||
le frasi che il test cerca vanno mantenuti.
|
||||
|
||||
## Revisione della specifica del 2026-09-28 (B-73 … B-98)
|
||||
## Revisione della specifica del 2026-09-28 (B-73 … B-100)
|
||||
|
||||
Quarta revisione, condotta sulla specifica in `docs/` invece che sul solo
|
||||
codice: quattro analisi indipendenti (meccanica della coin, macchina a stati
|
||||
@@ -230,31 +230,32 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
| B-79 | Il pagamento di norma non ha resto, quindi non si può accelerare e può bloccare la lotteria | Critica | *da registrare* | `test_payout_builder.py`, `test_broadcast.py`, `test_scheduler.py`, `test_rounds_service.py`, `test_admin.py`, `test_migrations.py` | RND, TX |
|
||||
| B-80 | Un solo indirizzo del montepremi per tutti i round | Critica | *da registrare* | `test_round_pool_address.py`, `test_pending_address.py`, `test_scheduler.py` | RND, BET, WDR |
|
||||
| B-81 | `drawing` resta bloccato dopo qualsiasi eccezione, non solo dopo un riavvio | Critica | *da registrare* | `test_draw_proof.py` | RND |
|
||||
| B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | **aperto** | — | DEP, RND |
|
||||
| B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | *da registrare* | `test_deposit_confirmations.py`, `test_pool_verification.py`, `test_electrum_listener.py`, `test_admin.py`, `test_users.py` | DEP, RND |
|
||||
| B-83 | Conferme e reintegri si basano solo sul server attivo | Alta | *da registrare* | `test_confirmation_proof.py`, `test_confirmation.py` | ELC, TX, DEP |
|
||||
| B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | *da registrare* | `test_electrum_client.py`, `test_electrum_listener.py` | ELC, SEC |
|
||||
| B-85 | Le riorganizzazioni della catena non vengono né seguite né segnalate | Alta | *da registrare* | `test_electrum_listener.py` | ELC, RND |
|
||||
| B-86 | Un server connesso ma fermo non viene mai sostituito | Alta | *da registrare* | `test_electrum_listener.py` | ELC, OPS |
|
||||
| B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | **aperto** | — | TX |
|
||||
| B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | *da registrare* | `test_rbf_dust.py`, `test_broadcast.py` | TX |
|
||||
| B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC |
|
||||
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | **aperto** | — | SEC, OPS |
|
||||
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | **aperto** | — | OPS, REG |
|
||||
| B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | **aperto** | — | SEC, OPS |
|
||||
| B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API |
|
||||
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | *da registrare* | `test_master_key_check.py` | SEC, OPS |
|
||||
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | *da registrare* | `test_restore_gap.py` | OPS, REG |
|
||||
| B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | *da registrare* | `test_container_hardening.py`, `test_config.py` | SEC, OPS |
|
||||
| B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | *da registrare* | `test_observability.py` | OPS, AUD, API |
|
||||
| B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS |
|
||||
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | **aperto** | — | SEC, API |
|
||||
| B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | **aperto** | — | RND, BET |
|
||||
| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | **aperto** | — | TX, BET, WDR, DEP |
|
||||
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | *da registrare* | `test_abuse_limits.py` | SEC, API |
|
||||
| B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | *da registrare* | `test_round_snapshot.py`, `test_admin.py`, `test_rounds_route.py`, `test_scheduler.py` | RND, BET |
|
||||
| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | *da registrare* | `test_state_consistency.py` | TX, BET, WDR, DEP |
|
||||
| B-97 | Le migrazioni possono eliminare in silenzio gli indici su espressione | Media | *da registrare* | `test_migrations.py` | DAT |
|
||||
| B-98 | La selezione delle monete si ferma alla prima che copre l'importo e rifiuta una scommessa pagabile | Media | **aperto** | — | BET, WDR |
|
||||
| B-98 | La selezione delle monete si ferma alla prima che copre l'importo e rifiuta una scommessa pagabile | Media | *da registrare* | `test_psbt_builder.py` | BET, WDR |
|
||||
| B-99 | Un server Electrum di Bitcoin supera ogni controllo sugli header (PLM ne condivide genesis e `powLimit`): il genesis non veniva verificato | Media | *da registrare* | `test_electrum_client.py` | ELC |
|
||||
| B-100 | Il tetto del fee rate coincide con il `maxfeerate` del nodo (10 000 sat/vB): una tariffa al tetto, con vsize stimato per eccesso, verrebbe rifiutata | Bassa | *da registrare* | — (costante; copertura da `test_rbf_dust.py`) | TX |
|
||||
|
||||
## Stato attuale
|
||||
|
||||
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
|
||||
- **Corretti:** B-01 … B-72, tranne B-16, che è chiuso nella sostanza (il
|
||||
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
|
||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79.
|
||||
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
|
||||
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (1 critico,
|
||||
4 alti, 6 medi), da implementare nell'ordine di
|
||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-92, B-94, B-95, B-96, B-98, B-99, B-100.
|
||||
- **Aperti:** dalla revisione del 2026-09-28, B-93 (0 critici,
|
||||
0 alti, 1 medio), da implementare nell'ordine di
|
||||
[piano-correzioni.md](piano-correzioni.md).
|
||||
- **Prossimo numero libero:** `B-99`.
|
||||
- **Prossimo numero libero:** `B-101`.
|
||||
@@ -217,10 +217,10 @@ sostituibilità fin dal primo invio.
|
||||
*Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:should_bump` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
- **[TX-10]** La nuova commissione punta a 1 sat/vB in più della tariffa
|
||||
registrata (`_FEE_RATE_INCREMENT`), senza superare 10.000 sat/vB
|
||||
registrata (`_FEE_RATE_INCREMENT`), senza superare 5.000 sat/vB
|
||||
(`MAX_FEE_RATE_SAT_VB`); l'aumento assoluto non è mai inferiore alla
|
||||
dimensione stimata della transazione per 1 sat/vB, il minimo richiesto da
|
||||
BIP125 per una sostituzione. Una riga già a 10.000 sat/vB non viene più
|
||||
BIP125 per una sostituzione. Una riga già a 5.000 sat/vB non viene più
|
||||
accelerata. Gli importi delle monete spese sono letti dalle transazioni
|
||||
precedenti come interi in satoshi.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_prevout_amount` ·
|
||||
@@ -243,11 +243,7 @@ sostituibilità fin dal primo invio.
|
||||
partita ([TX-17]): finché almeno un server la tiene in mempool non viene
|
||||
mai abbandonata.
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_find_change_output` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
*Stato:* da implementare (B-87) · *Oggi:* il pagamento si accelera già
|
||||
dall'uscita di riserva (B-79), ma per ogni transazione basta che l'uscita
|
||||
superi l'aumento: un resto di 294 sat diventa 153 sat, sotto la soglia di
|
||||
dust, e la rete rifiuta la sostituzione a ogni giro.
|
||||
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_rbf_dust.py`
|
||||
- **[TX-12]** **Prima si scrive la sostituzione, poi si invia.** La
|
||||
sostituzione firmata viene registrata come candidata in
|
||||
`pending_transaction_txids` (txid e transazione completa, [TX-19]) e
|
||||
@@ -282,10 +278,8 @@ sostituibilità fin dal primo invio.
|
||||
tentativo che fallisce di nuovo per lo stesso motivo sullo stesso txid va
|
||||
solo nei log, così una transazione non accelerabile non riempie il registro
|
||||
con una riga ogni 30 secondi.
|
||||
*Codice:* `app/tx/broadcast.py:RbfBumper._tick` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-87) · *Oggi:* l'errore finisce solo nei log
|
||||
dell'applicazione, invisibile da `/admin`.
|
||||
*Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:RbfBumper._record_failure` ·
|
||||
*Test:* `tests/unit/test_rbf_dust.py`
|
||||
|
||||
Il resto minimo di scommesse e prelievi è di 294 sat
|
||||
([BET-14](scommesse-e-prelievi.md#cosa-succede),
|
||||
@@ -334,11 +328,7 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
|
||||
corrente vale [TX-19]. Da lì la conferma segue la via normale. Una riga già
|
||||
`pending` presente in rete non viene toccata.
|
||||
*Codice:* `app/tx/reconcile.py:_promote` ·
|
||||
*Test:* `tests/unit/test_reconcile.py`
|
||||
*Stato:* da implementare (B-96) · *Oggi:* si scrive solo
|
||||
`pending_tx_recovered`: una scommessa o un pagamento recuperati non hanno
|
||||
mai `bet_placed` o `payout_sent` (e un prelievo recuperato non ha
|
||||
`withdrawal_sent`).
|
||||
*Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_state_consistency.py`
|
||||
- **[TX-17]** **Abbandono solo con prova.** Una riga viene abbandonata solo
|
||||
se vale una di queste due prove:
|
||||
1. un suo ingresso risulta **speso in un blocco** da una transazione che
|
||||
@@ -376,9 +366,7 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
|
||||
cui ha cambiato qualcosa (promozione, abbandono, riallineamento del txid),
|
||||
non per ogni riga esaminata.
|
||||
*Codice:* `app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* pubblica una notifica per ogni
|
||||
riga che ha interrogato, anche quando non cambia nulla.
|
||||
*Test:* `tests/unit/test_state_consistency.py`
|
||||
- **[TX-25]** Una scommessa in volo esce dall'attesa del round in `closing`
|
||||
([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#2--chiusura)) in
|
||||
due soli modi: confermata ([TX-07]) o abbandonata con prova ([TX-17]). Non
|
||||
@@ -389,16 +377,14 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
|
||||
- **[TX-29]** L'attesa in `closing` senza tempo massimo ([TX-25]) è
|
||||
**visibile**: quando un round resta in `closing` oltre la scadenza per più
|
||||
di `CLOSING_STALL_THRESHOLD_SECONDS` (1800 s), lo scheduler scrive un
|
||||
evento `closing_stalled` ([audit-log.md](audit-log.md) (B-96)) con il round,
|
||||
evento `closing_stalled` ([AUD-46](audit-log.md)) con il round,
|
||||
il numero di scommesse ancora in volo e i loro txid, e lo ripete ogni 1800 s
|
||||
finché l'attesa dura; `GET /rounds/current` espone da quando il round attende
|
||||
(`closing_waiting_since`). L'operatore decide con la procedura di
|
||||
[operativita.md](operativita.md) (B-96); il sistema non esclude da solo una
|
||||
[OPS-14](operativita.md#round-bloccato-in-closing); il sistema non esclude da solo una
|
||||
scommessa in volo dall'estrazione.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-96) · *Oggi:* l'attesa non produce alcun
|
||||
segnale oltre al round che resta in `closing`.
|
||||
*Test:* `tests/unit/test_state_consistency.py`
|
||||
|
||||
## Pagamenti del round: riprovare senza pagare due volte
|
||||
|
||||
@@ -420,11 +406,7 @@ queste ultime due, un nuovo tentativo potrebbe pagare il vincitore due volte.
|
||||
`paying_out` senza transazione né importi di un pagamento che non esiste, e
|
||||
lo scheduler ricostruisce il pagamento al tentativo successivo.
|
||||
*Codice:* `app/tx/reconcile.py:_abandon`, `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` ·
|
||||
*Test:* — (`tests/unit/test_scheduler.py` verifica i nuovi tentativi di un
|
||||
round in `paying_out`, non la sequenza abbandono → ricostruzione)
|
||||
*Stato:* da implementare (B-96) · *Oggi:* azzera solo `payout_txid`;
|
||||
`winner_amount_sats` e `fee_amount_sats` restano quelli del pagamento
|
||||
abbandonato.
|
||||
*Test:* `tests/unit/test_state_consistency.py`
|
||||
|
||||
## Dove guardare
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""snapshot the round's parameters at open: rounds.bet_amount_sats/fee_address/max_round_pool_sats, round_config.max_round_pool_sats (B-95)
|
||||
|
||||
Revision ID: a7d3e9f15b48
|
||||
Revises: f2c64a8d1e93
|
||||
Create Date: 2026-10-15 16:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a7d3e9f15b48'
|
||||
down_revision: Union[str, Sequence[str], None] = 'f2c64a8d1e93'
|
||||
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:
|
||||
"""Upgrade schema.
|
||||
|
||||
B-95 (DAT-27, DAT-55, DAT-61, DAT-62): a round now carries its own `bet_amount_sats`,
|
||||
`fee_address` and `max_round_pool_sats`, copied from `round_config` when it opens. Rounds
|
||||
that already exist are filled with the *current* values of `round_config` — for one still
|
||||
in progress that is exactly what it was using until now, since it read them live. The
|
||||
server defaults stay (changing them away would rebuild the table on SQLite).
|
||||
|
||||
Plain add_column / UPDATE calls — never batch_alter_table, which on SQLite rebuilds the
|
||||
table and silently drops the expression indexes (B-97, DAT-56).
|
||||
"""
|
||||
op.add_column("round_config", sa.Column("max_round_pool_sats", sa.BigInteger(), server_default="0", nullable=False))
|
||||
op.add_column("rounds", sa.Column("bet_amount_sats", sa.BigInteger(), server_default="1000000000", nullable=False))
|
||||
op.add_column("rounds", sa.Column("fee_address", sa.String(length=128), server_default="", nullable=False))
|
||||
op.add_column("rounds", sa.Column("max_round_pool_sats", sa.BigInteger(), server_default="0", nullable=False))
|
||||
op.execute(
|
||||
"UPDATE rounds SET "
|
||||
"bet_amount_sats = COALESCE((SELECT bet_amount_sats FROM round_config ORDER BY id LIMIT 1), bet_amount_sats), "
|
||||
"fee_address = COALESCE((SELECT fee_address FROM round_config ORDER BY id LIMIT 1), fee_address)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
for column in ("max_round_pool_sats", "fee_address", "bet_amount_sats"):
|
||||
op.drop_column("rounds", column)
|
||||
op.drop_column("round_config", "max_round_pool_sats")
|
||||
# drop_column on SQLite is a table rebuild: recreate the expression index (B-97).
|
||||
op.execute("DROP INDEX IF EXISTS ix_rounds_single_active")
|
||||
op.execute(f"CREATE UNIQUE INDEX ix_rounds_single_active ON rounds ((1)) WHERE status IN ({_ACTIVE})")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""deposit confirmations and the held payout: round_config.deposit_confirmations, rounds.payout_blocked_at (B-82)
|
||||
|
||||
Revision ID: f2c64a8d1e93
|
||||
Revises: e5a91c3d7b26
|
||||
Create Date: 2026-10-15 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f2c64a8d1e93'
|
||||
down_revision: Union[str, Sequence[str], None] = 'e5a91c3d7b26'
|
||||
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:
|
||||
"""Upgrade schema.
|
||||
|
||||
B-82 (DAT-62, DAT-68): `round_config.deposit_confirmations` (default 6) is how many
|
||||
confirmations an output paying a user needs before it is credited as a deposit;
|
||||
`rounds.payout_blocked_at` marks a payout held because the winner's own bet left the
|
||||
chain. Both are plain add_column calls — never batch_alter_table, which on SQLite
|
||||
rebuilds the table and silently drops the expression indexes (B-97, DAT-56).
|
||||
"""
|
||||
op.add_column(
|
||||
"round_config", sa.Column("deposit_confirmations", sa.Integer(), server_default="6", nullable=False)
|
||||
)
|
||||
op.add_column("rounds", sa.Column("payout_blocked_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column("rounds", "payout_blocked_at")
|
||||
op.drop_column("round_config", "deposit_confirmations")
|
||||
# drop_column on SQLite is a table rebuild: recreate the expression index (B-97).
|
||||
op.execute("DROP INDEX IF EXISTS ix_rounds_single_active")
|
||||
op.execute(f"CREATE UNIQUE INDEX ix_rounds_single_active ON rounds ((1)) WHERE status IN ({_ACTIVE})")
|
||||
+1
-1
@@ -5,7 +5,7 @@ requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"sqlalchemy>=2.0",
|
||||
"sqlalchemy[asyncio]>=2.0",
|
||||
"aiosqlite>=0.20",
|
||||
"alembic>=1.13",
|
||||
"pydantic-settings>=2.6",
|
||||
|
||||
+1301
File diff suppressed because it is too large.
Load diff
@@ -8,7 +8,7 @@ locally, only when you actually need it (e.g. disaster-recovery backup).
|
||||
Usage:
|
||||
python scripts/decrypt_master_key.py
|
||||
|
||||
Requires XPRV_ENCRYPTION_KEY to already be set (.env), same as the running
|
||||
Requires XPRV_ENCRYPTION_KEY_FILE (.env) to point at the key file, same as the running
|
||||
server. Looks for the encrypted key at MASTER_KEY_PATH (.env) — if that path
|
||||
doesn't exist, falls back to ./data/keys/master.xprv.enc, the location
|
||||
docker-compose.yml bind-mounts it to, since .env's default (./master.xprv.enc)
|
||||
@@ -34,7 +34,7 @@ def _resolve_key_path() -> str:
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not settings.xprv_encryption_key:
|
||||
raise SystemExit("XPRV_ENCRYPTION_KEY is not set")
|
||||
raise SystemExit("XPRV_ENCRYPTION_KEY_FILE is not set, or its file is missing or empty")
|
||||
|
||||
key_path = _resolve_key_path()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""One-time ops bootstrap: encrypt an externally-generated master xprv and
|
||||
write it to disk, using the exact same scheme generate_master_key.py uses
|
||||
(Fernet, XPRV_ENCRYPTION_KEY). Use this instead of generate_master_key.py
|
||||
(Fernet, the key in XPRV_ENCRYPTION_KEY_FILE). Use this instead of generate_master_key.py
|
||||
when you already have an xprv from elsewhere (e.g. generated offline/air-
|
||||
gapped) and want to bring your own instead of letting the server create one.
|
||||
|
||||
@@ -12,7 +12,7 @@ Usage:
|
||||
python scripts/encrypt_master_key.py
|
||||
python scripts/encrypt_master_key.py --overwrite # replace an existing key
|
||||
|
||||
Requires XPRV_ENCRYPTION_KEY to already be set (.env). Writes to
|
||||
Requires XPRV_ENCRYPTION_KEY_FILE (.env) to point at the key file. Writes to
|
||||
MASTER_KEY_PATH (.env) — falls back to ./data/keys/master.xprv.enc (the
|
||||
docker-compose.yml bind-mount location) if that directory exists and .env's
|
||||
default path doesn't, same fallback as decrypt_master_key.py.
|
||||
@@ -25,7 +25,8 @@ import os
|
||||
from embit.bip32 import HDKey
|
||||
|
||||
from app.config import settings
|
||||
from app.wallet.keystore import encrypt_xprv
|
||||
from app.wallet.keystore import encrypt_xprv, write_secret_file
|
||||
from app.wallet.verify import account_key, load_sample, sample_problems
|
||||
|
||||
_DOCKER_COMPOSE_DIR = "./data/keys"
|
||||
|
||||
@@ -47,7 +48,7 @@ if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
|
||||
if not settings.xprv_encryption_key:
|
||||
raise SystemExit("XPRV_ENCRYPTION_KEY is not set")
|
||||
raise SystemExit("XPRV_ENCRYPTION_KEY_FILE is not set, or its file is missing or empty")
|
||||
|
||||
key_path = _resolve_key_path()
|
||||
if os.path.exists(key_path) and not args.overwrite:
|
||||
@@ -69,8 +70,13 @@ if __name__ == "__main__":
|
||||
if not parsed.is_private:
|
||||
raise SystemExit("This is a public key (xpub), not a private key (xprv) — refusing to encrypt it as one.")
|
||||
|
||||
# SEC-05/SEC-50: the version bytes cannot tell a PLM key from a Bitcoin one, the stored
|
||||
# addresses can. A mismatch is refused even with --overwrite.
|
||||
problems = sample_problems(account_key(xprv), load_sample())
|
||||
if problems:
|
||||
raise SystemExit("this xprv does not match the database — refusing to write it: " + "; ".join(problems))
|
||||
|
||||
os.makedirs(os.path.dirname(key_path) or ".", exist_ok=True)
|
||||
with open(key_path, "wb") as f:
|
||||
f.write(encrypt_xprv(xprv))
|
||||
write_secret_file(key_path, encrypt_xprv(xprv))
|
||||
|
||||
print(f"Master key written (encrypted) to {key_path}")
|
||||
@@ -2,15 +2,20 @@
|
||||
|
||||
Usage:
|
||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# put that value in XPRV_ENCRYPTION_KEY (.env), then:
|
||||
# write it to the file XPRV_ENCRYPTION_KEY_FILE (.env) points at, then:
|
||||
python scripts/generate_master_key.py
|
||||
"""
|
||||
|
||||
from app.config import settings
|
||||
from app.wallet.hd import generate_master_key
|
||||
from app.wallet.verify import load_sample
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not settings.xprv_encryption_key:
|
||||
raise SystemExit("XPRV_ENCRYPTION_KEY is not set")
|
||||
raise SystemExit("XPRV_ENCRYPTION_KEY_FILE is not set, or its file is missing or empty")
|
||||
sample = load_sample()
|
||||
if sample.users or sample.rounds or sample.seeds:
|
||||
# SEC-50: a new key cannot reproduce the addresses the database already holds.
|
||||
raise SystemExit("the database already has users or rounds: a new key would not reproduce their addresses")
|
||||
generate_master_key()
|
||||
print(f"Master key written (encrypted) to {settings.master_key_path}")
|
||||
@@ -25,6 +25,15 @@ def _throwaway_master_key(tmp_path, monkeypatch):
|
||||
hd._account_key = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _registration_open(monkeypatch):
|
||||
"""B-90: a real listener keeps registration closed until its restore-gap check ran;
|
||||
only the tests about that check care, and they set it themselves."""
|
||||
from app.electrum.listener import ElectrumListener
|
||||
|
||||
monkeypatch.setattr(ElectrumListener, "registration_open", True)
|
||||
|
||||
|
||||
# B-74: the real powLimit needs ~2^32 hashes per header, so tests mine headers at a trivial
|
||||
# target (`0x207fffff`) and this swaps in a limit that allows it. Done at import, not in a
|
||||
# fixture, because some test modules mine headers while they are being collected. The
|
||||
|
||||
@@ -19,6 +19,12 @@ class FakeNetwork:
|
||||
self.asked: list[str] = []
|
||||
self.fanned_out: list[str] = []
|
||||
self.proofs_asked: list[tuple[str, int]] = []
|
||||
# B-82: what `verify_pool_outpoints` answers. Every bet output is present unless listed
|
||||
# in `pool_absent`; `pool_unavailable` models a quorum that could not be reached.
|
||||
self.pool_absent: set[tuple[str, int]] = set()
|
||||
self.pool_unavailable = False
|
||||
self.verified_scripthashes: list[str] = []
|
||||
self.verified_outpoints: list[list[tuple[str, int, int]]] = []
|
||||
|
||||
async def transaction_known(self, txid: str) -> bool | None:
|
||||
self.asked.append(txid)
|
||||
@@ -31,6 +37,16 @@ class FakeNetwork:
|
||||
self.proofs_asked.append((txid, height))
|
||||
return self.proven
|
||||
|
||||
async def verify_pool_outpoints(self, scripthash, outpoints):
|
||||
self.verified_scripthashes.append(scripthash)
|
||||
self.verified_outpoints.append(list(outpoints))
|
||||
hook = getattr(self, "on_verify", None)
|
||||
if hook is not None:
|
||||
await hook()
|
||||
if self.pool_unavailable:
|
||||
return None
|
||||
return {(txid, vout): (txid, vout) not in self.pool_absent for txid, vout, _amount in outpoints}
|
||||
|
||||
|
||||
MISSING_INPUTS = "bad-txns-inputs-missingorspent"
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""B-94 (SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12):
|
||||
credential length bounds, the password-change brake and audit, the bug-report quota, paged
|
||||
user list, structured errors everywhere and revalidated static files."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
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, "admin_token", "admin-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.db.models # noqa: F401
|
||||
import app.wallet.hd as hd
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db import base as db_base
|
||||
from app.db import session as db_session
|
||||
|
||||
hd._account_key = None
|
||||
hd.generate_master_key()
|
||||
db_base.engine = create_async_engine(settings.database_url)
|
||||
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
|
||||
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 app.electrum.listener import ElectrumListener
|
||||
from app.main import app
|
||||
|
||||
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
|
||||
app.state.auth_rate_limiters = None
|
||||
if hasattr(app.state, "auth_rate_limiters"):
|
||||
del app.state.auth_rate_limiters # fresh throttle state per test
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
await db_base.engine.dispose()
|
||||
|
||||
|
||||
ADMIN = {"X-Admin-Token": "admin-secret"}
|
||||
|
||||
|
||||
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 {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
|
||||
|
||||
def _code(resp):
|
||||
return resp.json()["detail"]["code"]
|
||||
|
||||
|
||||
# --- field lengths (API-19, REG-12) and the 422 shape (API-15) ---------------------
|
||||
|
||||
|
||||
async def test_login_fields_are_bounded_and_the_value_is_not_echoed(client):
|
||||
resp = await client.post("/auth/login", json={"username": "a", "password": "secret-" + "x" * 300})
|
||||
assert resp.status_code == 422
|
||||
assert _code(resp) == "invalid_request"
|
||||
errors = resp.json()["detail"]["params"]["errors"]
|
||||
assert errors[0]["loc"] == ["body", "password"] and set(errors[0]) == {"loc", "msg", "type"}
|
||||
assert "secret-" not in resp.text
|
||||
|
||||
|
||||
async def test_change_password_fields_are_bounded(client):
|
||||
headers = await _register(client)
|
||||
resp = await client.post(
|
||||
"/users/me/change-password",
|
||||
headers=headers,
|
||||
json={"current_password": "original-password", "new_password": "x" * 257},
|
||||
)
|
||||
assert resp.status_code == 422 and _code(resp) == "invalid_request"
|
||||
|
||||
|
||||
# --- the password-change brake (SEC-54) and its audit (AUD-42) ---------------------
|
||||
|
||||
|
||||
async def test_the_fifth_wrong_current_password_locks_even_the_right_one_out(client):
|
||||
headers = await _register(client)
|
||||
body = {"current_password": "wrong-password", "new_password": "brand-new-password"}
|
||||
for _ in range(5):
|
||||
assert (await client.post("/users/me/change-password", headers=headers, json=body)).status_code == 401
|
||||
resp = await client.post("/users/me/change-password", headers=headers, json=body)
|
||||
assert resp.status_code == 429 and _code(resp) == "rate_limited"
|
||||
right = {"current_password": "original-password", "new_password": "brand-new-password"}
|
||||
locked = await client.post("/users/me/change-password", headers=headers, json=right)
|
||||
assert locked.status_code == 429 and locked.json()["detail"]["params"]["retry_after_seconds"] >= 1
|
||||
|
||||
|
||||
async def test_a_successful_change_is_audited_and_clears_the_brake(client):
|
||||
from app.db import base as db_base
|
||||
from app.db.models import AuditLog
|
||||
|
||||
headers = await _register(client)
|
||||
bad = {"current_password": "wrong-password", "new_password": "brand-new-password"}
|
||||
for _ in range(3):
|
||||
await client.post("/users/me/change-password", headers=headers, json=bad)
|
||||
ok = {"current_password": "original-password", "new_password": "brand-new-password"}
|
||||
assert (await client.post("/users/me/change-password", headers=headers, json=ok)).status_code == 200
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
rows = (await session.execute(select(AuditLog).where(AuditLog.event_type == "password_changed"))).scalars().all()
|
||||
assert len(rows) == 1 and json.loads(rows[0].payload_json) == {"user_id": rows[0].user_id}
|
||||
assert "password" not in rows[0].payload_json.replace("user_id", "")
|
||||
|
||||
|
||||
# --- bug reports (SEC-55) ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_11th_bug_report_from_one_ip_waits_but_an_invalid_one_costs_nothing(client):
|
||||
for _ in range(3):
|
||||
assert (await client.post("/bug-reports", json={"description": " "})).status_code == 422
|
||||
for i in range(10):
|
||||
assert (await client.post("/bug-reports", json={"description": f"bug {i}"})).status_code == 201
|
||||
resp = await client.post("/bug-reports", json={"description": "one too many"})
|
||||
assert resp.status_code == 429 and _code(resp) == "rate_limited"
|
||||
assert 3000 < resp.json()["detail"]["params"]["retry_after_seconds"] <= 3601
|
||||
|
||||
|
||||
# --- paged user list (API-33) ------------------------------------------------------
|
||||
|
||||
|
||||
async def test_admin_users_are_paged_by_id(client):
|
||||
for i in range(5):
|
||||
await _register(client, username=f"user{i}")
|
||||
first = (await client.get("/admin/users", headers=ADMIN, params={"limit": 2})).json()
|
||||
second = (await client.get("/admin/users", headers=ADMIN, params={"limit": 2, "after_id": first[-1]["id"]})).json()
|
||||
last = (await client.get("/admin/users", headers=ADMIN, params={"limit": 2, "after_id": second[-1]["id"]})).json()
|
||||
assert [u["username"] for u in first + second + last] == [f"user{i}" for i in range(5)]
|
||||
assert len(last) == 1
|
||||
assert (await client.get("/admin/users", headers=ADMIN, params={"limit": 501})).status_code == 422
|
||||
|
||||
|
||||
# --- structured errors everywhere (API-16) -----------------------------------------
|
||||
|
||||
|
||||
async def test_admin_errors_are_structured(client):
|
||||
wrong = await client.get("/admin/users", headers={"X-Admin-Token": "nope"})
|
||||
assert wrong.status_code == 403 and _code(wrong) == "invalid_admin_token"
|
||||
missing = await client.post("/admin/users/999/reset-password", headers=ADMIN)
|
||||
assert missing.status_code == 404 and _code(missing) == "user_not_found"
|
||||
report = await client.post("/admin/bug-reports/999/status", headers=ADMIN, json={"status": "read"})
|
||||
assert report.status_code == 404 and _code(report) == "bug_report_not_found"
|
||||
|
||||
|
||||
async def test_framework_errors_are_structured(client):
|
||||
absent = await client.get("/users/me")
|
||||
assert absent.status_code == 401 and _code(absent) == "not_authenticated"
|
||||
assert absent.headers["www-authenticate"] == "Bearer"
|
||||
assert _code(await client.get("/no/such/path")) == "not_found"
|
||||
wrong_method = await client.put("/rounds/current")
|
||||
assert _code(wrong_method) in {"method_not_allowed", "not_found"}
|
||||
assert (await client.get("/users/me", headers={"Authorization": "Bearer junk"})).json()["detail"]["code"] == "session_expired"
|
||||
|
||||
|
||||
async def test_the_sse_cap_answers_in_the_structured_shape(client, monkeypatch):
|
||||
from app.api.routes import rounds
|
||||
from app.rounds.events import RoundEventCapacityError
|
||||
|
||||
def full(_ip):
|
||||
raise RoundEventCapacityError()
|
||||
|
||||
monkeypatch.setattr(rounds.broadcaster, "subscribe", full)
|
||||
resp = await client.get("/rounds/stream")
|
||||
assert resp.status_code == 503 and _code(resp) == "too_many_streams"
|
||||
|
||||
|
||||
# --- static files (API-09) ---------------------------------------------------------
|
||||
|
||||
|
||||
async def test_static_files_are_revalidated_on_every_use(client):
|
||||
resp = await client.get("/app.js")
|
||||
assert resp.status_code == 200 and resp.headers["cache-control"] == "no-cache"
|
||||
assert resp.headers.get("etag")
|
||||
@@ -265,6 +265,8 @@ async def test_config_update_without_changes_logs_nothing(client):
|
||||
{"rbf_timeout_seconds": 1},
|
||||
{"draw_confirmations": 0}, # B-73: a draw with no confirmations is a draw on the tip
|
||||
{"draw_confirmations": 21},
|
||||
{"deposit_confirmations": 0}, # B-82: a deposit credited with no confirmation at all
|
||||
{"deposit_confirmations": 101},
|
||||
],
|
||||
)
|
||||
async def test_config_rejects_unusable_values(client, payload):
|
||||
@@ -466,3 +468,95 @@ async def test_config_still_accepts_a_payable_change_and_unrelated_edits(client)
|
||||
assert resp.status_code == 200
|
||||
# an unrelated edit is never blocked by the payability check
|
||||
assert (await client.put("/admin/config", headers=headers, json={"draw_animation_seconds": 5})).status_code == 200
|
||||
|
||||
|
||||
async def test_deposit_confirmations_is_configurable_between_1_and_100(client): # B-82 (DAT-62, API-31)
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
assert (await client.get("/admin/config", headers=headers)).json()["deposit_confirmations"] == 6
|
||||
for value in (1, 100, 12):
|
||||
resp = await client.put("/admin/config", headers=headers, json={"deposit_confirmations": value})
|
||||
assert resp.status_code == 200 and resp.json()["deposit_confirmations"] == value
|
||||
audit = (await client.get("/admin/audit-log", headers=headers)).json()
|
||||
changes = [e["payload"]["deposit_confirmations"] for e in audit if e["event_type"] == "config_updated"]
|
||||
assert {"from": 100, "to": 12} in changes
|
||||
|
||||
|
||||
async def test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain(client): # B-82 (DAT-68, API-32)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import base as db_base
|
||||
from app.db.models import AuditLog, Round
|
||||
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported
|
||||
await conn.run_sync(db_base.Base.metadata.create_all)
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
session.add(Round(status="paying_out", payout_blocked_at=datetime.now(timezone.utc)))
|
||||
session.add(Round(status="closed"))
|
||||
await session.commit()
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
from app.rounds.pause import pause_automatically
|
||||
|
||||
await pause_automatically(session, "payout_blocked")
|
||||
await session.commit()
|
||||
assert (await client.get("/admin/config", headers=headers)).json()["paused_reason"] == "payout_blocked"
|
||||
listed = (await client.get("/admin/rounds", headers=headers)).json() # API-36: the mark is visible
|
||||
assert sorted(r["payout_blocked_at"] is not None for r in listed) == [False, True]
|
||||
|
||||
resp = await client.post("/admin/resume", headers=headers)
|
||||
assert resp.status_code == 200 and resp.json()["paused"] is False and resp.json()["paused_reason"] is None
|
||||
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
assert (await session.scalars(select(Round.payout_blocked_at))).all() == [None, None]
|
||||
resumed = [e for e in (await session.scalars(select(AuditLog))).all() if e.event_type == "lottery_resumed"]
|
||||
assert resumed and '"payout_blocked"' in resumed[0].payload_json
|
||||
|
||||
|
||||
# --- B-95 (DAT-62, API-31): the cap on a round's pool ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_max_round_pool_sats_is_configurable_and_bounded(client):
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
assert (await client.get("/admin/config", headers=headers)).json()["max_round_pool_sats"] == 0
|
||||
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
|
||||
assert (await put({"max_round_pool_sats": 50_000_000_000})).json()["max_round_pool_sats"] == 50_000_000_000
|
||||
assert (await put({"max_round_pool_sats": 0})).json()["max_round_pool_sats"] == 0 # 0 switches it off
|
||||
assert (await put({"max_round_pool_sats": 4_000_000_000_000_000})).status_code == 200
|
||||
assert (await put({"max_round_pool_sats": 4_000_000_000_000_001})).status_code == 422
|
||||
assert (await put({"max_round_pool_sats": -1})).status_code == 422
|
||||
|
||||
|
||||
async def test_a_cap_below_the_price_of_one_bet_is_refused_whichever_field_makes_it(client):
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
|
||||
resp = await put({"max_round_pool_sats": 999_999_999}) # the bet costs 1_000_000_000
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "max_round_pool_below_bet_amount"
|
||||
assert (await put({"max_round_pool_sats": 1_000_000_000})).status_code == 200 # exactly one bet fits
|
||||
resp = await put({"bet_amount_sats": 2_000_000_000}) # now the bet would exceed the stored cap
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "max_round_pool_below_bet_amount"
|
||||
assert (await put({"bet_amount_sats": 2_000_000_000, "max_round_pool_sats": 6_000_000_000})).status_code == 200
|
||||
assert (await client.get("/admin/config", headers=headers)).json()["bet_amount_sats"] == 2_000_000_000
|
||||
|
||||
|
||||
async def test_a_fee_rate_is_checked_against_the_bet_amount_of_the_round_in_progress(client):
|
||||
"""B-95 with RND-40: the round in progress is paid with the cost it opened with, which can be
|
||||
lower than the one configured now, so the fee rate has to leave that one payable too."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.db import base as db_base
|
||||
from app.db.models import Round
|
||||
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
|
||||
async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported
|
||||
await conn.run_sync(db_base.Base.metadata.create_all)
|
||||
assert (await put({"bet_amount_sats": 100_000_000})).status_code == 200
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
session.add(Round(status="open", opened_at=datetime.now(timezone.utc), bet_amount_sats=50_000)) # opened cheap
|
||||
await session.commit()
|
||||
# 14 sat/vB is fine for the configured 1 PLM (and for 100 PLM) but not for the 50_000 sat round
|
||||
resp = await put({"fee_rate_sat_vb": 14})
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "config_not_payable"
|
||||
assert (await put({"fee_rate_sat_vb": 10})).status_code == 200
|
||||
@@ -366,6 +366,7 @@ async def _flagged_user(session_factory) -> int:
|
||||
|
||||
def _reinstate_listener(session_factory, others: dict[str, _UnspentServer], active_entries):
|
||||
listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, _ENDPOINTS)
|
||||
listener.tip_height = 1000 # B-82: far past every credit threshold, coinbase maturity included
|
||||
listener.client = _ActiveClient(active_entries)
|
||||
return listener
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""B-91 (SEC-51, SEC-52, SEC-53): non-root, pinned images and dependencies, the key in a file."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import ConfigError, Settings, validate_runtime_secrets
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _read(name):
|
||||
return (ROOT / name).read_text()
|
||||
|
||||
|
||||
def test_the_dockerfile_pins_its_base_image_and_runs_as_a_non_root_user():
|
||||
dockerfile = _read("Dockerfile")
|
||||
assert re.search(r"^FROM python:3\.12-slim@sha256:[0-9a-f]{64}$", dockerfile, re.M)
|
||||
assert re.search(r"^USER 1000:1000$", dockerfile, re.M)
|
||||
assert "--require-hashes" in dockerfile and "umask 077" in dockerfile
|
||||
|
||||
|
||||
def test_every_locked_requirement_carries_a_hash():
|
||||
lock = _read("requirements.lock")
|
||||
pinned = re.findall(r"^([A-Za-z0-9_.\-]+==\S+) \\$", lock, re.M)
|
||||
assert len(pinned) > 20
|
||||
blocks = re.split(r"\n(?=[A-Za-z0-9_.\-]+==)", lock)[1:]
|
||||
assert all("--hash=sha256:" in block for block in blocks)
|
||||
|
||||
|
||||
def test_compose_pins_caddy_and_keeps_the_key_out_of_the_environment():
|
||||
compose = _read("docker-compose.yml")
|
||||
assert re.search(r"image: caddy:2-alpine@sha256:[0-9a-f]{64}", compose)
|
||||
assert "XPRV_ENCRYPTION_KEY_FILE=/run/secrets/xprv_encryption_key" in compose
|
||||
assert "file: ./secrets/xprv_encryption.key" in compose
|
||||
assert not re.search(r"XPRV_ENCRYPTION_KEY=", compose)
|
||||
assert "data-permissions" in compose and "service_completed_successfully" in compose
|
||||
|
||||
|
||||
def test_the_example_env_points_at_a_file_not_a_key():
|
||||
example = _read(".env.example")
|
||||
assert re.search(r"^XPRV_ENCRYPTION_KEY_FILE=", example, re.M)
|
||||
assert not re.search(r"^XPRV_ENCRYPTION_KEY=", example, re.M)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ignore", [".gitignore", ".dockerignore"])
|
||||
def test_the_secrets_folder_is_kept_out_of_git_and_the_image(ignore):
|
||||
assert "secrets/" in _read(ignore).split()
|
||||
|
||||
|
||||
def _settings(**overrides):
|
||||
return Settings(_env_file=None, jwt_secret="x" * 32, **overrides)
|
||||
|
||||
|
||||
def test_the_key_is_read_from_its_file(tmp_path):
|
||||
key_file = tmp_path / "k"
|
||||
key_file.write_text("a-fernet-key\n")
|
||||
config = _settings(xprv_encryption_key_file=str(key_file))
|
||||
assert config.xprv_encryption_key == "a-fernet-key"
|
||||
validate_runtime_secrets(config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", [None, " \n"])
|
||||
def test_a_missing_or_empty_key_file_stops_the_server_naming_the_file(tmp_path, content):
|
||||
key_file = tmp_path / "k"
|
||||
if content is not None:
|
||||
key_file.write_text(content)
|
||||
with pytest.raises(ConfigError, match="XPRV_ENCRYPTION_KEY_FILE .*is missing or empty"):
|
||||
validate_runtime_secrets(_settings(xprv_encryption_key_file=str(key_file)))
|
||||
@@ -0,0 +1,312 @@
|
||||
"""B-82 (DEP-03, DEP-04, DEP-12, DEP-14, ELC-17, DAT-10, DAT-62, API-31): a deposit is
|
||||
credited at `deposit_confirmations` (default 6), a coinbase output at its maturity of 120, and
|
||||
only an output of a platform transaction at 1 — the deposit being the one input a user can
|
||||
double-spend with a private chain.
|
||||
|
||||
The real listener is driven here: a fake active connection, fake "other" servers for the
|
||||
quorum, real transactions so the coinbase check reads (and hash-checks) an actual raw tx.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from embit import script
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import AuditLog, PendingTransaction, PendingTransactionTxid, RoundConfig, User, UtxoEvent
|
||||
from app.deposits.service import (
|
||||
COINBASE_MATURITY,
|
||||
DepositPolicy,
|
||||
classify_new_deposits,
|
||||
find_new_credit_candidates,
|
||||
)
|
||||
from app.electrum.listener import ElectrumListener
|
||||
from app.wallet.balance import compute_pending_balance
|
||||
from tests.unit.test_electrum_listener import _ENDPOINTS, session_factory # noqa: F401 (fixture)
|
||||
|
||||
_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
|
||||
_OUT = script.Script.from_address(_ADDRESS)
|
||||
|
||||
|
||||
def _plain_tx(seed: int = 1) -> Transaction:
|
||||
return Transaction(
|
||||
vin=[TransactionInput(bytes([seed]) * 32, 0)], vout=[TransactionOutput(1_000_000_000, _OUT)]
|
||||
)
|
||||
|
||||
|
||||
def _coinbase_tx() -> Transaction:
|
||||
return Transaction(vin=[TransactionInput(bytes(32), 0xFFFFFFFF)], vout=[TransactionOutput(1_000_000_000, _OUT)])
|
||||
|
||||
|
||||
def _entry(tx: Transaction, height: int, value: int = 1_000_000_000) -> dict:
|
||||
return {"tx_hash": tx.txid().hex(), "tx_pos": 0, "height": height, "value": value}
|
||||
|
||||
|
||||
class _Active:
|
||||
"""The listener's active connection: the address' coins, and raw transactions by txid."""
|
||||
|
||||
def __init__(self, entries, raws: dict[str, str] | None = None):
|
||||
self.entries = entries
|
||||
self.raws = raws or {}
|
||||
self.asked_for: list[str] = []
|
||||
|
||||
async def listunspent(self, scripthash):
|
||||
return self.entries
|
||||
|
||||
async def get_transaction(self, txid, verbose=False):
|
||||
self.asked_for.append(txid)
|
||||
return self.raws[txid]
|
||||
|
||||
|
||||
class _Other:
|
||||
def __init__(self, entries):
|
||||
self._entries = entries
|
||||
|
||||
async def connect(self):
|
||||
pass
|
||||
|
||||
async def listunspent(self, scripthash):
|
||||
return self._entries
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
async def _user(session_factory, *, deposit_confirmations: int | None = None) -> int:
|
||||
async with session_factory() as session:
|
||||
if deposit_confirmations is not None:
|
||||
session.add(RoundConfig(fee_address="", deposit_confirmations=deposit_confirmations))
|
||||
user = User(username="alice", password_hash="x", derivation_index=0, address=_ADDRESS)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
return user.id
|
||||
|
||||
|
||||
def _listener(session_factory, active: _Active, others_entries, *, tip: int) -> ElectrumListener:
|
||||
others = {host: _Other(others_entries[host]) for host in ("second.example", "third.example")}
|
||||
listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, _ENDPOINTS)
|
||||
listener.client = active
|
||||
listener.tip_height = tip
|
||||
return listener
|
||||
|
||||
|
||||
async def _balance(session_factory, user_id: int) -> int:
|
||||
async with session_factory() as session:
|
||||
return (await session.get(User, user_id)).cached_balance_sats
|
||||
|
||||
|
||||
def _same(entries):
|
||||
return {"second.example": entries, "third.example": entries}
|
||||
|
||||
|
||||
# --- the thresholds (DEP-03) -----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_deposit_is_credited_exactly_at_six_confirmations(session_factory):
|
||||
user_id = await _user(session_factory)
|
||||
tx = _plain_tx()
|
||||
entry = _entry(tx, height=100) # confirmations = tip - 100 + 1
|
||||
active = _Active([entry], {tx.txid().hex(): tx.serialize().hex()})
|
||||
|
||||
at_five = _listener(session_factory, active, _same([entry]), tip=104) # 5 confirmations
|
||||
await at_five.refresh_user(user_id, "sh")
|
||||
assert await _balance(session_factory, user_id) == 0
|
||||
assert at_five.pending_deposit_sats(user_id) == 1_000_000_000 # waiting, and shown as such
|
||||
|
||||
at_six = _listener(session_factory, active, _same([entry]), tip=105) # 6 confirmations
|
||||
await at_six.refresh_user(user_id, "sh")
|
||||
assert await _balance(session_factory, user_id) == 1_000_000_000
|
||||
assert at_six.pending_deposit_sats(user_id) == 0
|
||||
|
||||
|
||||
async def test_deposit_confirmations_is_read_from_the_configuration(session_factory):
|
||||
user_id = await _user(session_factory, deposit_confirmations=2)
|
||||
tx = _plain_tx()
|
||||
entry = _entry(tx, height=100)
|
||||
active = _Active([entry], {tx.txid().hex(): tx.serialize().hex()})
|
||||
|
||||
await _listener(session_factory, active, _same([entry]), tip=100).refresh_user(user_id, "sh") # 1 conf
|
||||
assert await _balance(session_factory, user_id) == 0
|
||||
await _listener(session_factory, active, _same([entry]), tip=101).refresh_user(user_id, "sh") # 2 confs
|
||||
assert await _balance(session_factory, user_id) == 1_000_000_000
|
||||
|
||||
|
||||
async def test_an_immature_coinbase_is_not_credited_until_it_has_120_confirmations(session_factory):
|
||||
user_id = await _user(session_factory)
|
||||
tx = _coinbase_tx()
|
||||
entry = _entry(tx, height=100)
|
||||
txid = tx.txid().hex()
|
||||
active = _Active([entry], {txid: tx.serialize().hex()})
|
||||
|
||||
# plenty for a deposit (50 confirmations), not for a coinbase
|
||||
immature = _listener(session_factory, active, _same([entry]), tip=149)
|
||||
await immature.refresh_user(user_id, "sh")
|
||||
assert await _balance(session_factory, user_id) == 0
|
||||
assert immature.pending_deposit_sats(user_id) == 1_000_000_000
|
||||
assert txid in active.asked_for # it was recognised as a coinbase from the raw transaction
|
||||
|
||||
one_short = _listener(session_factory, active, _same([entry]), tip=100 + COINBASE_MATURITY - 2)
|
||||
await one_short.refresh_user(user_id, "sh")
|
||||
assert await _balance(session_factory, user_id) == 0
|
||||
|
||||
mature = _listener(session_factory, active, _same([entry]), tip=100 + COINBASE_MATURITY - 1)
|
||||
await mature.refresh_user(user_id, "sh") # exactly 120 confirmations
|
||||
assert await _balance(session_factory, user_id) == 1_000_000_000
|
||||
|
||||
|
||||
async def test_a_coinbase_is_not_mistaken_for_a_plain_deposit_when_the_server_cannot_say(session_factory):
|
||||
"""If the raw transaction cannot be fetched, or hashes to something else than the txid,
|
||||
the output waits — a server cannot make an immature coinbase look mature by lying."""
|
||||
user_id = await _user(session_factory)
|
||||
tx, other = _coinbase_tx(), _plain_tx(9)
|
||||
entry = _entry(tx, height=100)
|
||||
|
||||
missing = _Active([entry], {}) # KeyError on get_transaction
|
||||
await _listener(session_factory, missing, _same([entry]), tip=160).refresh_user(user_id, "sh")
|
||||
assert await _balance(session_factory, user_id) == 0
|
||||
|
||||
swapped = _Active([entry], {tx.txid().hex(): other.serialize().hex()}) # a different transaction
|
||||
listener = _listener(session_factory, swapped, _same([entry]), tip=160)
|
||||
assert await listener.is_coinbase(tx.txid().hex()) is None
|
||||
await listener.refresh_user(user_id, "sh")
|
||||
assert await _balance(session_factory, user_id) == 0
|
||||
|
||||
|
||||
async def test_an_output_of_a_platform_transaction_is_credited_at_one_confirmation(session_factory):
|
||||
"""The change of a bet, a withdrawal or a payout: the platform built the transaction, so
|
||||
nobody can double-spend its inputs — including a version replaced by an RBF bump."""
|
||||
user_id = await _user(session_factory)
|
||||
current, replaced = _plain_tx(2), _plain_tx(3)
|
||||
async with session_factory() as session:
|
||||
row = PendingTransaction(
|
||||
kind="bet", user_id=user_id, current_txid=current.txid().hex(), fee_rate_sat_vb=1,
|
||||
raw_tx_hex=current.serialize().hex(), status="pending",
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
session.add(PendingTransactionTxid(pending_transaction_id=row.id, txid=replaced.txid().hex(), raw_hex="00"))
|
||||
await session.commit()
|
||||
|
||||
entries = [_entry(current, height=100), _entry(replaced, height=100)]
|
||||
active = _Active(entries, {})
|
||||
await _listener(session_factory, active, _same(entries), tip=100).refresh_user(user_id, "sh") # 1 conf
|
||||
assert active.asked_for == [] # no coinbase lookup either: a platform output is not a deposit
|
||||
assert await _balance(session_factory, user_id) == 2_000_000_000
|
||||
|
||||
|
||||
async def test_dust_is_never_a_waiting_deposit(session_factory):
|
||||
user_id = await _user(session_factory)
|
||||
tx = _plain_tx()
|
||||
entry = _entry(tx, height=100, value=293)
|
||||
listener = _listener(session_factory, _Active([entry]), _same([entry]), tip=100)
|
||||
await listener.refresh_user(user_id, "sh")
|
||||
assert listener.pending_deposit_sats(user_id) == 0 and await _balance(session_factory, user_id) == 0
|
||||
|
||||
|
||||
# --- the quorum counts confirmations on our tip (ELC-17, DEP-04) -------------------------------------
|
||||
|
||||
|
||||
async def test_the_other_servers_must_place_the_coin_deep_enough_too(session_factory):
|
||||
"""Our active server says 6 confirmations; two others put the same coin one block higher
|
||||
(5 confirmations on our tip): the credit is withheld, not lost."""
|
||||
user_id = await _user(session_factory)
|
||||
tx = _plain_tx()
|
||||
raws = {tx.txid().hex(): tx.serialize().hex()}
|
||||
ours = _entry(tx, height=100)
|
||||
shallower = _entry(tx, height=101)
|
||||
listener = _listener(session_factory, _Active([ours], raws), _same([shallower]), tip=105)
|
||||
await listener.refresh_user(user_id, "sh")
|
||||
assert await _balance(session_factory, user_id) == 0
|
||||
|
||||
agreeing = _listener(session_factory, _Active([ours], raws), _same([ours]), tip=105)
|
||||
await agreeing.refresh_user(user_id, "sh")
|
||||
assert await _balance(session_factory, user_id) == 1_000_000_000
|
||||
|
||||
|
||||
async def test_corroborate_utxo_credit_compares_heights_on_our_tip(session_factory):
|
||||
listener = _listener(session_factory, _Active([]), _same([{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 5}]), tip=105)
|
||||
assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5, required_confirmations=6) is True
|
||||
assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5, required_confirmations=7) is False
|
||||
# without a requirement only "confirmed" matters, as before
|
||||
assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5) is True
|
||||
|
||||
|
||||
# --- classification and the displayed balance (DEP-12 (b), DEP-14) -----------------------------------
|
||||
|
||||
|
||||
async def test_classify_new_deposits_splits_credit_from_waiting():
|
||||
async def never(_txid):
|
||||
raise AssertionError("no coinbase lookup expected")
|
||||
|
||||
entries = [
|
||||
{"tx_hash": "01" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # platform, 1 conf
|
||||
{"tx_hash": "02" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # deposit, 1 conf: waits
|
||||
{"tx_hash": "03" * 32, "tx_pos": 0, "height": 96, "value": 1_000}, # deposit, 5 confs: waits
|
||||
{"tx_hash": "04" * 32, "tx_pos": 0, "height": 500, "value": 1_000}, # not on our chain yet: waits
|
||||
]
|
||||
policy = DepositPolicy(deposit_confirmations=6, platform_txids={"01" * 32})
|
||||
credit, waiting = await classify_new_deposits(entries, 100, policy, never)
|
||||
assert [(e["tx_hash"], required) for e, required in credit] == [("01" * 32, 1)]
|
||||
assert [e["tx_hash"] for e in waiting] == ["02" * 32, "03" * 32, "04" * 32]
|
||||
|
||||
|
||||
async def test_the_displayed_balance_includes_waiting_deposits_but_the_spendable_one_does_not(session_factory):
|
||||
user_id = await _user(session_factory)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
assert await compute_pending_balance(session, user) == (0, False)
|
||||
shown, has_pending = await compute_pending_balance(session, user, pending_deposit_sats=7_000)
|
||||
assert (shown, has_pending) == (7_000, True)
|
||||
assert user.cached_balance_sats == 0 # DEP-13: never spendable until credited
|
||||
|
||||
|
||||
async def test_a_waiting_deposit_that_vanishes_stops_being_shown(session_factory):
|
||||
"""DEP-14: the set is replaced at every refresh, so a deposit dropped by a reorganisation
|
||||
is no longer shown."""
|
||||
user_id = await _user(session_factory)
|
||||
tx = _plain_tx()
|
||||
entry = _entry(tx, height=100)
|
||||
listener = _listener(session_factory, _Active([entry]), _same([entry]), tip=100)
|
||||
await listener.refresh_user(user_id, "sh")
|
||||
assert listener.pending_deposit_sats(user_id) == 1_000_000_000
|
||||
listener.client = _Active([]) # gone from the chain
|
||||
await listener.refresh_user(user_id, "sh")
|
||||
assert listener.pending_deposit_sats(user_id) == 0
|
||||
|
||||
|
||||
async def test_new_blocks_refresh_the_users_that_have_a_waiting_deposit(session_factory, monkeypatch):
|
||||
"""No scripthash notification announces that a deposit matured (its history did not
|
||||
change), so each block refreshes only the users with a waiting deposit, one task at a time."""
|
||||
import asyncio
|
||||
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
listener.client = object()
|
||||
listener._scripthash_to_user = {"sh1": 1, "sh2": 2}
|
||||
listener._pending_deposits = {1: [{"value": 5}]}
|
||||
started: list[int] = []
|
||||
release = asyncio.Event()
|
||||
|
||||
async def fake_refresh(user_id, scripthash):
|
||||
started.append(user_id)
|
||||
await release.wait()
|
||||
|
||||
monkeypatch.setattr(listener, "refresh_user", fake_refresh)
|
||||
listener._refresh_waiting_deposits()
|
||||
listener._refresh_waiting_deposits() # a second block while the first refresh is still running
|
||||
await asyncio.sleep(0)
|
||||
assert started == [1] # user 2 has nothing waiting; user 1 is not refreshed twice at once
|
||||
release.set()
|
||||
await asyncio.sleep(0.01)
|
||||
listener._refresh_waiting_deposits()
|
||||
await asyncio.sleep(0)
|
||||
assert started == [1, 1]
|
||||
|
||||
|
||||
async def test_find_new_credit_candidates_still_lists_every_new_confirmed_output(session_factory):
|
||||
"""The read half is unchanged: thresholds are applied by classify_new_deposits, between
|
||||
the read and the write (no session open across the coinbase lookup, B-18)."""
|
||||
user_id = await _user(session_factory)
|
||||
entries = [{"tx_hash": "05" * 32, "tx_pos": 0, "height": 100, "value": 1_000}]
|
||||
async with session_factory() as session:
|
||||
assert await find_new_credit_candidates(session, user_id, entries) == entries
|
||||
assert (await session.scalars(select(UtxoEvent))).all() == []
|
||||
assert (await session.scalars(select(AuditLog))).all() == []
|
||||
@@ -325,12 +325,30 @@ class _PinWriter(FakeWriter):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _answer_features(reader, writer, result=None):
|
||||
"""Make `writer` answer `server.features` the moment it is asked: a reply fed up front
|
||||
would be read before its request is registered, and dropped."""
|
||||
from app.wallet.plm_network import GENESIS_HASH
|
||||
|
||||
original = writer.write
|
||||
|
||||
def write(data):
|
||||
original(data)
|
||||
sent = json.loads(data.decode())
|
||||
if sent["method"] == "server.features":
|
||||
reply = {"genesis_hash": GENESIS_HASH} if result is None else result
|
||||
reader.feed_data((json.dumps({"id": sent["id"], "result": reply}) + "\n").encode())
|
||||
|
||||
writer.write = write
|
||||
|
||||
|
||||
async def _connect_with_pin(monkeypatch, pin, der):
|
||||
import hashlib # noqa: F401
|
||||
|
||||
writer = _PinWriter(der)
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(b'{"id": 1, "result": ["fake", "1.4"]}\n')
|
||||
_answer_features(reader, writer)
|
||||
|
||||
async def fake_open_connection(host, port, **kwargs):
|
||||
return reader, writer
|
||||
@@ -413,3 +431,22 @@ async def test_only_a_server_error_object_is_an_explicit_refusal():
|
||||
silent._read_task.cancel()
|
||||
finally:
|
||||
client_module._REQUEST_TIMEOUT_SECONDS = original
|
||||
|
||||
|
||||
async def test_a_server_on_another_chain_is_refused_at_the_handshake(monkeypatch):
|
||||
"""ELC-30: Bitcoin's genesis (or no genesis at all) fails like an unreachable server."""
|
||||
import pytest
|
||||
|
||||
bitcoin = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26e"
|
||||
for result in ({"genesis_hash": bitcoin}, {}):
|
||||
writer = FakeWriter()
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(b'{"id": 1, "result": ["fake", "1.4"]}\n')
|
||||
_answer_features(reader, writer, result)
|
||||
|
||||
async def fake_open_connection(host, port, reader=reader, writer=writer, **kwargs):
|
||||
return reader, writer
|
||||
|
||||
monkeypatch.setattr(asyncio, "open_connection", fake_open_connection)
|
||||
with pytest.raises(ElectrumError, match="not on the PLM chain"):
|
||||
await ElectrumClient("localhost", 1).connect()
|
||||
@@ -405,6 +405,7 @@ async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(sessio
|
||||
}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
|
||||
listener.client = _ActiveClient(_UNRELATED_ENTRY) # our own connection no longer sees the UTXO either
|
||||
|
||||
await listener.refresh_user(user_id, "scripthash")
|
||||
@@ -431,6 +432,7 @@ async def test_refresh_user_does_not_mark_when_corroboration_fails(session_facto
|
||||
{"first.example": [], "second.example": still_there, "third.example": still_there}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
|
||||
listener.client = _ActiveClient(_UNRELATED_ENTRY)
|
||||
|
||||
await listener.refresh_user(user_id, "scripthash")
|
||||
@@ -719,6 +721,7 @@ async def test_refresh_user_does_not_credit_a_utxo_only_our_own_server_reports(s
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
# Our own connection reports the tracked UTXO plus a phantom one nobody else has.
|
||||
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
|
||||
listener.client = _ActiveClient(
|
||||
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
|
||||
)
|
||||
@@ -742,6 +745,7 @@ async def test_refresh_user_credits_once_the_others_corroborate(session_factory)
|
||||
{"first.example": [], "second.example": _PHANTOM, "third.example": _PHANTOM}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
|
||||
listener.client = _ActiveClient(
|
||||
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
|
||||
)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""B-89 (SEC-49, SEC-50, SEC-09): the master key must be the one that produced the database."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from embit import script
|
||||
|
||||
from app.config import ConfigError, settings
|
||||
from app.wallet import hd
|
||||
from app.wallet.keystore import encrypt_round_seed
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.verify import load_sample, sample_problems, verify_master_key
|
||||
|
||||
|
||||
def _database(tmp_path, monkeypatch, users=(), rounds=()):
|
||||
"""A file DB with just the columns the check reads."""
|
||||
path = tmp_path / "check.db"
|
||||
db = sqlite3.connect(path)
|
||||
db.execute("CREATE TABLE users (derivation_index INTEGER, address TEXT)")
|
||||
db.execute(
|
||||
"CREATE TABLE rounds (id INTEGER, pool_address TEXT, server_seed_enc TEXT, server_seed TEXT, server_seed_hash TEXT)"
|
||||
)
|
||||
db.executemany("INSERT INTO users VALUES (?, ?)", users)
|
||||
db.executemany("INSERT INTO rounds VALUES (?, ?, ?, ?, ?)", rounds)
|
||||
db.commit()
|
||||
db.close()
|
||||
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{path}")
|
||||
|
||||
|
||||
def _addr(path):
|
||||
return script.p2wpkh(hd._load_account_key().derive(path).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
|
||||
def test_a_fresh_instance_only_needs_the_key_to_decrypt(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/absent.db")
|
||||
verify_master_key()
|
||||
|
||||
|
||||
def test_the_matching_key_passes(tmp_path, monkeypatch):
|
||||
_database(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
users=[(i, _addr(f"0/{i}")) for i in range(25)],
|
||||
rounds=[(1, _addr("1/0"), None, None, None), (2, _addr("2/2"), None, None, None)],
|
||||
)
|
||||
verify_master_key()
|
||||
|
||||
|
||||
def test_another_master_key_is_refused_without_printing_it(tmp_path, monkeypatch):
|
||||
"""SEC-49: the rows came from a different xprv (the two-keys incident)."""
|
||||
_database(tmp_path, monkeypatch, users=[(0, _addr("0/0")), (1, _addr("0/1"))])
|
||||
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "other.xprv.enc"))
|
||||
hd._account_key = None
|
||||
hd.generate_master_key()
|
||||
with pytest.raises(ConfigError, match="derivation index 0") as excinfo:
|
||||
verify_master_key()
|
||||
assert "xprv" not in str(excinfo.value).lower().replace("master key", "")
|
||||
|
||||
|
||||
def test_only_the_edges_of_the_user_table_are_sampled(tmp_path, monkeypatch):
|
||||
users = [(i, _addr(f"0/{i}")) for i in range(40)]
|
||||
_database(tmp_path, monkeypatch, users=users)
|
||||
assert [i for i, _ in load_sample().users] == list(range(10)) + list(range(30, 40))
|
||||
|
||||
|
||||
def test_a_round_pool_address_must_be_its_own_or_the_reserve(tmp_path, monkeypatch):
|
||||
_database(tmp_path, monkeypatch, rounds=[(5, _addr("2/9"), None, None, None)])
|
||||
with pytest.raises(ConfigError, match="round 5"):
|
||||
verify_master_key()
|
||||
|
||||
|
||||
def test_a_wrong_encryption_key_is_refused_at_startup(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
|
||||
hd._account_key = None
|
||||
with pytest.raises(ConfigError, match="cannot be loaded"):
|
||||
verify_master_key()
|
||||
|
||||
|
||||
def test_an_unrevealed_secret_must_decrypt_and_match_its_hash(tmp_path, monkeypatch):
|
||||
secret = "ab" * 32
|
||||
good = (1, "", encrypt_round_seed(secret), None, hashlib.sha256(bytes.fromhex(secret)).hexdigest())
|
||||
bad_hash = (2, "", encrypt_round_seed(secret), None, "00" * 32)
|
||||
revealed = (3, "", "not-a-token", secret, "00" * 32) # already revealed: not checked
|
||||
_database(tmp_path, monkeypatch, rounds=[good, bad_hash, revealed])
|
||||
problems = sample_problems(hd._load_account_key(), load_sample(), settings.xprv_encryption_key)
|
||||
assert problems == ["secret of round 2 does not match its published hash"]
|
||||
|
||||
|
||||
def test_the_key_file_is_written_0600(tmp_path, monkeypatch):
|
||||
path = tmp_path / "k.xprv.enc"
|
||||
monkeypatch.setattr(settings, "master_key_path", str(path))
|
||||
os.close(os.open(path, os.O_CREAT | os.O_WRONLY, 0o644)) # a leftover world-readable file
|
||||
hd.generate_master_key(overwrite=True)
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_the_import_script_refuses_an_xprv_that_does_not_match_the_database(tmp_path, monkeypatch):
|
||||
"""SEC-05 / SEC-50: a Bitcoin-looking xprv passes the version check, the addresses do not."""
|
||||
from embit.bip32 import HDKey
|
||||
|
||||
from app.wallet.verify import account_key
|
||||
|
||||
_database(tmp_path, monkeypatch, users=[(0, _addr("0/0"))])
|
||||
other = HDKey.from_seed(b"\x09" * 32, version=PLM_MAINNET["xprv"]).to_base58(version=PLM_MAINNET["xprv"])
|
||||
assert sample_problems(account_key(other), load_sample())
|
||||
mine = hd._load_account_key()
|
||||
assert sample_problems(mine, load_sample()) == []
|
||||
@@ -203,3 +203,13 @@ def test_the_bump_reserve_column_exists_at_head_and_is_nullable(migrated_db): #
|
||||
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
|
||||
assert "bump_reserve_sats" in rounds and rounds["bump_reserve_sats"][3] == 0
|
||||
assert _index_sql(migrated_db, "ix_rounds_single_active") is not None
|
||||
|
||||
|
||||
def test_deposit_confirmations_and_payout_blocked_columns_exist_at_head(migrated_db): # B-82 (DAT-62, DAT-68)
|
||||
with sqlite3.connect(migrated_db) as conn:
|
||||
config = {r[1]: r for r in conn.execute("PRAGMA table_info(round_config)")}
|
||||
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
|
||||
assert config["deposit_confirmations"][3] == 1 and config["deposit_confirmations"][4] in ("6", "'6'")
|
||||
assert "payout_blocked_at" in rounds and rounds["payout_blocked_at"][3] == 0
|
||||
assert _index_sql(migrated_db, "ix_rounds_single_active") is not None
|
||||
assert _index_sql(migrated_db, "ix_users_username_lower") is not None
|
||||
@@ -0,0 +1,195 @@
|
||||
"""B-92 (API-37, API-46, API-47, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29): readiness, Electrum
|
||||
status, audit browsing, the registration row and the logs."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from tests.unit.test_abuse_limits import ADMIN, _register, client # noqa: F401 (fixture + helper)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def listener(client):
|
||||
from app.main import app
|
||||
|
||||
return app.state.electrum_listener
|
||||
|
||||
|
||||
# --- /health/ready -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _ready_app(listener, *, header_age, heartbeat_age, connected=True):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.main import app
|
||||
|
||||
listener.client = object() if connected else None
|
||||
listener.last_header_age_seconds = lambda: header_age
|
||||
app.state.scheduler = SimpleNamespace(heartbeat_age_seconds=lambda: heartbeat_age)
|
||||
|
||||
|
||||
async def test_ready_only_when_connected_fresh_and_alive(client, listener):
|
||||
_ready_app(listener, header_age=100, heartbeat_age=5)
|
||||
ok = await client.get("/health/ready")
|
||||
assert ok.status_code == 200 and ok.json() == {
|
||||
"status": "ready",
|
||||
"checks": {"electrum_connected": True, "last_header_age_seconds": 100, "scheduler_heartbeat_age_seconds": 5},
|
||||
}
|
||||
for kwargs in (
|
||||
{"header_age": 100, "heartbeat_age": 5, "connected": False},
|
||||
{"header_age": 1800, "heartbeat_age": 5},
|
||||
{"header_age": None, "heartbeat_age": 5},
|
||||
{"header_age": 100, "heartbeat_age": 30},
|
||||
{"header_age": 100, "heartbeat_age": None},
|
||||
):
|
||||
_ready_app(listener, **kwargs)
|
||||
bad = await client.get("/health/ready")
|
||||
assert bad.status_code == 503 and bad.json()["status"] == "not_ready", kwargs
|
||||
|
||||
|
||||
async def test_a_360_second_gap_between_blocks_is_not_unreadiness(client, listener):
|
||||
"""With 120 s blocks, 360 s of silence happens ~5% of the time by chance."""
|
||||
_ready_app(listener, header_age=400, heartbeat_age=5)
|
||||
assert (await client.get("/health/ready")).status_code == 200
|
||||
|
||||
|
||||
def test_the_scheduler_beats_on_every_tick(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
from app.rounds.scheduler import RoundScheduler
|
||||
|
||||
scheduler = RoundScheduler(None, None)
|
||||
assert scheduler.heartbeat_age_seconds() is None
|
||||
|
||||
async def one_tick():
|
||||
async def fake_tick():
|
||||
raise asyncio.CancelledError
|
||||
|
||||
scheduler._tick = fake_tick
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await scheduler.run()
|
||||
|
||||
asyncio.run(one_tick())
|
||||
assert scheduler.heartbeat_age_seconds() == 0
|
||||
|
||||
|
||||
# --- /admin/electrum ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_electrum_status_endpoint(client, listener):
|
||||
from app.electrum.client import ElectrumEndpoint
|
||||
|
||||
listener._endpoints = [ElectrumEndpoint("a.example", 50002, True), ElectrumEndpoint("b.example", 50002, True)]
|
||||
listener.client = object()
|
||||
listener.tip_height = 538900
|
||||
listener.last_rotation = {"reason": "stalled_tip", "from": "x:1", "at": "2026-10-18T00:00:00+00:00"}
|
||||
body = (await client.get("/admin/electrum", headers=ADMIN)).json()
|
||||
assert body["connected"] is True and body["endpoint"] == str(listener.current_endpoint)
|
||||
assert body["tip_height"] == 538900 and body["fallback_count"] == 1 and body["quorum_enforced"] is True
|
||||
assert body["last_rotation"]["reason"] == "stalled_tip"
|
||||
listener.client = None
|
||||
assert (await client.get("/admin/electrum", headers=ADMIN)).json()["endpoint"] is None
|
||||
assert (await client.get("/admin/electrum")).status_code == 403
|
||||
|
||||
|
||||
async def test_the_listener_records_why_it_moved_to_another_server():
|
||||
from app.electrum.listener import ServerStalledError, _rotation_reason
|
||||
from app.rounds.draw import HeaderValidationError
|
||||
|
||||
assert _rotation_reason(ServerStalledError("x")) == "stalled_tip"
|
||||
assert _rotation_reason(HeaderValidationError("x")) == "invalid_header"
|
||||
assert _rotation_reason(RuntimeError("x")) == "error"
|
||||
|
||||
|
||||
# --- /admin/audit-log ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def _seed_audit(rows):
|
||||
from app.db import base as db_base
|
||||
from app.db.models import AuditLog
|
||||
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
for event, user_id, round_id, at in rows:
|
||||
session.add(AuditLog(event_type=event, payload_json="{}", user_id=user_id, round_id=round_id, created_at=at))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_the_audit_log_is_filtered_and_paged(client):
|
||||
now = datetime(2026, 10, 1, 12, tzinfo=timezone.utc)
|
||||
await _seed_audit(
|
||||
[(f"event_{i % 3}", i % 2, 10 + i % 2, now + timedelta(hours=i)) for i in range(12)]
|
||||
)
|
||||
|
||||
async def ids(**params):
|
||||
r = await client.get("/admin/audit-log", headers=ADMIN, params=params)
|
||||
assert r.status_code == 200, r.text
|
||||
return [e["id"] for e in r.json()]
|
||||
|
||||
everything = await ids(limit=500)
|
||||
assert everything == sorted(everything, reverse=True) and len(everything) == 12
|
||||
|
||||
first = await ids(limit=5)
|
||||
assert first == everything[:5]
|
||||
assert await ids(limit=5, before_id=first[-1]) == everything[5:10]
|
||||
|
||||
r = await client.get("/admin/audit-log", headers=ADMIN, params=[("event_type", "event_0"), ("event_type", "event_1")])
|
||||
assert {e["event_type"] for e in r.json()} == {"event_0", "event_1"}
|
||||
assert await ids(event_type="nope") == []
|
||||
assert len(await ids(round_id=10, user_id=0)) == 6
|
||||
window = await ids(**{"from": "2026-10-01T14:00:00+00:00", "to": "2026-10-01T17:00:00+00:00"})
|
||||
assert len(window) == 3 # 14:00, 15:00, 16:00 — `to` is exclusive
|
||||
|
||||
for bad in ({"from": "2026-10-01T14:00:00"}, {"before_id": "x"}, {"to": "yesterday"}):
|
||||
r = await client.get("/admin/audit-log", headers=ADMIN, params=bad)
|
||||
assert r.status_code == 422 and r.json()["detail"]["code"] == "invalid_request"
|
||||
|
||||
|
||||
# --- user_registered -------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_registration_writes_user_registered_in_its_transaction(client):
|
||||
from app.db import base as db_base
|
||||
from app.db.models import AuditLog, User
|
||||
|
||||
await _register(client, username="carol")
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
user = (await session.scalars(select(User))).one()
|
||||
row = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "user_registered"))).one()
|
||||
assert row.user_id == user.id and row.round_id is None
|
||||
assert json.loads(row.payload_json) == {"derivation_index": 0, "address": user.address}
|
||||
assert "carol" not in row.payload_json
|
||||
|
||||
|
||||
# --- logs and proxy -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_app_logs_also_go_to_stdout(monkeypatch, tmp_path):
|
||||
from app import logging_config
|
||||
|
||||
root = logging.getLogger()
|
||||
before = list(root.handlers)
|
||||
monkeypatch.setattr(logging_config, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(logging_config, "LOG_FILE", tmp_path / "app.log")
|
||||
try:
|
||||
logging_config.setup_logging()
|
||||
added = [h for h in root.handlers if h not in before]
|
||||
assert any(isinstance(h, logging.StreamHandler) and h.stream is sys.stdout for h in added)
|
||||
finally:
|
||||
for h in [h for h in root.handlers if h not in before]:
|
||||
root.removeHandler(h)
|
||||
h.close()
|
||||
|
||||
|
||||
def test_uvicorn_trusts_only_the_proxy_and_the_container_is_checked():
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
dockerfile, compose = (root / "Dockerfile").read_text(), (root / "docker-compose.yml").read_text()
|
||||
assert "--proxy-headers" in dockerfile and "--forwarded-allow-ips" in dockerfile
|
||||
assert "FORWARDED_ALLOW_IPS=172.28.0.10" in compose and "ipv4_address: 172.28.0.10" in compose
|
||||
assert "subnet: 172.28.0.0/24" in compose and "/health/ready" in compose
|
||||
@@ -0,0 +1,319 @@
|
||||
"""B-82 (RND-22, RND-39, DAT-11, DAT-67, DAT-68, ELC-27, AUD-44, AUD-45): the coins of a round
|
||||
are verified with the quorum before a payout is built.
|
||||
|
||||
A user can bet and then — with a private chain, or just by being unlucky in a reorganisation —
|
||||
get the deposit that funded the bet erased. The bet leaves the chain but its participant stayed
|
||||
`confirmed`, so the payout looked for coins that no longer existed and failed "insufficient pool
|
||||
UTXOs" for ever, with every other player's money stuck. Now the bets that are gone leave the
|
||||
round, the 70/30 split is computed on what is really there, and a winner whose own bet is gone
|
||||
holds the payout and pauses the lottery.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import MIN_QUORUM_RESPONSES
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User
|
||||
from app.electrum.client import ElectrumEndpoint
|
||||
from app.electrum.listener import ElectrumListener
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.scheduler import RoundScheduler
|
||||
from app.rounds.service import payout_config_problem, winner_share
|
||||
from app.wallet.hd import derive_pool_address
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import (
|
||||
DUST_LIMIT_SATS,
|
||||
MAX_PARTICIPANTS_PER_ROUND,
|
||||
MAX_TX_INPUTS,
|
||||
estimate_vsize,
|
||||
payout_bump_reserve,
|
||||
)
|
||||
from tests.unit.test_scheduler import ( # noqa: F401 (fixture and helpers)
|
||||
_FEE_ADDRESS,
|
||||
_WINNER_ADDRESS,
|
||||
FakePayoutClient,
|
||||
FakePayoutListener,
|
||||
_payout_bet_txid as _bet_txid,
|
||||
_seed_paying_out_round,
|
||||
payout_session_factory,
|
||||
)
|
||||
|
||||
_BET = 999_000_000 # the net amount of a 10 PLM bet, roughly
|
||||
|
||||
|
||||
def _scheduler(session_factory, listener=None):
|
||||
listener = listener or FakePayoutListener(FakePayoutClient([]))
|
||||
return RoundScheduler(session_factory, listener), listener
|
||||
|
||||
|
||||
async def _state(session_factory):
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, 1)
|
||||
participants = {
|
||||
p.bet_txid: p.status for p in (await session.scalars(select(RoundParticipant))).all()
|
||||
}
|
||||
events = [(e.event_type, e.payload_json) for e in (await session.scalars(select(AuditLog).order_by(AuditLog.id))).all()]
|
||||
pending = (await session.scalars(select(PendingTransaction))).all()
|
||||
config = await session.scalar(select(RoundConfig))
|
||||
return round_, participants, events, pending, config
|
||||
|
||||
|
||||
def _names(events):
|
||||
return [name for name, _ in events]
|
||||
|
||||
|
||||
# --- everything is there -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else(payout_session_factory):
|
||||
"""RND-22: other coins at the pool address are not touched — the active connection is not
|
||||
even asked for the address' coins, and the inputs are the participants' `bet_txid:bet_vout`."""
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET, _BET])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
stray = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 10, "value": 5_000_000_000}]
|
||||
listener.client = FakePayoutClient(stray)
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
assert listener.client.broadcasted
|
||||
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
|
||||
assert sorted((v.txid.hex(), v.vout) for v in tx.vin) == sorted((_bet_txid(i), 0) for i in range(3))
|
||||
assert listener.verified_scripthashes == [address_to_scripthash(derive_pool_address(1))]
|
||||
assert [(t, v, a) for t, v, a in listener.verified_outpoints[0]] == [(_bet_txid(i), 0, _BET) for i in range(3)]
|
||||
round_, participants, events, pending, _ = await _state(payout_session_factory)
|
||||
assert set(participants.values()) == {"confirmed"} and round_.pool_amount_sats == 3 * _BET
|
||||
assert "participant_reorged_out" not in _names(events) and "payout_blocked" not in _names(events)
|
||||
|
||||
|
||||
# --- a bet that is gone (RND-39, DAT-67, AUD-45) ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there(payout_session_factory):
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET + 1, _BET + 2, _BET + 3])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
gone = (_bet_txid(2), 0) # a loser's bet, double-spent away
|
||||
listener.pool_absent = {gone}
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
round_, participants, events, pending, _ = await _state(payout_session_factory)
|
||||
remaining = _BET + (_BET + 1) + (_BET + 3)
|
||||
assert participants[gone[0]] == "reorged_out"
|
||||
assert [s for t, s in participants.items() if t != gone[0]] == ["confirmed"] * 3
|
||||
assert round_.pool_amount_sats == remaining # DAT-67: rewritten, and only ever reduced
|
||||
row = next(p for n, p in events if n == "participant_reorged_out")
|
||||
assert f'"txid": "{gone[0]}"' in row and f'"pool_amount_sats": {remaining}' in row
|
||||
# the transaction spends the three bets that are there, and splits exactly their sum
|
||||
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
|
||||
assert gone not in {(v.txid.hex(), v.vout) for v in tx.vin}
|
||||
assert len(tx.vin) == 3
|
||||
fee = estimate_vsize(3, 3)
|
||||
reserve = payout_bump_reserve(3)
|
||||
assert tx.vout[0].value == winner_share(remaining) - fee == round_.winner_amount_sats
|
||||
assert tx.vout[1].value == remaining - winner_share(remaining) - reserve == round_.fee_amount_sats
|
||||
assert tx.vout[2].value == reserve == round_.bump_reserve_sats
|
||||
assert sum(o.value for o in tx.vout) + fee == remaining
|
||||
|
||||
|
||||
async def test_several_bets_gone_at_once_each_get_a_row_in_one_transaction(payout_session_factory):
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[_BET] * 5)
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
listener.pool_absent = {(_bet_txid(1), 0), (_bet_txid(4), 0)}
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
round_, participants, events, _, _ = await _state(payout_session_factory)
|
||||
assert sorted(t for t, s in participants.items() if s == "reorged_out") == sorted([_bet_txid(1), _bet_txid(4)])
|
||||
assert _names(events).count("participant_reorged_out") == 2
|
||||
assert round_.pool_amount_sats == 3 * _BET
|
||||
|
||||
|
||||
# --- the winner's own bet is gone (DAT-68, AUD-44) -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery(payout_session_factory):
|
||||
winner_id = await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET, _BET])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
listener.pool_absent = {(_bet_txid(0), 0), (_bet_txid(2), 0)} # the winner's, and one more
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
round_, participants, events, pending, config = await _state(payout_session_factory)
|
||||
assert listener.client.broadcasted == [] and pending == [] # nothing built, nothing sent
|
||||
assert round_.payout_blocked_at is not None and round_.status == "paying_out"
|
||||
assert (config.paused, config.paused_reason) == (True, "payout_blocked")
|
||||
names = _names(events)
|
||||
assert "payout_blocked" in names and "payout_failed" not in names # not a failure, not a retry clock
|
||||
assert ("lottery_paused", '{"reason": "payout_blocked"}') in events
|
||||
# the other missing bet is excluded now; the winner's stays `confirmed` so that it is
|
||||
# verified again after the resume (OPS-30)
|
||||
assert participants[_bet_txid(2)] == "reorged_out" and participants[_bet_txid(0)] == "confirmed"
|
||||
blocked = next(p for n, p in events if n == "payout_blocked")
|
||||
assert f'"txid": "{_bet_txid(0)}"' in blocked
|
||||
|
||||
|
||||
async def test_a_held_payout_is_not_attempted_again_until_the_lottery_is_resumed(payout_session_factory):
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
listener.pool_absent = {(_bet_txid(0), 0)}
|
||||
await scheduler._trigger_payout(1)
|
||||
asked = len(listener.verified_scripthashes)
|
||||
|
||||
for _ in range(3): # three scheduler steps over the held round
|
||||
await scheduler._retry_payout_if_due(1)
|
||||
await scheduler._trigger_payout(1)
|
||||
assert len(listener.verified_scripthashes) == asked # no verification, no build, no failure rows
|
||||
_, _, events, pending, _ = await _state(payout_session_factory)
|
||||
assert pending == [] and "payout_failed" not in _names(events)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("still_gone", [True, False])
|
||||
async def test_after_the_resume_the_verification_repeats(payout_session_factory, still_gone):
|
||||
"""DAT-68: the operator's resume clears the mark; the next attempt verifies again. If the
|
||||
bet is still gone the payout is held and the lottery paused again (a new row); if it is
|
||||
back, the payout goes out."""
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
listener.pool_absent = {(_bet_txid(0), 0)}
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
async with payout_session_factory() as session: # what POST /admin/resume does
|
||||
round_ = await session.get(Round, 1)
|
||||
round_.payout_blocked_at = None
|
||||
(await session.scalar(select(RoundConfig))).paused = False
|
||||
await session.commit()
|
||||
|
||||
if not still_gone:
|
||||
listener.pool_absent = set()
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
round_, _, events, pending, config = await _state(payout_session_factory)
|
||||
if still_gone:
|
||||
assert _names(events).count("payout_blocked") == 2
|
||||
assert round_.payout_blocked_at is not None and config.paused is True and pending == []
|
||||
else:
|
||||
assert round_.payout_blocked_at is None and len(pending) == 1 and "payout_sent" in _names(events)
|
||||
|
||||
|
||||
# --- no quorum (RND-25, ELC-27) ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_without_a_quorum_nothing_changes_and_the_attempt_is_a_payout_failed(payout_session_factory):
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
listener.pool_unavailable = True
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
round_, participants, events, pending, config = await _state(payout_session_factory)
|
||||
assert set(participants.values()) == {"confirmed"} and round_.pool_amount_sats == 2 * _BET
|
||||
assert pending == [] and listener.client.broadcasted == []
|
||||
assert [p for n, p in events if n == "payout_failed"] and "pool verification unavailable" in dict(events)["payout_failed"]
|
||||
assert config.paused is False and round_.payout_blocked_at is None
|
||||
|
||||
|
||||
# --- the extremes of RND-40, through the scheduler ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_round_of_one_participant_at_the_smallest_viable_bet_is_paid(payout_session_factory):
|
||||
"""The smallest net bet RND-40 allows (the config's own bound) still yields a payout whose
|
||||
three outputs are all at least dust."""
|
||||
bet = next(b for b in range(10_000, 200_000) if payout_config_problem(b + estimate_vsize(MAX_TX_INPUTS, 2), 1) is None)
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[bet])
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
|
||||
assert len(tx.vin) == 1 and len(tx.vout) == 3
|
||||
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
|
||||
assert sum(o.value for o in tx.vout) + estimate_vsize(1, 3) == bet
|
||||
|
||||
|
||||
async def test_a_full_round_is_paid_with_the_reserve_fee_and_dust_all_covered(payout_session_factory):
|
||||
bet = 1_000_000_000 - estimate_vsize(MAX_TX_INPUTS, 2)
|
||||
await _seed_paying_out_round(payout_session_factory, bets=[bet] * MAX_PARTICIPANTS_PER_ROUND)
|
||||
scheduler, listener = _scheduler(payout_session_factory)
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
|
||||
assert len(tx.vin) == MAX_PARTICIPANTS_PER_ROUND and len(tx.vout) == 3
|
||||
assert tx.vout[2].value == payout_bump_reserve(MAX_PARTICIPANTS_PER_ROUND)
|
||||
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
|
||||
assert sum(o.value for o in tx.vout) + estimate_vsize(MAX_PARTICIPANTS_PER_ROUND, 3) == bet * MAX_PARTICIPANTS_PER_ROUND
|
||||
|
||||
|
||||
# --- the quorum's verdict itself (ELC-27) -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
_ENDPOINTS = [
|
||||
ElectrumEndpoint("first.example", 50002, True),
|
||||
ElectrumEndpoint("second.example", 50002, True),
|
||||
ElectrumEndpoint("third.example", 50002, True),
|
||||
ElectrumEndpoint("fourth.example", 50002, True),
|
||||
]
|
||||
_OUTPOINT = ("aa" * 32, 0, 1_000)
|
||||
|
||||
|
||||
class _Server:
|
||||
def __init__(self, entries):
|
||||
self._entries = entries
|
||||
|
||||
async def connect(self):
|
||||
if self._entries is None:
|
||||
raise ConnectionRefusedError("down")
|
||||
|
||||
async def listunspent(self, scripthash):
|
||||
return self._entries
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _listener(answers: dict[str, object], session_factory=None):
|
||||
servers = {host: _Server(entries) for host, entries in answers.items()}
|
||||
return ElectrumListener(lambda endpoint: servers[endpoint.host], session_factory, _ENDPOINTS)
|
||||
|
||||
|
||||
def _listed(value=1_000, height=50):
|
||||
return [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": height, "value": value}]
|
||||
|
||||
|
||||
async def test_a_strict_majority_decides_each_outpoint():
|
||||
# first.example is the active endpoint (never asked); second, third, fourth vote
|
||||
present = _listener({"second.example": _listed(), "third.example": _listed(), "fourth.example": []})
|
||||
assert await present.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): True}
|
||||
absent = _listener({"second.example": _listed(), "third.example": [], "fourth.example": []})
|
||||
assert await absent.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
|
||||
|
||||
|
||||
async def test_another_amount_counts_as_absent_and_a_mempool_coin_as_neither():
|
||||
other_amount = _listener({h: _listed(value=999) for h in ("second.example", "third.example", "fourth.example")})
|
||||
assert await other_amount.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
|
||||
unconfirmed = _listener({h: _listed(height=0) for h in ("second.example", "third.example", "fourth.example")})
|
||||
assert await unconfirmed.verify_pool_outpoints("sh", [_OUTPOINT]) is None # may be mined again: no verdict
|
||||
|
||||
|
||||
async def test_too_few_answers_or_a_tie_conclude_nothing():
|
||||
assert MIN_QUORUM_RESPONSES == 2
|
||||
one_answer = _listener({"second.example": [], "third.example": None, "fourth.example": None})
|
||||
assert await one_answer.verify_pool_outpoints("sh", [_OUTPOINT]) is None
|
||||
tie = _listener({"second.example": _listed(), "third.example": [], "fourth.example": None})
|
||||
assert await tie.verify_pool_outpoints("sh", [_OUTPOINT]) is None
|
||||
# nothing at all: the active server is never a substitute for the quorum
|
||||
nobody = _listener({h: None for h in ("second.example", "third.example", "fourth.example")})
|
||||
assert await nobody.verify_pool_outpoints("sh", [_OUTPOINT]) is None
|
||||
|
||||
|
||||
async def test_the_active_server_is_not_asked_and_cannot_vouch_for_a_bet():
|
||||
class _Hostile(_Server):
|
||||
async def listunspent(self, scripthash):
|
||||
raise AssertionError("the active server must not be asked")
|
||||
|
||||
servers = {"first.example": _Hostile(_listed()), "second.example": _Server([]), "third.example": _Server([]), "fourth.example": _Server([])}
|
||||
listener = ElectrumListener(lambda endpoint: servers[endpoint.host], None, _ENDPOINTS)
|
||||
listener._endpoint_index = 0
|
||||
listener.client = object()
|
||||
assert await listener.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
|
||||
@@ -279,3 +279,55 @@ def test_dust_sized_recipient_amount_is_refused():
|
||||
change_address=change_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
|
||||
# --- B-98 (BET-24): selection leaves a change output whenever the coins allow it ---
|
||||
|
||||
|
||||
def _built(utxos, amount, reduce=False):
|
||||
signer = _key(7)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
return build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=utxos,
|
||||
to_address=script.p2wpkh(_key(8).to_public()).address(network=PLM_MAINNET),
|
||||
amount_sats=amount,
|
||||
change_address=from_script.address(network=PLM_MAINNET),
|
||||
fee_rate_sat_vb=1,
|
||||
reduce_amount_to_keep_change=reduce,
|
||||
)
|
||||
|
||||
|
||||
def test_a_bet_takes_a_second_coin_when_the_first_covers_it_exactly():
|
||||
"""BET-24: 10 + 1 PLM and a 10 PLM bet — both coins, 1 PLM change, no refusal."""
|
||||
utxos = [Utxo("aa" * 32, 0, 1_000_000_000), Utxo("bb" * 32, 0, 100_000_000)]
|
||||
built = _built(utxos, 1_000_000_000)
|
||||
assert built.change_sats == 100_000_000
|
||||
assert len(Transaction.parse(bytes.fromhex(built.raw_hex)).vin) == 2
|
||||
|
||||
|
||||
def test_a_withdrawal_uses_the_second_coin_instead_of_being_reduced():
|
||||
"""BET-24 / WDR-09: the reduction only happens when no combination leaves change."""
|
||||
utxos = [Utxo("aa" * 32, 0, 1_000_000_000), Utxo("bb" * 32, 0, 100_000_000)]
|
||||
built = _built(utxos, 1_000_000_000, reduce=True)
|
||||
assert built.change_sats == 100_000_000
|
||||
assert built.recipient_sats == 1_000_000_000 - built.fee_sats
|
||||
|
||||
|
||||
def test_with_no_coin_to_spare_the_old_outcome_stands():
|
||||
"""BET-24: a lone coin equal to the bet is still refused / the withdrawal still reduced."""
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
utxos = [Utxo("aa" * 32, 0, 1_000_000_000)]
|
||||
with pytest.raises(InsufficientFundsError) as excinfo:
|
||||
_built(utxos, 1_000_000_000)
|
||||
assert excinfo.value.code == "balance_leaves_no_change"
|
||||
assert _built(utxos, 1_000_000_000, reduce=True).change_sats == DUST_LIMIT_SATS
|
||||
|
||||
|
||||
def test_the_change_target_never_pushes_past_the_input_cap():
|
||||
"""BET-24 / BET-08: capped at MAX_TX_INPUTS, a total that covers the amount is kept."""
|
||||
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
|
||||
selected, total = select_utxos(utxos, 100_000 * MAX_TX_INPUTS, min_change_sats=294)
|
||||
assert len(selected) == MAX_TX_INPUTS and total == 100_000 * MAX_TX_INPUTS
|
||||
@@ -0,0 +1,284 @@
|
||||
"""B-87 (TX-11, TX-24, AUD-40, AUD-09): a bump never leaves a dust-sized output, and every bump
|
||||
that cannot be made is visible from /admin.
|
||||
|
||||
`bump_fee` only required `change > fee delta`. A change at the 294 sat minimum (a full-balance
|
||||
withdrawal) shrank to ~153 sat after the very first minimum bump, a replacement with a
|
||||
sub-dust output is refused by relay policy, and the bumper retried it every 30 seconds for
|
||||
ever with nothing but a log line to show for it.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from embit import script
|
||||
from embit.finalizer import finalize_psbt
|
||||
from embit.psbt import PSBT
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User
|
||||
from app.tx.broadcast import RbfBumper, RbfError, bump_fee
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import (
|
||||
DUST_LIMIT_SATS,
|
||||
MAX_FEE_RATE_SAT_VB,
|
||||
RBF_SEQUENCE,
|
||||
Utxo,
|
||||
build_signed_transaction,
|
||||
estimate_vsize,
|
||||
)
|
||||
from tests.unit.fake_network import FakeNetwork, rpc_error
|
||||
from tests.unit.test_broadcast import FakeClient, _key, _seed_payout, session_factory # noqa: F401 (fixture)
|
||||
|
||||
_AMOUNT = 10_000_000
|
||||
_DELTA = estimate_vsize(1, 2) # the first minimum bump of a one-input, two-output tx at 1 sat/vB: 141 sat
|
||||
|
||||
|
||||
def _stale() -> datetime:
|
||||
return datetime.now(timezone.utc) - timedelta(seconds=5000)
|
||||
|
||||
|
||||
async def _pending_bet(session_factory, *, change: int, fee_rate: int = 1, username: str = "alice"):
|
||||
"""A real bet whose change output is exactly `change` sats, in `pending`, due for a bump."""
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
|
||||
signer = derive_user_key(0)
|
||||
address = derive_user_address(0)
|
||||
utxo_txid = "11" * 32
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=script.p2wpkh(signer.to_public()),
|
||||
utxos=[Utxo(utxo_txid, 0, _AMOUNT + change)],
|
||||
to_address=script.p2wpkh(_key(99).to_public()).address(network=PLM_MAINNET),
|
||||
amount_sats=_AMOUNT,
|
||||
change_address=address,
|
||||
fee_rate_sat_vb=fee_rate,
|
||||
)
|
||||
assert built.change_sats == change
|
||||
async with session_factory() as session:
|
||||
user = User(username=username, password_hash="x", derivation_index=0, address=address)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
row = PendingTransaction(
|
||||
kind="bet", user_id=user.id, current_txid=built.txid, fee_rate_sat_vb=fee_rate,
|
||||
raw_tx_hex=built.raw_hex, status="pending", broadcast_at=_stale(), last_broadcast_at=_stale(),
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row.id, built, FakeClient({utxo_txid: _AMOUNT + change})
|
||||
|
||||
|
||||
def _outputs(raw_hex: str) -> list[int]:
|
||||
return [o.value for o in Transaction.parse(bytes.fromhex(raw_hex)).vout]
|
||||
|
||||
|
||||
# --- the change that would fall under dust is dropped into the fee (TX-11) ------------------------------
|
||||
|
||||
|
||||
async def test_a_minimum_change_is_dropped_into_the_fee_by_the_first_bump(session_factory):
|
||||
"""The case of the finding: a 294 sat change. The replacement carries one output (the
|
||||
recipient's, untouched), pays the whole change as fee, and is the last one possible."""
|
||||
pending_id, built, client = await _pending_bet(session_factory, change=DUST_LIMIT_SATS)
|
||||
|
||||
new_txid = await bump_fee(session_factory, client, pending_id)
|
||||
|
||||
assert new_txid is not None and new_txid != built.txid
|
||||
old, new = _outputs(built.raw_hex), _outputs(client.broadcasted[0])
|
||||
assert len(old) == 2 and len(new) == 1
|
||||
assert new[0] == old[0] # the recipient is never touched
|
||||
assert sum(old) - sum(new) == DUST_LIMIT_SATS # the whole change went to the fee
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
assert row.current_txid == new_txid and row.status == "pending"
|
||||
assert row.fee_rate_sat_vb == (built.fee_sats + DUST_LIMIT_SATS) // estimate_vsize(1, 1)
|
||||
|
||||
# nothing is left to reduce: any further bump is impossible, with the reason to show for it
|
||||
async with session_factory() as session:
|
||||
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
|
||||
await session.commit()
|
||||
with pytest.raises(RbfError) as refused:
|
||||
await bump_fee(session_factory, client, pending_id)
|
||||
assert refused.value.reason == "change_too_small" and len(client.broadcasted) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change, kept",
|
||||
[
|
||||
pytest.param(DUST_LIMIT_SATS + _DELTA, True, id="leaves-exactly-dust-limit-and-is-kept"),
|
||||
pytest.param(DUST_LIMIT_SATS + _DELTA - 1, False, id="one-sat-under-and-is-dropped"),
|
||||
pytest.param(DUST_LIMIT_SATS + _DELTA + 1_000, True, id="plenty-and-is-kept"),
|
||||
],
|
||||
)
|
||||
async def test_the_dust_boundary_of_what_a_bump_leaves(session_factory, change, kept):
|
||||
pending_id, built, client = await _pending_bet(session_factory, change=change)
|
||||
|
||||
await bump_fee(session_factory, client, pending_id)
|
||||
|
||||
new = _outputs(client.broadcasted[0])
|
||||
if kept:
|
||||
assert len(new) == 2 and new[1] == change - _DELTA >= DUST_LIMIT_SATS
|
||||
else:
|
||||
assert len(new) == 1
|
||||
assert sum(_outputs(built.raw_hex)) - sum(new) == change # all of it, not just the delta
|
||||
|
||||
|
||||
async def test_a_change_that_cannot_even_pay_the_minimum_increase_cannot_be_bumped(session_factory):
|
||||
"""Giving the whole output to the fee must still reach BIP125's minimum increase."""
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
|
||||
signer = derive_user_key(0)
|
||||
address = derive_user_address(0)
|
||||
legacy = Transaction(
|
||||
vin=[TransactionInput(bytes.fromhex("22" * 32), 0, sequence=RBF_SEQUENCE)],
|
||||
vout=[
|
||||
TransactionOutput(9_999_000, script.Script.from_address(script.p2wpkh(_key(97).to_public()).address(network=PLM_MAINNET))),
|
||||
TransactionOutput(_DELTA - 1, script.Script.from_address(address)),
|
||||
],
|
||||
)
|
||||
async with session_factory() as session:
|
||||
user = User(username="bob", password_hash="x", derivation_index=0, address=address)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
row = PendingTransaction(
|
||||
kind="bet", user_id=user.id, current_txid=legacy.txid().hex(), fee_rate_sat_vb=1,
|
||||
raw_tx_hex=legacy.serialize().hex(), status="pending", broadcast_at=_stale(), last_broadcast_at=_stale(),
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
pending_id = row.id
|
||||
client = FakeClient({"22" * 32: 10_000_000})
|
||||
with pytest.raises(RbfError):
|
||||
await bump_fee(session_factory, client, pending_id)
|
||||
assert client.broadcasted == []
|
||||
|
||||
|
||||
async def test_a_payouts_bump_reserve_is_dropped_the_same_way(session_factory):
|
||||
"""TX-11 names the reserve too: when a bump would shrink it under dust it is dropped, and
|
||||
the winner's and the fee address' outputs are still byte-for-byte what they were."""
|
||||
built, pending_id, prevouts = await _seed_payout(session_factory, n=1, fee_rate=1)
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
row.fee_rate_sat_vb = 9 # the next target (10 sat/vB) leaves 172 sat of a 1 720 sat reserve
|
||||
await session.commit()
|
||||
client = FakeClient(prevouts)
|
||||
|
||||
await bump_fee(session_factory, client, pending_id)
|
||||
|
||||
old_tx = Transaction.parse(bytes.fromhex(built.raw_hex))
|
||||
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
|
||||
assert len(old_tx.vout) == 3 and len(new_tx.vout) == 2
|
||||
assert new_tx.vout[0].serialize() == old_tx.vout[0].serialize()
|
||||
assert new_tx.vout[1].serialize() == old_tx.vout[1].serialize()
|
||||
assert sum(o.value for o in old_tx.vout) - sum(o.value for o in new_tx.vout) == old_tx.vout[2].value
|
||||
|
||||
|
||||
# --- every refused bump is visible (TX-24, AUD-40, AUD-09) ------------------------------------------------------
|
||||
|
||||
|
||||
def _bumper(session_factory, client, network=None):
|
||||
return RbfBumper(session_factory, lambda: client, network=network or FakeNetwork())
|
||||
|
||||
|
||||
async def _failures(session_factory) -> list[dict]:
|
||||
async with session_factory() as session:
|
||||
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "rbf_bump_failed"))).all()
|
||||
return [dict(json.loads(r.payload_json), user_id=r.user_id, round_id=r.round_id) for r in rows]
|
||||
|
||||
|
||||
async def test_a_change_too_small_bump_writes_one_rbf_bump_failed_row_per_txid_and_reason(session_factory):
|
||||
pending_id, built, client = await _pending_bet(session_factory, change=DUST_LIMIT_SATS)
|
||||
bumper = _bumper(session_factory, client)
|
||||
|
||||
await bumper._tick(client) # succeeds: the change is dropped
|
||||
assert await _failures(session_factory) == []
|
||||
|
||||
async with session_factory() as session:
|
||||
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
|
||||
await session.commit()
|
||||
for _ in range(3): # three 30 second rounds with the same impossible bump
|
||||
await bumper._tick(client)
|
||||
async with session_factory() as session:
|
||||
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
|
||||
await session.commit()
|
||||
|
||||
failures = await _failures(session_factory)
|
||||
assert len(failures) == 1 # once, not once per tick
|
||||
row = failures[0]
|
||||
assert row["reason"] == "change_too_small" and row["kind"] == "bet"
|
||||
assert row["pending_transaction_id"] == pending_id and row["user_id"] is not None and row["round_id"] is None
|
||||
async with session_factory() as session:
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
assert row["txid"] == pending.current_txid != built.txid # the current txid, i.e. the one that cannot be bumped
|
||||
assert pending.status == "pending" # the row itself is untouched (TX-24)
|
||||
assert len(row["message"]) <= 200
|
||||
|
||||
|
||||
async def test_the_maximum_fee_rate_is_reported(session_factory):
|
||||
pending_id, built, client = await _pending_bet(session_factory, change=1_000_000, fee_rate=MAX_FEE_RATE_SAT_VB)
|
||||
await _bumper(session_factory, client)._tick(client)
|
||||
failures = await _failures(session_factory)
|
||||
assert [f["reason"] for f in failures] == ["max_fee_rate"] and failures[0]["txid"] == built.txid
|
||||
assert client.broadcasted == []
|
||||
|
||||
|
||||
async def test_a_replacement_the_network_refuses_is_reported_and_leaves_the_row_alone(session_factory):
|
||||
pending_id, built, client = await _pending_bet(session_factory, change=1_000_000)
|
||||
|
||||
async def refuse(raw):
|
||||
raise rpc_error("min relay fee not met")
|
||||
|
||||
client.broadcast = refuse
|
||||
bumper = _bumper(session_factory, client, FakeNetwork(known=False))
|
||||
await bumper._tick(client)
|
||||
|
||||
failures = await _failures(session_factory)
|
||||
assert [f["reason"] for f in failures] == ["replacement_rejected"]
|
||||
assert "min relay fee not met" in failures[0]["message"]
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(PendingTransaction, pending_id)).current_txid == built.txid
|
||||
|
||||
|
||||
async def test_a_new_txid_or_a_new_reason_writes_a_new_row(session_factory):
|
||||
"""AUD-09: the pair (current txid, reason) is what makes two failures the same one."""
|
||||
pending_id, built, client = await _pending_bet(session_factory, change=1_000_000)
|
||||
bumper = _bumper(session_factory, client, FakeNetwork(known=False))
|
||||
|
||||
async def refuse(raw):
|
||||
raise rpc_error("min relay fee not met")
|
||||
|
||||
real_broadcast = client.broadcast
|
||||
client.broadcast = refuse
|
||||
for _ in range(2):
|
||||
await bumper._tick(client)
|
||||
async with session_factory() as session:
|
||||
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
|
||||
await session.commit()
|
||||
assert [f["reason"] for f in await _failures(session_factory)] == ["replacement_rejected"] # same pair: once
|
||||
|
||||
client.broadcast = real_broadcast # the bump now succeeds ...
|
||||
await bumper._tick(client)
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
assert row.current_txid != built.txid
|
||||
row.fee_rate_sat_vb = MAX_FEE_RATE_SAT_VB # ... and the next one hits the ceiling: new txid, new reason
|
||||
row.last_broadcast_at = _stale()
|
||||
await session.commit()
|
||||
await bumper._tick(client)
|
||||
failures = await _failures(session_factory)
|
||||
assert [f["reason"] for f in failures] == ["replacement_rejected", "max_fee_rate"]
|
||||
assert failures[0]["txid"] == built.txid and failures[1]["txid"] != built.txid
|
||||
|
||||
|
||||
async def test_a_payout_failure_carries_its_round(session_factory):
|
||||
built, pending_id, prevouts = await _seed_payout(session_factory, n=1)
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
row.fee_rate_sat_vb = MAX_FEE_RATE_SAT_VB
|
||||
row.last_broadcast_at = _stale()
|
||||
await session.commit()
|
||||
client = FakeClient(prevouts)
|
||||
await _bumper(session_factory, client)._tick(client)
|
||||
failures = await _failures(session_factory)
|
||||
assert len(failures) == 1 and failures[0]["kind"] == "payout" and failures[0]["round_id"] == 1
|
||||
assert failures[0]["user_id"] is None
|
||||
@@ -0,0 +1,133 @@
|
||||
"""B-90 (REG-15, OPS-27): a restored backup must not hand out derivation indexes that already
|
||||
have history, and START_PAUSED starts the lottery paused."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import AuditLog, RoundConfig, User
|
||||
from app.electrum.listener import ElectrumListener
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.wallet.hd import derive_user_address
|
||||
from tests.unit.test_electrum_listener import _ENDPOINTS, session_factory # noqa: F401 (fixture)
|
||||
|
||||
|
||||
class _Server:
|
||||
"""Answers get_history: the given scripthashes have a transaction, the rest none."""
|
||||
|
||||
def __init__(self, used=(), fail=False):
|
||||
self.used = set(used)
|
||||
self.fail = fail
|
||||
|
||||
async def connect(self):
|
||||
if self.fail:
|
||||
raise ConnectionRefusedError("down")
|
||||
|
||||
async def request(self, method, params=None):
|
||||
assert method == "blockchain.scripthash.get_history"
|
||||
return [{"tx_hash": "aa" * 32, "height": 5}] if params[0] in self.used else []
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _listener(session_factory, active, others):
|
||||
servers = {"second.example": others[0], "third.example": others[1]}
|
||||
listener = ElectrumListener(lambda endpoint: servers[endpoint.host], session_factory, _ENDPOINTS)
|
||||
listener.client = active
|
||||
listener.registration_open = False
|
||||
return listener
|
||||
|
||||
|
||||
async def _users(session_factory, count):
|
||||
async with session_factory() as session:
|
||||
for i in range(count):
|
||||
session.add(User(username=f"u{i}", password_hash="x", derivation_index=i, address=derive_user_address(i)))
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _hash(index):
|
||||
return address_to_scripthash(derive_user_address(index))
|
||||
|
||||
|
||||
async def _audit(session_factory):
|
||||
async with session_factory() as session:
|
||||
return list((await session.execute(select(AuditLog).where(AuditLog.event_type == "restore_gap_detected"))).scalars())
|
||||
|
||||
|
||||
async def test_registration_opens_when_the_next_20_indexes_are_unused(session_factory):
|
||||
await _users(session_factory, 3)
|
||||
listener = _listener(session_factory, _Server(), [_Server(), _Server()])
|
||||
await listener._check_restore_gap()
|
||||
assert listener.registration_open and await _audit(session_factory) == []
|
||||
|
||||
|
||||
async def test_one_server_reporting_history_is_enough_to_keep_it_closed(session_factory):
|
||||
"""REG-15: the checked window is highest + 1 … highest + 20; any server's word counts."""
|
||||
await _users(session_factory, 3) # highest assigned index 2 -> checks 3..22
|
||||
listener = _listener(session_factory, _Server(), [_Server(used={_hash(22)}), _Server()])
|
||||
await listener._check_restore_gap()
|
||||
assert not listener.registration_open
|
||||
(row,) = await _audit(session_factory)
|
||||
payload = json.loads(row.payload_json)
|
||||
assert payload["highest_assigned_index"] == 2
|
||||
assert payload["used"] == [{"derivation_index": 22, "address": derive_user_address(22)}]
|
||||
|
||||
|
||||
async def test_an_index_just_past_the_window_is_not_looked_at(session_factory):
|
||||
await _users(session_factory, 3)
|
||||
listener = _listener(session_factory, _Server(used={_hash(23)}), [_Server(), _Server()])
|
||||
await listener._check_restore_gap()
|
||||
assert listener.registration_open
|
||||
|
||||
|
||||
async def test_an_empty_database_checks_indexes_0_to_19(session_factory):
|
||||
listener = _listener(session_factory, _Server(used={_hash(0)}), [_Server(), _Server()])
|
||||
await listener._check_restore_gap()
|
||||
(row,) = await _audit(session_factory)
|
||||
assert json.loads(row.payload_json)["highest_assigned_index"] is None
|
||||
|
||||
|
||||
async def test_without_the_active_servers_answer_nothing_is_decided(session_factory):
|
||||
"""The check is simply tried again on the next session; registration stays closed."""
|
||||
listener = _listener(session_factory, None, [_Server(), _Server()])
|
||||
with pytest.raises(RuntimeError):
|
||||
await listener._check_restore_gap()
|
||||
assert not listener.registration_open and not listener._restore_checked
|
||||
|
||||
|
||||
async def test_an_unreachable_quorum_server_does_not_block_a_clean_result(session_factory):
|
||||
listener = _listener(session_factory, _Server(), [_Server(fail=True), _Server()])
|
||||
await listener._check_restore_gap()
|
||||
assert listener.registration_open
|
||||
|
||||
|
||||
async def test_register_is_refused_while_the_check_has_not_passed(session_factory, monkeypatch):
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
monkeypatch.setattr(ElectrumListener, "registration_open", False)
|
||||
app.state.electrum_listener = listener
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as client:
|
||||
response = await client.post("/auth/register", json={"username": "alice", "password": "password123"})
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"]["code"] == "registration_unavailable"
|
||||
|
||||
|
||||
async def test_start_paused_pauses_as_the_operator_without_overwriting_an_automatic_reason(session_factory):
|
||||
from app.rounds.pause import pause_automatically, pause_by_operator
|
||||
|
||||
async with session_factory() as session:
|
||||
await pause_by_operator(session, "START_PAUSED")
|
||||
await session.commit()
|
||||
config = (await session.execute(select(RoundConfig))).scalar_one()
|
||||
assert config.paused and config.paused_reason == "operator"
|
||||
await pause_automatically(session, "reorg")
|
||||
await pause_by_operator(session, "START_PAUSED")
|
||||
await session.commit()
|
||||
assert config.paused_reason == "reorg" # an automatic reason is the one that needs a look
|
||||
events = (await session.execute(select(AuditLog).where(AuditLog.event_type == "lottery_paused"))).scalars().all()
|
||||
assert [json.loads(e.payload_json)["reason"] for e in events] == ["operator", "reorg", "operator"]
|
||||
@@ -183,7 +183,11 @@ async def _payout(payout_session_factory, pool_address: str, round_id: int = 1):
|
||||
client = _RecordingPayoutClient(
|
||||
[{"tx_hash": "44" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS}]
|
||||
)
|
||||
await RoundScheduler(payout_session_factory, FakePayoutListener(client))._trigger_payout(round_id)
|
||||
listener = FakePayoutListener(client)
|
||||
await RoundScheduler(payout_session_factory, listener)._trigger_payout(round_id)
|
||||
# B-82: a round with its own address asks the quorum (the listener), not the active
|
||||
# connection, which is only asked for a round paid from the shared reserve address.
|
||||
client.scripthashes = client.scripthashes + listener.verified_scripthashes
|
||||
return client
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""B-95 (RND-05, RND-06, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-31): a round
|
||||
runs by the parameters it opened with.
|
||||
|
||||
Only the duration and the cooldown were copied onto the round when it opened; the cost of a bet
|
||||
and the fee address were read live, so an edit from /admin in the middle of a round gave the
|
||||
players of that round different stakes for the same chance, and redirected the commission of
|
||||
the round in progress. The cap on the pool is new.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.bets.service import BetError, place_bet
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User
|
||||
from app.rounds.scheduler import RoundScheduler
|
||||
from app.rounds.service import open_new_round_if_needed
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from tests.unit.test_bets import FakeElectrumClient, _make_funded_user, session_factory # noqa: F401 (fixture)
|
||||
from tests.unit.test_migrations import _alembic, _index_sql
|
||||
from tests.unit.test_scheduler import FakePayoutClient, FakePayoutListener, _FEE_ADDRESS as _SEEDED_FEE_ADDRESS
|
||||
|
||||
_OTHER_FEE_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
|
||||
|
||||
|
||||
async def _edit_config(session_factory, **values) -> None:
|
||||
async with session_factory() as session:
|
||||
config = (await session.scalars(select(RoundConfig))).one()
|
||||
for name, value in values.items():
|
||||
setattr(config, name, value)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _open_round(session_factory) -> Round:
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
return round_
|
||||
|
||||
|
||||
# --- the copy (RND-05, DAT-61) ---------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_round_copies_cost_fee_address_and_cap_when_it_opens(session_factory):
|
||||
await _edit_config(session_factory, bet_amount_sats=700_000_000, max_round_pool_sats=5_000_000_000)
|
||||
round_ = await _open_round(session_factory)
|
||||
assert (round_.bet_amount_sats, round_.fee_address, round_.max_round_pool_sats) == (
|
||||
700_000_000,
|
||||
_SEEDED_FEE_ADDRESS,
|
||||
5_000_000_000,
|
||||
)
|
||||
assert round_.draw_confirmations == 3 and round_.duration_seconds == 600 # what was already copied
|
||||
|
||||
|
||||
async def test_an_edit_from_admin_never_reaches_the_round_in_progress_but_the_next_one(session_factory):
|
||||
first = await _open_round(session_factory)
|
||||
await _edit_config(
|
||||
session_factory, bet_amount_sats=2_000_000_000, fee_address=_OTHER_FEE_ADDRESS, max_round_pool_sats=9_000_000_000
|
||||
)
|
||||
async with session_factory() as session:
|
||||
again = await session.get(Round, first.id)
|
||||
assert (again.bet_amount_sats, again.fee_address, again.max_round_pool_sats) == (
|
||||
1_000_000_000,
|
||||
_SEEDED_FEE_ADDRESS,
|
||||
0,
|
||||
)
|
||||
again.status = "closed"
|
||||
again.closed_at = None # no cooldown to wait for
|
||||
await session.commit()
|
||||
second = await _open_round(session_factory)
|
||||
assert (second.bet_amount_sats, second.fee_address, second.max_round_pool_sats) == (
|
||||
2_000_000_000,
|
||||
_OTHER_FEE_ADDRESS,
|
||||
9_000_000_000,
|
||||
)
|
||||
|
||||
|
||||
# --- the cost (BET-21, DAT-26) -----------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("edited", [3_000_000_000, 500_000_000], ids=["raised", "lowered"])
|
||||
async def test_every_bet_of_a_round_pays_the_roundd_cost_whatever_is_edited_meanwhile(session_factory, edited):
|
||||
first = await _make_funded_user(session_factory, 0, 5_000_000_000)
|
||||
second = await _make_funded_user(session_factory, 1, 5_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
await place_bet(session, client, await session.get(User, first))
|
||||
await _edit_config(session_factory, bet_amount_sats=edited)
|
||||
async with session_factory() as session:
|
||||
await place_bet(session, client, await session.get(User, second))
|
||||
|
||||
amounts = []
|
||||
for raw in client.broadcasted:
|
||||
tx = Transaction.parse(bytes.fromhex(raw))
|
||||
amounts.append(tx.vout[0].value)
|
||||
# both pay the same stake (cost 10 PLM minus the same network fee), not the edited one
|
||||
assert amounts[0] == amounts[1]
|
||||
assert 1_000_000_000 - 10_000 < amounts[0] <= 1_000_000_000
|
||||
async with session_factory() as session:
|
||||
stakes = (await session.scalars(select(RoundParticipant.bet_amount_sats))).all()
|
||||
assert len(set(stakes)) == 1
|
||||
|
||||
|
||||
# --- the commission goes where the round said (RND-06) ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_payout_pays_the_fee_address_the_round_opened_with(payout_session_factory):
|
||||
from app.wallet.hd import derive_pool_address
|
||||
from tests.unit.test_scheduler import _seed_paying_out_round
|
||||
|
||||
edited_to = derive_pool_address(4242) # a valid address that is neither the winner's nor the seeded one
|
||||
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
async with payout_session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().fee_address = edited_to # edited mid-round
|
||||
await session.commit()
|
||||
client = FakePayoutClient([])
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
|
||||
assert tx.vout[1].script_pubkey.address(network=PLM_MAINNET) == _SEEDED_FEE_ADDRESS
|
||||
assert edited_to not in {o.script_pubkey.address(network=PLM_MAINNET) for o in tx.vout}
|
||||
|
||||
|
||||
async def test_clearing_the_fee_address_mid_round_does_not_stop_its_payout(payout_session_factory):
|
||||
from tests.unit.test_scheduler import _seed_paying_out_round
|
||||
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
async with payout_session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().fee_address = "" # the operator slip of B-66
|
||||
await session.commit()
|
||||
client = FakePayoutClient([])
|
||||
await RoundScheduler(payout_session_factory, FakePayoutListener(client))._trigger_payout(1)
|
||||
assert client.broadcasted and not [
|
||||
e for e in (await _events(payout_session_factory)) if e == "payout_failed"
|
||||
]
|
||||
|
||||
|
||||
async def _events(session_factory) -> list[str]:
|
||||
async with session_factory() as session:
|
||||
return list((await session.scalars(select(AuditLog.event_type))).all())
|
||||
|
||||
|
||||
# the fixture of the payout tests (the in-memory database with a master key)
|
||||
from tests.unit.test_scheduler import payout_session_factory # noqa: E402,F401
|
||||
|
||||
|
||||
# --- the cap on the pool (BET-20, DAT-27) ------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _bet(session_factory, user_id: int, client=None):
|
||||
async with session_factory() as session:
|
||||
return await place_bet(session, client or FakeElectrumClient(), await session.get(User, user_id))
|
||||
|
||||
|
||||
async def test_a_bet_is_refused_once_the_pool_has_reached_the_cap_and_only_then(session_factory):
|
||||
# one bet nets ~ 999.9k sat: a cap of 1.5 bets lets exactly two bets in (the second starts under it)
|
||||
await _edit_config(session_factory, max_round_pool_sats=1_500_000_000)
|
||||
users = [await _make_funded_user(session_factory, i, 2_000_000_000) for i in range(3)]
|
||||
|
||||
await _bet(session_factory, users[0]) # pool 0 < cap
|
||||
await _bet(session_factory, users[1]) # pool ~1.0e9 < 1.5e9: accepted, takes the pool past the cap
|
||||
with pytest.raises(BetError) as refused:
|
||||
await _bet(session_factory, users[2]) # pool ~2.0e9 >= cap
|
||||
assert refused.value.code == "round_full" and refused.value.params["max_round_pool_sats"] == 1_500_000_000
|
||||
async with session_factory() as session: # nothing moved for the refused player
|
||||
assert (await session.scalars(select(PendingTransaction.user_id))).all().count(users[2]) == 0
|
||||
assert len((await session.scalars(select(RoundParticipant))).all()) == 2
|
||||
from app.db.models import UtxoEvent
|
||||
|
||||
refused_coins = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == users[2]))).all()
|
||||
assert refused_coins and all(coin.spent_txid is None for coin in refused_coins)
|
||||
|
||||
|
||||
async def test_a_pool_exactly_at_the_cap_has_reached_it(session_factory):
|
||||
""""Reached" is `>=`: a pool that equals the cap is full."""
|
||||
first = await _make_funded_user(session_factory, 0, 2_000_000_000)
|
||||
second = await _make_funded_user(session_factory, 1, 2_000_000_000)
|
||||
await _bet(session_factory, first)
|
||||
async with session_factory() as session:
|
||||
net = (await session.scalars(select(RoundParticipant.bet_amount_sats))).one()
|
||||
(await session.scalars(select(Round))).one().max_round_pool_sats = net # the round's own cap, set to the pool
|
||||
await session.commit()
|
||||
with pytest.raises(BetError) as refused:
|
||||
await _bet(session_factory, second)
|
||||
assert refused.value.code == "round_full"
|
||||
|
||||
|
||||
async def test_the_cap_counts_bets_still_in_flight(session_factory):
|
||||
await _edit_config(session_factory, max_round_pool_sats=900_000_000)
|
||||
first = await _make_funded_user(session_factory, 0, 2_000_000_000)
|
||||
second = await _make_funded_user(session_factory, 1, 2_000_000_000)
|
||||
await _bet(session_factory, first) # `broadcast`, not confirmed
|
||||
async with session_factory() as session:
|
||||
assert (await session.scalars(select(RoundParticipant.status))).all() == ["broadcast"]
|
||||
with pytest.raises(BetError) as refused:
|
||||
await _bet(session_factory, second)
|
||||
assert refused.value.code == "round_full"
|
||||
|
||||
|
||||
async def test_without_a_cap_nothing_limits_the_pool_but_the_participants(session_factory):
|
||||
users = [await _make_funded_user(session_factory, i, 2_000_000_000) for i in range(4)]
|
||||
for user_id in users:
|
||||
await _bet(session_factory, user_id)
|
||||
async with session_factory() as session:
|
||||
assert len((await session.scalars(select(RoundParticipant))).all()) == 4
|
||||
|
||||
|
||||
async def test_raising_or_lowering_the_cap_mid_round_does_not_change_that_round(session_factory):
|
||||
await _edit_config(session_factory, max_round_pool_sats=1_500_000_000)
|
||||
users = [await _make_funded_user(session_factory, i, 2_000_000_000) for i in range(4)]
|
||||
await _bet(session_factory, users[0])
|
||||
await _edit_config(session_factory, max_round_pool_sats=100_000_000_000) # raised: this round keeps 1.5e9
|
||||
await _bet(session_factory, users[1])
|
||||
with pytest.raises(BetError):
|
||||
await _bet(session_factory, users[2])
|
||||
await _edit_config(session_factory, max_round_pool_sats=1) # lowered below everything: no effect either
|
||||
async with session_factory() as session:
|
||||
assert (await session.scalars(select(Round.max_round_pool_sats))).all() == [1_500_000_000]
|
||||
|
||||
|
||||
# --- the existing rounds at the migration (DAT-55) -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_migration_fills_existing_rounds_with_the_current_configuration(tmp_path):
|
||||
db_path = tmp_path / "old.db"
|
||||
_alembic(db_path, "upgrade", "f2c64a8d1e93")
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO round_config (fee_address, bet_amount_sats, round_duration_seconds, round_cooldown_seconds, "
|
||||
"fee_rate_sat_vb, rbf_timeout_seconds, draw_animation_seconds, paused, deposit_confirmations, "
|
||||
"draw_confirmations, updated_at) "
|
||||
f"VALUES ('{_OTHER_FEE_ADDRESS}', 750000000, 600, 30, 1, 900, 20, 0, 6, 3, '2026-01-01 00:00:00')"
|
||||
)
|
||||
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closed', '2026-01-01 00:00:00')")
|
||||
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('paying_out', '2026-01-02 00:00:00')")
|
||||
_alembic(db_path, "upgrade", "head")
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
rows = conn.execute("SELECT bet_amount_sats, fee_address, max_round_pool_sats FROM rounds").fetchall()
|
||||
cap = conn.execute("SELECT max_round_pool_sats FROM round_config").fetchone()[0]
|
||||
assert rows == [(750_000_000, _OTHER_FEE_ADDRESS, 0)] * 2 and cap == 0
|
||||
assert _index_sql(Path(db_path), "ix_rounds_single_active") is not None
|
||||
assert _index_sql(Path(db_path), "ix_users_username_lower") is not None
|
||||
@@ -316,3 +316,27 @@ async def test_lottery_configured_is_false_when_the_amounts_could_not_pay_a_roun
|
||||
(await session.scalars(select(RoundConfig))).one().bet_amount_sats = 1_000_000_000
|
||||
await session.commit()
|
||||
assert (await ac.get("/rounds/current")).json()["lottery_configured"] is True
|
||||
|
||||
|
||||
async def test_current_round_shows_the_round_cost_while_active_and_the_configured_one_otherwise(client): # B-95 (API-21)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Round, RoundConfig
|
||||
|
||||
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=70, status="open", opened_at=datetime.now(timezone.utc), bet_amount_sats=1_000_000_000))
|
||||
await session.commit()
|
||||
async with session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().bet_amount_sats = 4_000_000_000 # edited mid-round
|
||||
await session.commit()
|
||||
|
||||
assert (await ac.get("/rounds/current")).json()["bet_amount_sats"] == 1_000_000_000 # the round's own
|
||||
|
||||
async with session_factory() as session:
|
||||
(await session.get(Round, 70)).status = "closed"
|
||||
await session.commit()
|
||||
assert (await ac.get("/rounds/current")).json()["bet_amount_sats"] == 4_000_000_000 # what the next one will cost
|
||||
@@ -120,7 +120,15 @@ async def payout_session_factory(tmp_path, monkeypatch):
|
||||
hd._account_key = None
|
||||
|
||||
|
||||
async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int:
|
||||
def _payout_bet_txid(index: int) -> str:
|
||||
return f"{index + 0x33:064x}"
|
||||
|
||||
|
||||
async def _seed_paying_out_round(session_factory, round_id: int = 1, bets: list[int] | None = None) -> int:
|
||||
"""A round in `paying_out` whose confirmed participants — the first one is the winner —
|
||||
each placed a bet of the given net amount (B-82: the payout spends exactly those bet
|
||||
outputs, `bet_txid:0`, so the pool is their sum)."""
|
||||
bets = bets if bets is not None else [_POOL_AMOUNT_SATS]
|
||||
async with session_factory() as session:
|
||||
winner = User(username="winner", password_hash="x", derivation_index=0, address=_WINNER_ADDRESS)
|
||||
session.add(winner)
|
||||
@@ -130,11 +138,28 @@ async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int:
|
||||
Round(
|
||||
id=round_id,
|
||||
status="paying_out",
|
||||
pool_amount_sats=_POOL_AMOUNT_SATS,
|
||||
pool_amount_sats=sum(bets),
|
||||
fee_address=_FEE_ADDRESS, # B-95: the round's own, copied at open
|
||||
winner_user_id=winner.id,
|
||||
pool_address=derive_pool_address(round_id), # B-80
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
for index, amount in enumerate(bets):
|
||||
user = winner
|
||||
if index:
|
||||
user = User(
|
||||
username=f"bettor{index}", password_hash="x", derivation_index=index,
|
||||
address=derive_pool_address(7000 + index),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=round_id, user_id=user.id, bet_amount_sats=amount, bet_txid=_payout_bet_txid(index),
|
||||
bet_vout=0, status="confirmed", draw_position=index,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return winner.id
|
||||
|
||||
@@ -206,18 +231,14 @@ async def test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_in
|
||||
so a round past MAX_TX_INPUTS participants could not be paid at all — the build
|
||||
failed with too_many_inputs, the round stayed "paying_out" retrying every 60s,
|
||||
and no new round could ever open behind it. It must now broadcast normally."""
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
|
||||
participants = MAX_TX_INPUTS + 1
|
||||
bet_sats = _POOL_AMOUNT_SATS // participants
|
||||
entries = [
|
||||
{"tx_hash": f"{i:064x}", "tx_pos": 0, "height": 10, "value": bet_sats}
|
||||
for i in range(participants)
|
||||
]
|
||||
# The pool's total must cover the round's recorded pool_amount_sats, exactly as
|
||||
# on-chain: integer division above leaves a remainder, so top the last one up.
|
||||
entries[-1]["value"] += _POOL_AMOUNT_SATS - bet_sats * participants
|
||||
client = FakePayoutClient(entries)
|
||||
bets = [bet_sats] * participants
|
||||
# The pool's total is the round's recorded pool_amount_sats: integer division above
|
||||
# leaves a remainder, so top the last one up.
|
||||
bets[-1] += _POOL_AMOUNT_SATS - bet_sats * participants
|
||||
await _seed_paying_out_round(payout_session_factory, bets=bets)
|
||||
client = FakePayoutClient([])
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
@@ -347,8 +368,9 @@ async def test_trigger_payout_logs_a_failure_when_not_connected(payout_session_f
|
||||
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 = ""
|
||||
# B-95 (RND-06): the payout reads the round's own address, so only an empty one on the
|
||||
# round (a database edited by hand — the API never lets it be emptied) blocks it.
|
||||
(await session.get(Round, 1)).fee_address = ""
|
||||
await session.commit()
|
||||
|
||||
entries = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
|
||||
|
||||
@@ -671,7 +671,11 @@ def _payout_scheduler(session_factory, client):
|
||||
from app.rounds.scheduler import RoundScheduler
|
||||
from tests.unit.test_scheduler import FakePayoutListener
|
||||
|
||||
return RoundScheduler(session_factory, FakePayoutListener(client))
|
||||
listener = FakePayoutListener(client)
|
||||
# B-82: the network phase of a payout is the quorum's verification of the round's coins,
|
||||
# so that is where a test injects "the lock is lost while the network is being used".
|
||||
listener.on_verify = getattr(client, "_hook_listunspent", None)
|
||||
return RoundScheduler(session_factory, listener)
|
||||
|
||||
|
||||
class _PayoutClient:
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""B-96: the smaller state inconsistencies — WDR-15, BET-07 (pending-covered bet), TX-16, TX-18,
|
||||
TX-27, TX-29/AUD-46, DEP-15/DEP-16 and BET-02/WDR-01."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.bets.service import BetError, place_bet
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.scheduler import CLOSING_STALL_THRESHOLD_SECONDS, RoundScheduler
|
||||
from app.tx.reconcile import reconcile_once
|
||||
from app.withdrawals.service import WithdrawalError, request_withdrawal
|
||||
from tests.unit.fake_network import FakeNetwork
|
||||
from tests.unit.test_reconcile import KnownTxClient, _seed_bet
|
||||
from tests.unit.test_reconcile import session_factory as reconcile_session_factory # noqa: F401 (fixture)
|
||||
from tests.unit.test_scheduler import FakeListener, _seed_closing_round
|
||||
from tests.unit.test_scheduler import session_factory as scheduler_session_factory # noqa: F401 (fixture)
|
||||
from tests.unit.test_withdrawals import (
|
||||
BET_AMOUNT_SATS,
|
||||
EXTERNAL_ADDRESS,
|
||||
FakeElectrumClient,
|
||||
_make_funded_user,
|
||||
)
|
||||
from tests.unit.test_withdrawals import session_factory as wallet_session_factory # noqa: F401 (fixture)
|
||||
|
||||
|
||||
# --- WDR-15 and the pending-covered bet ---------------------------------------------
|
||||
|
||||
|
||||
async def test_a_reduced_full_balance_withdrawal_is_checked_against_the_minimum(wallet_session_factory):
|
||||
"""WDR-15: moving the minimum from a one-coin balance leaves a dust limit behind, which puts
|
||||
the amount that actually goes out under the minimum."""
|
||||
session_factory = wallet_session_factory
|
||||
user_id = await _make_funded_user(session_factory, 50, BET_AMOUNT_SATS)
|
||||
client = FakeElectrumClient()
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(WithdrawalError) as excinfo:
|
||||
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS)
|
||||
assert excinfo.value.code == "amount_below_minimum"
|
||||
assert excinfo.value.params == {"minimum_sats": BET_AMOUNT_SATS}
|
||||
assert client.broadcasted == []
|
||||
|
||||
|
||||
async def test_a_bet_covered_only_by_unconfirmed_change_says_wait_not_insufficient(wallet_session_factory):
|
||||
"""BET-07 / DEP-13: the balance on screen covers the bet, the confirmed one does not."""
|
||||
session_factory = wallet_session_factory
|
||||
user_id = await _make_funded_user(session_factory, 51, 3_000_000_000)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
await request_withdrawal(session, FakeElectrumClient(), user, EXTERNAL_ADDRESS, 1_200_000_000)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 0
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, FakeElectrumClient(), user)
|
||||
assert excinfo.value.code == "balance_pending_confirmation"
|
||||
assert excinfo.value.params["pending_sats"] > 0
|
||||
|
||||
|
||||
async def test_a_bet_with_no_money_at_all_is_still_insufficient(wallet_session_factory):
|
||||
session_factory = wallet_session_factory
|
||||
user_id = await _make_funded_user(session_factory, 52, 100_000_000)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, FakeElectrumClient(), user)
|
||||
assert excinfo.value.code == "insufficient_balance"
|
||||
|
||||
|
||||
# --- TX-16 / TX-18 / TX-27 -----------------------------------------------------------
|
||||
|
||||
|
||||
async def _events(session_factory):
|
||||
async with session_factory() as session:
|
||||
return [e.event_type for e in (await session.scalars(select(AuditLog).order_by(AuditLog.id))).all()]
|
||||
|
||||
|
||||
async def test_a_recovered_bet_gets_the_event_its_send_would_have_written(reconcile_session_factory):
|
||||
"""TX-16 / AUD-30: pending_tx_recovered alone left a recovered bet out of the audit trail."""
|
||||
session_factory = reconcile_session_factory
|
||||
await _seed_bet(session_factory, pending_status="building", participant_status="building", age_seconds=300)
|
||||
await reconcile_once(session_factory, KnownTxClient("betxid"))
|
||||
assert sorted(await _events(session_factory)) == ["bet_placed", "pending_tx_recovered"]
|
||||
async with session_factory() as session:
|
||||
placed = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "bet_placed"))).one()
|
||||
assert json.loads(placed.payload_json) == {"txid": "betxid", "amount_sats": 999_000_000}
|
||||
assert placed.user_id is not None and placed.round_id == 1
|
||||
|
||||
|
||||
async def test_a_recovered_withdrawal_and_payout_get_theirs_too(reconcile_session_factory):
|
||||
from app.tx.reconcile import _promote
|
||||
|
||||
session_factory = reconcile_session_factory
|
||||
async with session_factory() as session:
|
||||
withdrawal = Withdrawal(
|
||||
user_id=1, external_address=EXTERNAL_ADDRESS, amount_requested_sats=5, amount_sent_sats=4, txid="w", status="building"
|
||||
)
|
||||
round_ = Round(status="paying_out", winner_user_id=7, winner_amount_sats=700, fee_amount_sats=300)
|
||||
session.add_all([withdrawal, round_])
|
||||
await session.flush()
|
||||
w_row = PendingTransaction(kind="withdrawal", withdrawal_id=withdrawal.id, user_id=1, current_txid="w", fee_rate_sat_vb=1, raw_tx_hex="", status="building")
|
||||
p_row = PendingTransaction(kind="payout", round_id=round_.id, current_txid="p", fee_rate_sat_vb=1, raw_tx_hex="", status="building")
|
||||
session.add_all([w_row, p_row])
|
||||
await session.flush()
|
||||
await _promote(session, w_row)
|
||||
await _promote(session, p_row)
|
||||
await session.commit()
|
||||
rows = {e.event_type: e for e in (await session.scalars(select(AuditLog))).all()}
|
||||
assert json.loads(rows["withdrawal_sent"].payload_json) == {
|
||||
"txid": "w", "amount_sent_sats": 4, "external_address": EXTERNAL_ADDRESS,
|
||||
}
|
||||
assert json.loads(rows["payout_sent"].payload_json) == {"txid": "p", "winner_sats": 700, "commission_sats": 300}
|
||||
assert rows["payout_sent"].user_id == 7
|
||||
|
||||
|
||||
async def test_an_abandoned_payout_leaves_no_amounts_behind(reconcile_session_factory):
|
||||
"""TX-18 / DAT-22."""
|
||||
from app.tx.reconcile import _abandon
|
||||
|
||||
session_factory = reconcile_session_factory
|
||||
async with session_factory() as session:
|
||||
round_ = Round(
|
||||
status="paying_out", payout_txid="p", winner_amount_sats=700, fee_amount_sats=300, bump_reserve_sats=50
|
||||
)
|
||||
session.add(round_)
|
||||
await session.flush()
|
||||
row = PendingTransaction(kind="payout", round_id=round_.id, current_txid="p", fee_rate_sat_vb=1, raw_tx_hex="", status="pending")
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
await _abandon(session, row, "no server knows it")
|
||||
await session.commit()
|
||||
await session.refresh(round_)
|
||||
assert (round_.payout_txid, round_.winner_amount_sats, round_.fee_amount_sats, round_.bump_reserve_sats) == (
|
||||
None, None, None, None,
|
||||
)
|
||||
|
||||
|
||||
async def test_the_reconciler_only_pings_the_dashboards_for_rows_it_changed(reconcile_session_factory, monkeypatch):
|
||||
"""TX-27: a row found present and already `pending` is examined, not changed."""
|
||||
session_factory = reconcile_session_factory
|
||||
await _seed_bet(session_factory, pending_status="pending", participant_status="broadcast", age_seconds=30 * 3600)
|
||||
pings = []
|
||||
monkeypatch.setattr(broadcaster, "publish", lambda: pings.append(1))
|
||||
assert await reconcile_once(session_factory, KnownTxClient("betxid")) == 0
|
||||
assert pings == []
|
||||
|
||||
|
||||
# --- TX-29 / AUD-46 ------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _stalled_rows(session_factory):
|
||||
async with session_factory() as session:
|
||||
return list(
|
||||
(await session.scalars(select(AuditLog).where(AuditLog.event_type == "closing_stalled").order_by(AuditLog.id))).all()
|
||||
)
|
||||
|
||||
|
||||
async def test_closing_stalled_is_written_past_the_threshold_and_repeats_every_30_minutes(scheduler_session_factory):
|
||||
session_factory = scheduler_session_factory
|
||||
round_id = await _seed_closing_round(session_factory, n=2)
|
||||
async with session_factory() as session:
|
||||
for participant in (await session.scalars(select(RoundParticipant))).all():
|
||||
participant.status = "broadcast"
|
||||
await session.commit()
|
||||
scheduler = RoundScheduler(session_factory, FakeListener())
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS - 60))
|
||||
assert await _stalled_rows(session_factory) == [] # not long enough yet
|
||||
|
||||
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 60))
|
||||
(row,) = await _stalled_rows(session_factory)
|
||||
payload = json.loads(row.payload_json)
|
||||
assert payload["in_flight"] == 2 and len(payload["txids"]) == 2 and payload["seconds_past_deadline"] >= 1860
|
||||
assert row.round_id == round_id and row.user_id is None
|
||||
|
||||
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 120))
|
||||
assert len(await _stalled_rows(session_factory)) == 1 # the last row is the clock: not again yet
|
||||
|
||||
async with session_factory() as session:
|
||||
last = await session.get(AuditLog, row.id)
|
||||
last.created_at = last.created_at - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 1)
|
||||
await session.commit()
|
||||
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=2 * CLOSING_STALL_THRESHOLD_SECONDS))
|
||||
assert len(await _stalled_rows(session_factory)) == 2
|
||||
|
||||
|
||||
# --- DEP-15 / DEP-16 / BET-02 / WDR-01 ------------------------------------------------
|
||||
|
||||
|
||||
async def test_refreshes_of_one_user_run_one_at_a_time_and_other_users_in_parallel(scheduler_session_factory):
|
||||
from app.electrum.listener import ElectrumListener
|
||||
|
||||
listener = ElectrumListener(lambda endpoint: None, scheduler_session_factory)
|
||||
running, peak, order = {}, {}, []
|
||||
|
||||
async def fake_locked(user_id, scripthash):
|
||||
running[user_id] = running.get(user_id, 0) + 1
|
||||
peak[user_id] = max(peak.get(user_id, 0), running[user_id])
|
||||
order.append((user_id, scripthash))
|
||||
await asyncio.sleep(0.01)
|
||||
running[user_id] -= 1
|
||||
|
||||
listener._refresh_user_locked = fake_locked
|
||||
await asyncio.gather(
|
||||
listener.refresh_user(1, "a"), listener.refresh_user(1, "b"), listener.refresh_user(2, "c")
|
||||
)
|
||||
assert peak == {1: 1, 2: 1}
|
||||
assert [s for u, s in order if u == 1] == ["a", "b"] # in order of arrival
|
||||
|
||||
|
||||
async def test_one_users_failed_refresh_does_not_end_the_session(scheduler_session_factory):
|
||||
from app.electrum.listener import ElectrumListener
|
||||
|
||||
listener = ElectrumListener(lambda endpoint: None, scheduler_session_factory)
|
||||
listener._scripthash_to_user = {"s1": 1, "s2": 2}
|
||||
calls = []
|
||||
|
||||
async def fake_refresh(user_id, scripthash):
|
||||
calls.append(user_id)
|
||||
if user_id == 1:
|
||||
raise RuntimeError("duplicate txid/vout")
|
||||
|
||||
listener.refresh_user = fake_refresh
|
||||
queue = asyncio.Queue()
|
||||
for item in (("s1", None), ("s2", None)):
|
||||
queue.put_nowait(item)
|
||||
task = asyncio.create_task(listener._consume_scripthash(queue))
|
||||
await asyncio.sleep(0.05)
|
||||
assert calls == [1, 2] and not task.done()
|
||||
task.cancel()
|
||||
|
||||
|
||||
async def test_the_connection_is_read_after_the_user_lock_is_taken():
|
||||
"""BET-02 / WDR-01: a request queued on the lock across a reconnection must use the
|
||||
connection that exists when it gets the lock, not the one it saw on arrival."""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.api.routes import bets as bets_route
|
||||
|
||||
listener = SimpleNamespace(client="old")
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(_user_id):
|
||||
listener.client = None # the connection dropped while this request waited
|
||||
yield
|
||||
|
||||
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(electrum_listener=listener, user_locks=SimpleNamespace(acquire=acquire))))
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await bets_route.create_bet(request, SimpleNamespace(), SimpleNamespace(id=1), None)
|
||||
assert excinfo.value.detail["code"] == "network_unavailable"
|
||||
@@ -176,3 +176,16 @@ async def test_me_created_at_is_utc_stamped(client):
|
||||
assert resp.status_code == 200
|
||||
created_at = resp.json()["created_at"]
|
||||
assert created_at.endswith("+00:00") or created_at.endswith("Z")
|
||||
|
||||
|
||||
async def test_me_shows_waiting_deposits_in_the_pending_balance_only(client): # B-82 (DEP-12 (b), DEP-14)
|
||||
token = await _register(client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me = (await client.get("/users/me", headers=headers)).json()
|
||||
assert (me["balance_sats"], me["pending_balance_sats"], me["has_pending"]) == (0, 0, False)
|
||||
|
||||
listener = client._transport.app.state.electrum_listener
|
||||
listener._pending_deposits[me["id"]] = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 5, "value": 250_000_000}]
|
||||
shown = (await client.get("/users/me", headers=headers)).json()
|
||||
assert shown["balance_sats"] == 0 # not spendable
|
||||
assert shown["pending_balance_sats"] == 250_000_000 and shown["has_pending"] is True
|
||||
Reference in new issue
Block a user