Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
526a649c8b | ||
|
|
666cb1a0c9 | ||
|
|
162ceed40f | ||
|
|
5f6abe5b32 | ||
|
|
23d58796b6 | ||
|
|
c4b2dc3ea2 | ||
|
|
c0314e2bf0 | ||
|
|
8a0ebecfcc | ||
|
|
8dd913ec59 | ||
|
|
37cc5eeeb5 | ||
|
|
77e07e87dc | ||
|
|
6246b13247 | ||
|
|
0aac73e557 | ||
|
|
57721355f0 | ||
|
|
ab65728bdc | ||
|
|
9c7befe595 | ||
|
|
421fe72a8b | ||
|
|
907e32e9e0 | ||
|
|
8f3cdcb2f8 | ||
|
|
64f62291d2 | ||
|
|
025754c860 | ||
|
|
99d7a1ee00 | ||
|
|
e5af15087c | ||
|
|
a384b08044 | ||
|
|
ee4e845c89 | ||
|
|
977bb762c7 | ||
|
|
fe909bedcf | ||
|
|
9207bbcb8f |
@@ -0,0 +1,67 @@
|
||||
# BUGS.md — audit of 2026-08-03
|
||||
|
||||
Third full-codebase audit, opened after the 2026-07-26 (B-01 … B-24) and
|
||||
2026-07-27 (B-25 … B-49) lists were emptied. Numbering continues from the last
|
||||
fixed finding, B-51.
|
||||
|
||||
The list opened at B-52 … B-72 and holds only what is still **open**: a finding is
|
||||
removed from this file once it is fixed, and is not listed here afterwards. Per
|
||||
CLAUDE.md's convention each entry gets its own commit with its own regression test,
|
||||
and the `B-nn` marker goes in a comment next to the fix, so
|
||||
`git log --all --grep 'B-nn'` is the record of how any closed finding was closed.
|
||||
|
||||
State of the tree at audit time: 264 unit tests, all passing; `tests/integration/`
|
||||
still empty; withdrawal and the RBF bump path still never live-broadcast.
|
||||
|
||||
Verified as *not* broken while looking for these: i18n key parity (146 identical
|
||||
keys across all 7 languages), HTML escaping of every user-controlled value in
|
||||
`admin.js`, the Alembic chain (linear, single head, matching `models.py`),
|
||||
strictly-integer satoshi arithmetic everywhere, and `.gitignore` coverage of
|
||||
secrets/DB/logs (nothing sensitive is tracked in git).
|
||||
|
||||
Severity is about consequence, not likelihood:
|
||||
**critical** = money stuck or lost, or the lottery stops;
|
||||
**high** = a security control that does not hold;
|
||||
**medium** = wrong behaviour with a bounded blast radius;
|
||||
**low** = drift between documentation and code.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### (not new) `drawing` does not resume after a restart
|
||||
|
||||
Already tracked as an accepted gap in CLAUDE.md's "Known gaps", not re-numbered
|
||||
here. Worth restating in context: with `restart: unless-stopped` on the app
|
||||
container, this is the one state that gets stuck with money in play, and it
|
||||
remains the last prerequisite for running unattended.
|
||||
|
||||
---
|
||||
|
||||
## Low — documentation and consistency drift
|
||||
|
||||
### B-71 — `.env` points `MASTER_KEY_PATH` at a second copy of the master key
|
||||
|
||||
CLAUDE.md's deployment section prescribes pointing `MASTER_KEY_PATH` at the
|
||||
host-side `./data/keys/master.xprv.enc` so the venv scripts and the container read
|
||||
one file. `.env` instead sets `./master.xprv.enc`, and both files now exist in the
|
||||
working tree (both gitignored). They were verified during this audit to decrypt to
|
||||
the *same* xprv, so nothing has diverged yet — but `scripts/decrypt_master_key.py`
|
||||
reads a different file from the one the container uses, and a future
|
||||
`generate_master_key.py --overwrite` would split them silently, with an ops
|
||||
recovery path that then reports the wrong key.
|
||||
|
||||
The same duplication exists for the database (`./plm_lottery.db` next to
|
||||
`data/db/plm_lottery.db`), which is less dangerous but equally confusing.
|
||||
|
||||
Fix: set `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, delete the stray
|
||||
root copy once confirmed redundant, and state the same for `DATABASE_URL`.
|
||||
|
||||
### B-72 — `docs/setup.md` still frames setup as "locally or via Docker"
|
||||
|
||||
`docs/setup.md:1-10` lists Python as a prerequisite "for the local/venv workflow"
|
||||
and describes the master-key step as local *or* Docker, while B-44 made Docker the
|
||||
only supported way to run the server (`docs/running-the-server.md` and README were
|
||||
updated, this file was not). The venv genuinely is needed for tests, migrations and
|
||||
the key scripts — the wording just needs to say that instead of implying a second
|
||||
way to run the server.
|
||||
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
||||
|
||||
## Project status
|
||||
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 253 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 351 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
|
||||
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
|
||||
|
||||
@@ -33,7 +33,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
|
||||
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
|
||||
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
|
||||
|
||||
python -m pytest # all 253 tests
|
||||
python -m pytest # all 351 tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
|
||||
```
|
||||
@@ -55,13 +55,13 @@ docker compose down
|
||||
|
||||
`SITE_ADDRESS` unset → `localhost`, Caddy issues a self-signed cert from its internal CA (browser warning on first visit is expected; `curl -k`). `SITE_ADDRESS=lottery.example.com docker compose up -d` → real Let's Encrypt cert, automatically renewed (needs DNS pointing here and ports 80+443 reachable).
|
||||
|
||||
The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and a CSP scoped to `default-src 'self'` plus the Google Fonts `@import` in `style.css`/`admin.css`. `script-src`/`style-src` need `'unsafe-inline'` because both SPAs use inline `onclick` handlers and `style=""` attributes throughout — removing those is a separate, larger refactor, not a header change. `restart: unless-stopped` on `app` means a mid-round crash auto-restarts: `closing` and `paying_out` resume on their own, `drawing` does not (see Known gaps).
|
||||
The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and a CSP scoped to `default-src 'self'` plus the Google Fonts `@import` in `style.css`/`admin.css`. It also overwrites `X-Forwarded-For` with the real peer (`header_up X-Forwarded-For {remote_host}`, B-54) — Caddy otherwise *appends* to whatever the client sent, which made every IP-keyed control (the B-33 throttles, B-38's SSE cap) bypassable; `app/api/client_ip.py` independently reads the *last* hop, so either half closes it. `script-src`/`style-src` need `'unsafe-inline'` because both SPAs use inline `onclick` handlers and `style=""` attributes throughout — removing those is a separate, larger refactor, not a header change. `restart: unless-stopped` on `app` means a mid-round crash auto-restarts: `closing` and `paying_out` resume on their own, `drawing` does not (see Known gaps).
|
||||
|
||||
## Tech stack
|
||||
|
||||
- Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite, `embit` for keys/PSBT/tx parsing.
|
||||
- **PLM access via the Electrum protocol only** (no full node/P2P). Dev bootstrap server: `santantonio.sytes.net:50002` (SSL).
|
||||
- Auth: Argon2 hashing + JWT (HS256, 24h, **no revocation** — B-34).
|
||||
- Auth: Argon2 hashing + JWT (HS256, 24h). Tokens **are** revocable (B-34): the token carries a `tv` claim, `User.token_version` is bumped by a self-service password change and by an admin reset, and `get_current_user`/`get_optional_user` reject any token whose `tv` no longer matches — so changing the password invalidates every session issued before it, instead of leaving them valid for up to `jwt_expire_minutes`. A token predating the claim decodes as `tv = 0`, which is what a migrated user starts at, so the deploy didn't log everyone out. Argon2 costs tens of ms of CPU per call by design, so every async caller goes through `hash_password_async`/`verify_password_async` (`run_in_threadpool`, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts.
|
||||
- Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or `XPRV_ENCRYPTION_KEY` is empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
|
||||
- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway.
|
||||
|
||||
@@ -84,28 +84,32 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
|
||||
|---|---|---|
|
||||
| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable |
|
||||
| Prize split | **70% winner / 30% fees**, rounding remainder to fees | **hardcoded** in `rounds/scheduler.py` — a code change, not an admin edit |
|
||||
| Round duration / cooldown | 600s / 30s | `RoundConfig` |
|
||||
| Round duration / cooldown | 600s / 30s | `RoundConfig`, but **snapshotted onto `Round.duration_seconds`/`cooldown_seconds` when a round opens** (B-61) — an edit applies from the next round, never to the one in progress |
|
||||
| Draw animation | 20s (cosmetic frontend minimum only) | `RoundConfig` |
|
||||
| Fee rate / RBF timeout | 1 sat/vB / 900s | `RoundConfig` |
|
||||
| Min withdrawal | = current `bet_amount_sats` (no separate field) | `withdrawals/service.py` |
|
||||
| Min deposit | none | — |
|
||||
| Min password length | 8 | `auth/security.py:MIN_PASSWORD_LENGTH` |
|
||||
| Confirmations, every tx kind | **1** | hardcoded in `tx/confirmation.py` |
|
||||
| Max inputs per tx | 50 (`MAX_TX_INPUTS`, B-48) — over it the build fails with `too_many_inputs`, it never spends more | hardcoded in `wallet/psbt_builder.py` |
|
||||
| Max inputs per *user* tx (bet, withdrawal) | 50 (`MAX_TX_INPUTS`, B-48) — over it the build fails with `too_many_inputs`, it never spends more | hardcoded in `wallet/psbt_builder.py` |
|
||||
| Max inputs per *payout* | 500 (`MAX_PAYOUT_TX_INPUTS`, B-52) — the pool holds one UTXO per bet, so reusing the user cap made any round past ~50 players unpayable | hardcoded in `wallet/psbt_builder.py` |
|
||||
| Max participants per round | 400 (`MAX_PARTICIPANTS_PER_ROUND`, B-52) — the 401st bet is refused with `round_full` *before* any money moves, so "a round can always be paid out" is an invariant rather than something discovered at payout time | hardcoded in `wallet/psbt_builder.py`, enforced in `bets/service.py` |
|
||||
|
||||
`GET /rounds/current`'s `jackpot_sats` is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual `bet_amount_sats` (each already net of its own bet fee) rather than `count × current bet amount` — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11).
|
||||
`GET /rounds/current`'s `jackpot_sats` is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual `bet_amount_sats` (each already net of its own bet fee) rather than `count × current bet amount` — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11). It counts **confirmed participants only** (B-65), matching what the draw picks from and what the payout can spend, with `pending_participant_count`/`pending_jackpot_sats`/`has_pending_bets` reporting the in-flight bets alongside — inclusive figures, not deltas, exactly like `pending_balance_sats` (see "Balance display"). `/`'s round card shows the confirmed numbers big and the difference as an amber "+N in attesa" suffix, so a player who just bet sees their own bet immediately without the advertised jackpot ever exceeding what will be paid.
|
||||
|
||||
**Round cooldown** (`round_cooldown_seconds`, not in the original flowchart): gap after a round closes before the next opens, so players can see the outcome.
|
||||
|
||||
**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner.
|
||||
|
||||
**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time.
|
||||
|
||||
## Code map
|
||||
|
||||
| Package | Contents |
|
||||
|---|---|
|
||||
| `app/main.py` | entry point: lifespan starts the six background tasks, mounts the routers and `app/static/` |
|
||||
| `app/api/routes/` | `admin`, `bets`, `withdrawals`, `rounds` (incl. SSE), `users`, `qr`; `app/api/errors.py` holds the error contract |
|
||||
| `app/auth/` | routes (register/login), Argon2 + JWT (`security.py`), `get_current_user`/`get_optional_user` |
|
||||
| `app/api/routes/` | `admin`, `bets`, `withdrawals`, `rounds` (incl. SSE), `users`, `qr`, `bug_reports`; `app/api/errors.py` holds the error contract and `app/api/client_ip.py` the trusted-peer extraction every IP-keyed control uses |
|
||||
| `app/auth/` | routes (register/login), Argon2 + JWT (`security.py`), `get_current_user`/`get_optional_user`, login/registration throttling (`rate_limit.py`) |
|
||||
| `app/db/` | `models.py` (all tables + the active-round index), engine/session factories |
|
||||
| `app/wallet/` | HD derivation + WIF export (`hd.py`), PLM network constants, address/scripthash, balance math, `psbt_builder.py` (build/sign bet, withdrawal, payout; `select_utxos`) |
|
||||
| `app/electrum/` | `client.py` (JSON-RPC, endpoint parsing, timeouts), `listener.py` (the one connection: rotation, keepalive, header validation, corroboration, deposit crediting) |
|
||||
@@ -128,7 +132,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
|
||||
| `PendingTransactionReconciler` | `tx/reconcile.py` | at startup, then 120s | resolves `building`/`pending` rows against the chain |
|
||||
| `DepositReconciler` | `deposits/reconcile.py` | 300s (sleeps first) | re-`refresh_user`s every address, catching a silently-lost subscription (B-30) |
|
||||
|
||||
Chain access goes through `listener.client`, passed as `lambda: listener.client` so a reconnect swaps the client under its consumers; a task finding it `None` skips that cycle instead of failing. `DepositReconciler` takes the whole listener instead, reusing `refresh_user` so the periodic and notification-driven paths can't diverge.
|
||||
Chain access goes through `listener.client`, passed as `lambda: listener.client` so a reconnect swaps the client under its consumers; a task finding it `None` skips that cycle instead of failing. **A non-null `client` means the tip is already known**: `_run_once` publishes it only after the first header has been applied (B-63), so `client is not None` can be read as "the chain is reachable *and* we know where it is" — `tip_height` is never the initial 0 behind a live client, which is what the draw depends on (see DRAW below). `DepositReconciler` takes the whole listener instead, reusing `refresh_user` so the periodic and notification-driven paths can't diverge.
|
||||
|
||||
## Electrum connection
|
||||
|
||||
@@ -137,8 +141,8 @@ One connection serves everything — deposit credits, broadcasts, confirmations,
|
||||
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
|
||||
- **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets` *while holding the per-user lock*, and could stall the confirmation poller permanently.
|
||||
- **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
|
||||
- **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner.
|
||||
- **A quorum corroborates the two money-moving decisions** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29). No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing.
|
||||
- **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. Two more headers are refused *without* ending the session, since neither implies a hostile server (B-64): one at a height we already hold a header for (a reorg at the tip, or one server disagreeing — the hash committed to for a height is never swapped under us, and `corroborate_header` is what catches us holding an orphan), and one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63.
|
||||
- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum. No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing. A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep.
|
||||
|
||||
On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep.
|
||||
|
||||
@@ -148,21 +152,21 @@ Diagrams: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifec
|
||||
|
||||
**REG** — on signup the server derives a P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from the encrypted master xprv. Permanent, and doubles as deposit address, winnings address and withdrawal change address.
|
||||
|
||||
**DEP** — the listener subscribes to the user's scripthash; balance is credited after **1 confirmation**, with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
|
||||
**DEP** — the listener subscribes to the user's scripthash; balance is credited after **1 confirmation** and only once the other servers corroborate the outpoint and its amount (B-59), with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
|
||||
|
||||
**PLAY** — fixed cost, **at most one active bet per user**. PSBT user-address → pool-address, always with a **change output back to the same user address** (a user's balance must never exactly equal the bet). Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
|
||||
**PLAY** — fixed cost, **at most one active bet per user**, and **at most `MAX_PARTICIPANTS_PER_ROUND` (400) players per round** — past that the bet is refused with `round_full` and the player waits for the next round (B-52: the payout must spend one pool UTXO per bet, so a round is only ever allowed to grow to what a single payout tx can drain). PSBT user-address → pool-address, always with a **change output back to the same user address** of at least `DUST_LIMIT_SATS` — a user's balance must never exactly equal the bet, and since B-62 that's enforced (`balance_leaves_no_change`) rather than assumed. Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
|
||||
|
||||
**DRAW** — configurable timer (default 600s):
|
||||
- *Bet cutoff is the round's own deadline* (`opened_at + round_duration_seconds`), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`.
|
||||
- *Bet cutoff is the round's own deadline* (`round_deadline` = `opened_at + Round.duration_seconds`, the value snapshotted at open time — B-61), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a **compare-and-set on the round row** (`UPDATE rounds ... WHERE status = 'open'`, B-53): the scheduler flips `open` → `closing` in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only `confirmed` participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is `_close_and_draw` re-counting in-flight bets in the same session it snapshots the participants from.
|
||||
- *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
|
||||
- *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount.
|
||||
- *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount. The baseline the draw compares against (`tip_at_close`) must be a height we actually knew at closing time: a `0` there means *unknown*, not "the chain is at zero", so `_wait_for_next_block` adopts the first height it then learns as the baseline and waits for a block strictly after it (`draw_baseline_tip_unknown`, B-63) — seeding from a block that already existed while bets were open would make the winner predictable to whoever was watching the chain.
|
||||
- *Payout* is signed with the pool key; its **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
|
||||
- *UI, two independent layers.* A generic phase box ("Pagamento al vincitore in corso…") shows to **every** viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by `status`. **Additively**, a personalized "Hai vinto!/Non hai vinto" box appears only where `user_played` is true (computed via `get_optional_user`, since the endpoint is reachable logged-out) — nobody else has anything to reveal.
|
||||
- *Reveal timing.* Delayed by at least `draw_animation_seconds`, anchored to the server's `closes_at` so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for `winner_user_id`. Once revealed it's persisted in `localStorage.plm_persisted_result`, surviving the move to `closed` — at which point `get_active_round` stops returning the round and `winner_user_id` disappears from `GET /rounds/current`. `GET /users/me/last-round-result` is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: `refreshRound`/`checkLastRoundResult` in `app/static/app.js`.
|
||||
|
||||
**WITHDRAW** — the only way out to an external address: PSBT user-address → external + change back to the user, fee deducted from the withdrawn amount, same RBF pattern.
|
||||
**WITHDRAW** — the only way out to an external address: PSBT user-address → external + change back to the user, fee deducted from the withdrawn amount, same RBF pattern. A full-balance withdrawal moves `balance - DUST_LIMIT_SATS` so the change output (and with it the ability to fee-bump) always exists — `Withdrawal.amount_requested_sats` vs `amount_sent_sats` is what records the difference (B-62).
|
||||
|
||||
PLAY and WITHDRAW share a **per-user lock** (`tx/locks.py`): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set.
|
||||
PLAY and WITHDRAW share a **per-user lock** (`tx/locks.py`): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set. That is the *only* mutual exclusion between them (B-70): a withdrawal is accepted while a bet is still unconfirmed, as long as confirmed, unspent UTXOs cover it — the lock plus `select_utxos` skipping anything already marked `spent_txid` is what prevents the two from picking the same input, so freezing the rest of the balance for a block on top of that would restrict the user without protecting anything.
|
||||
|
||||
**Three separate on-chain confirmations sit between the timer hitting zero and the payout landing** — a common point of confusion:
|
||||
1. **Last bet's confirmation** — the round doesn't even flip to `"closing"` until every broadcast bet has 1 conf (`_tick`'s `pending_count` check). May already have happened before the deadline.
|
||||
@@ -206,16 +210,18 @@ Everything that spends money is written **before** it is broadcast and resolved
|
||||
Two static SPAs served directly by FastAPI (`main.py` mounts `app/static/` and adds routes for `/admin`, `/guida`, `/report-bug`) — no build step, no framework, no bundler, `Cache-Control: no-store`.
|
||||
|
||||
- **`/`** — end-user test UI: register/login, then a navbar dashboard with four panels (Deposito with a QR from `GET /qr/{address}`, Bet, Prelievo, Profilo — account info + self-service password change via `POST /users/me/change-password`), above a persistent round-status card and the chain-status bar with the language switcher.
|
||||
- **`/admin`** — gated by a token screen (not a login: just `X-Admin-Token` vs `ADMIN_TOKEN`), then five sections each backed by its own `/admin/*` endpoint: Parametri (`RoundConfig` + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log; plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction.
|
||||
- **`/admin`** — gated by a token screen (not a login: just `X-Admin-Token` vs `ADMIN_TOKEN`), then six sections each backed by its own `/admin/*` endpoint: Parametri (`RoundConfig` + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log, Bug report (triage `open` → `read` → `resolved`, audit-logged `bug_report_status_changed`); plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction.
|
||||
- **`/report-bug`** — standalone page (no navbar, own language switcher), reachable logged-in or logged-out: `POST /bug-reports` stores the report with the submitter attached when there is one, `GET /bug-reports/mine` is the reporter-side status view for the logged-in case, and `/admin`'s Bug report section is the triage end.
|
||||
|
||||
Both talk to the same JSON API; there's no admin/user API split beyond `require_admin`.
|
||||
|
||||
## Internationalization (`/` only)
|
||||
## Internationalization (`/` and `/report-bug`)
|
||||
|
||||
`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is always available. Language: `localStorage.plm_lang` → `navigator.language` → `en`. The switcher sits in the **chain-bar, not the navbar**, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it.
|
||||
`app/static/i18n.js` holds every user-facing string of `/` and `/report-bug` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch. `/` loads it before `app.js`; `/report-bug` loads it before its own inline script — either way `t()` is always available by the time it's called. Language: `localStorage.plm_lang` → `navigator.language` → `en`, shared across both pages since they read/write the same `localStorage` key. On `/` the switcher sits in the **chain-bar, not the navbar**, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it. `/report-bug` has no navbar at all, so its switcher is just a top-right bar of its own.
|
||||
|
||||
- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`) via `applyStaticTranslations(root?)`; anything rendered from server data uses `t()` in `app.js` and is re-rendered by `onLanguageChange()`. An element belongs to one camp or the other, **never both**, or the two mechanisms overwrite each other — that's why `#bet-btn` has no `data-i18n`: its label carries the configurable bet amount, so `renderBetButton()` owns it.
|
||||
- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`) via `applyStaticTranslations(root?)`; anything rendered from server data uses `t()` directly (`app.js`'s `onLanguageChange()`, `report-bug.html`'s own inline equivalent) and is re-rendered on a language switch. An element belongs to one camp or the other, **never both**, or the two mechanisms overwrite each other — that's why `#bet-btn` has no `data-i18n`: its label carries the configurable bet amount, so `renderBetButton()` owns it.
|
||||
- **Every language must have exactly the same key set.** There is no fallback beyond `en`; a missing key renders as the raw key string.
|
||||
- `/report-bug`'s `bugReport.englishNotice` string is itself translated into all 7 languages — it just always *says*, in whichever language the visitor reads, to write the actual bug description in English (so the admin panel, which is Italian-operator-facing and untranslated, doesn't end up with reports in 7 different languages).
|
||||
- `/admin` is intentionally untranslated (operator-facing, Italian), as is `/guida`.
|
||||
|
||||
**API error contract** (`app/api/errors.py`) — the API is single-language by design. Failures answer with a structured `detail`: `{"code", "message", "params"}`, where `message` is English for non-dashboard consumers and `code` is what the frontend maps to `error.<code>` in `i18n.js` (falling back to `message` for an unknown code). `BetError`/`WithdrawalError` subclass `ApiError` and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (`internal_error`), so clients never special-case unexpected errors, and the exception text stays in `logs/app.log`. Adding a user-facing error: give it a code, add `error.<code>` to all 7 languages, and pass values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling) instead of baking them into English text.
|
||||
@@ -229,6 +235,7 @@ Explicit design choices, not derivable from any single file — respect them:
|
||||
- **1 confirmation** for every tx kind. Don't introduce differing thresholds (3, 6, …) without an explicit decision.
|
||||
- The draw algorithm is a **replaceable component**, not the final design — don't architect around its current form.
|
||||
- `GET /admin/users/{id}/privkey` exporting a raw WIF is **intentional**, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes `admin_privkey_accessed` — don't remove that logging.
|
||||
- **Usernames are case-insensitive** (B-57): one namespace, enforced by a unique index on `lower(username)` (`app/db/models.py`) and matched with `func.lower(...)` on both register and login. The name is still *stored* as typed — that's what `/admin` and the audit log show. The migration refuses to run if two existing accounts differ only by case, rather than guessing which one to rename: both may hold funds.
|
||||
- Argon2 hashing means **no password recovery, only reset**: `POST /admin/users/{id}/reset-password` sets a new random password, returns it once for the operator to relay, and logs `admin_password_reset`. No self-service reset exists (no email is ever collected); a logged-in user can only *change* their password by supplying the current one.
|
||||
- RBF bumps are paid by whoever's change the tx pays back to — the user for bets/withdrawals, the pool for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only the sender's own change shrinks (`bump_fee`).
|
||||
|
||||
@@ -237,11 +244,11 @@ Explicit design choices, not derivable from any single file — respect them:
|
||||
Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here.
|
||||
|
||||
- **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_wait_for_next_block` after a crash. That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36). Restart-resumption itself remains the last prerequisite for running unattended.
|
||||
- **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback — an exact-amount tx or too-small change raises `RbfError`. Not permanent, though: an unbumpable tx that never confirms is eventually abandoned and its UTXOs released.
|
||||
- **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why `build_signed_transaction` now guarantees a change output of at least `DUST_LIMIT_SATS` instead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused with `balance_leaves_no_change`. What's left is a bump whose *delta* exceeds an otherwise-fine change output, which still raises `RbfError`; that tx is eventually abandoned and its UTXOs released.
|
||||
- **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network).
|
||||
- **No user-facing history.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
|
||||
- **No user-facing history of rounds or transactions.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above) and `GET /bug-reports/mine` one more (the reporter's own reports). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
|
||||
- **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
|
||||
- **No rate limiting anywhere** (register, bet, withdrawal, admin, SSE). For login this is a blocker, not a gap — tracked as B-33.
|
||||
- **`/guida` and `/report-bug` are placeholders** (`app/static/guida.html`, `report-bug.html`) — links work, content is "coming soon".
|
||||
- **No rate limiting on bet, withdrawal, admin or SSE.** Login has a per-username + per-IP failure throttle and registration a per-IP quota of 5 accounts/hour (`app/auth/rate_limit.py`: `RateLimiter` for failed guesses at a secret, `RollingQuota` for "how many of these may one source create" — B-33, B-58); everything else is unlimited.
|
||||
- **`/guida` is a placeholder** (`app/static/guida.html`) — the link works, the content is "coming soon". `/report-bug` is *not*: it is fully implemented and translated, with admin triage (see Frontends).
|
||||
- **No integration tests against a live Electrum connection.** `tests/integration/` is empty; live verification has all been manual (`scripts/electrum_smoke_test.py`, ad hoc scripts, real mainnet txs).
|
||||
- **Single-process assumptions**: the SSE broadcaster and the per-user locks are in-process only. A multi-worker deployment needs a shared channel and a DB/Redis lock. The round-uniqueness invariant is *not* in this category — it's a DB index.
|
||||
|
||||
@@ -29,5 +29,15 @@
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
|
||||
}
|
||||
|
||||
reverse_proxy app:8123
|
||||
# B-54: Caddy *appends* the real peer address to whatever X-Forwarded-For the
|
||||
# client sent, so without this the header arrives as "<whatever the client
|
||||
# claimed>, <real ip>" and every IP-keyed control in the app (the login and
|
||||
# registration throttles of B-33, the SSE per-IP subscriber cap of B-38) is
|
||||
# defeated by simply rotating a fake value per request. Overwriting the header
|
||||
# with the actual peer makes the app's assumption true at the source; it also
|
||||
# reads the last hop rather than the first (app/api/client_ip.py), so the two
|
||||
# defences hold independently.
|
||||
reverse_proxy app:8123 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,9 @@ domain).
|
||||
## Documentation
|
||||
|
||||
- [CLAUDE.md](CLAUDE.md) — architecture, commands, domain decisions, known gaps (for anyone/anything working on the code)
|
||||
- [flowchart.mmd](flowchart.mmd) — the source-of-truth flow diagram the implementation follows node-by-node
|
||||
- [flowchart/](flowchart/) — the source-of-truth flow diagrams the implementation follows node-by-node: [platform-overview.mmd](flowchart/platform-overview.mmd) (the 5-phase flow) and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) (the round/draw lifecycle)
|
||||
- [docs/setup.md](docs/setup.md) — one-time setup (secrets, master key, migrations)
|
||||
- [docs/running-the-server.md](docs/running-the-server.md) — how to launch it (local venv vs. Docker+Caddy, dev vs. production TLS)
|
||||
- [docs/running-the-server.md](docs/running-the-server.md) — how to launch it with Docker+Caddy (dev vs. production TLS)
|
||||
- [docs/guida-utente.md](docs/guida-utente.md) — end-user guide to the test UI (Italian)
|
||||
- [docs/guida-admin.md](docs/guida-admin.md) — admin dashboard guide (Italian)
|
||||
|
||||
@@ -51,7 +51,7 @@ domain).
|
||||
|
||||
Python (FastAPI, SQLAlchemy async + Alembic, Argon2 + JWT auth), Electrum
|
||||
protocol for PLM network access (no full node), Docker + Caddy for
|
||||
deployment. See [CLAUDE.md](CLAUDE.md#tech-stack-mvp) for the complete list
|
||||
deployment. See [CLAUDE.md](CLAUDE.md#tech-stack) for the complete list
|
||||
and the reasoning behind each choice.
|
||||
|
||||
## Testing
|
||||
@@ -61,7 +61,7 @@ python -m pytest # all tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
```
|
||||
|
||||
232 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
351 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
|
||||
the pending-inclusive balance calculation, and the SSE push channel. No
|
||||
automated integration tests against a live Electrum connection — mainnet
|
||||
|
||||
+15
-1
@@ -10,8 +10,22 @@ def client_ip(request: Request) -> str:
|
||||
Shared by the login/registration throttles (B-33) and the SSE per-IP
|
||||
subscriber cap (B-38) so the two can't drift into different notions of
|
||||
"the client's IP".
|
||||
|
||||
B-54: the *last* element, not the first. A proxy appends the address it saw
|
||||
to any X-Forwarded-For the client already sent, so the first element is
|
||||
attacker-controlled — with the header read from the front, rotating a fake
|
||||
value per request gave every request a fresh identity and turned all three
|
||||
IP-keyed controls above into decoration. The last element is the one written
|
||||
by the hop closest to us, i.e. by our own proxy. Exactly one trusted proxy
|
||||
sits in front of this app (Caddy, see docker-compose.yml, where `app` is
|
||||
only `expose`d on the compose network and never published to the host), so
|
||||
the last element is the real peer. The Caddyfile now also overwrites the
|
||||
header with `header_up X-Forwarded-For {remote_host}`, which collapses it to
|
||||
a single value — belt and braces: either fix alone closes B-54.
|
||||
"""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
hops = [hop.strip() for hop in forwarded.split(",") if hop.strip()]
|
||||
if hops:
|
||||
return hops[-1]
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
+87
-3
@@ -8,9 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.audit.log import write_audit_log
|
||||
from app.auth.security import hash_password
|
||||
from app.auth.security import hash_password_async
|
||||
from app.config import settings
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, User
|
||||
from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
|
||||
from app.db.session import get_session
|
||||
from app.rounds.config import get_round_config
|
||||
from app.wallet.address import is_valid_plm_address
|
||||
@@ -217,7 +217,7 @@ async def reset_user_password(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
|
||||
new_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.password_hash = await hash_password_async(new_password)
|
||||
# B-34: this endpoint exists precisely for the "account compromised" case —
|
||||
# without bumping token_version, whoever was already logged in (the
|
||||
# attacker, if that's who prompted the reset) stayed logged in on their
|
||||
@@ -346,3 +346,87 @@ async def list_pending_transactions(
|
||||
)
|
||||
for p in entries
|
||||
]
|
||||
|
||||
|
||||
_BUG_REPORT_STATUSES = ("open", "read", "resolved")
|
||||
|
||||
|
||||
class AdminBugReportResponse(BaseModel):
|
||||
id: int
|
||||
description: str
|
||||
contact: str | None
|
||||
user_id: int | None
|
||||
username: str | None
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
def _bug_report_response(report: BugReport, username: str | None) -> AdminBugReportResponse:
|
||||
return AdminBugReportResponse(
|
||||
id=report.id,
|
||||
description=report.description,
|
||||
contact=report.contact,
|
||||
user_id=report.user_id,
|
||||
username=username,
|
||||
status=report.status,
|
||||
created_at=isoformat_utc(report.created_at),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/bug-reports", response_model=list[AdminBugReportResponse], dependencies=[Depends(require_admin)]
|
||||
)
|
||||
async def list_bug_reports(
|
||||
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
|
||||
) -> list[AdminBugReportResponse]:
|
||||
reports = (await session.scalars(select(BugReport).order_by(BugReport.id.desc()).limit(limit))).all()
|
||||
user_ids = {r.user_id for r in reports if r.user_id is not None}
|
||||
usernames = {}
|
||||
if user_ids:
|
||||
users = (await session.scalars(select(User).where(User.id.in_(user_ids)))).all()
|
||||
usernames = {u.id: u.username for u in users}
|
||||
|
||||
return [
|
||||
_bug_report_response(r, usernames.get(r.user_id) if r.user_id is not None else None)
|
||||
for r in reports
|
||||
]
|
||||
|
||||
|
||||
class BugReportStatusUpdate(BaseModel):
|
||||
status: str = Field(pattern="^(open|read|resolved)$")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/bug-reports/{report_id}/status",
|
||||
response_model=AdminBugReportResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def update_bug_report_status(
|
||||
report_id: int, body: BugReportStatusUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> AdminBugReportResponse:
|
||||
report = await session.get(BugReport, report_id)
|
||||
if report is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found")
|
||||
|
||||
# B-60: every other admin mutation (config edit, pause/resume, privkey export,
|
||||
# password reset) leaves a trace; this one silently marked a report `resolved`.
|
||||
# With one shared ADMIN_TOKEN and no per-admin identity, the audit log is the
|
||||
# only accountability there is. Before/after like config_updated, and nothing
|
||||
# written when the status doesn't actually change — re-clicking the status a
|
||||
# report already has isn't an event.
|
||||
if report.status != body.status:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"bug_report_status_changed",
|
||||
{"report_id": report_id, "from": report.status, "to": body.status},
|
||||
user_id=report.user_id,
|
||||
)
|
||||
report.status = body.status
|
||||
await session.commit()
|
||||
|
||||
username = None
|
||||
if report.user_id is not None:
|
||||
user = await session.get(User, report.user_id)
|
||||
username = user.username if user is not None else None
|
||||
|
||||
return _bug_report_response(report, username)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.auth.dependencies import get_current_user, get_optional_user
|
||||
from app.db.models import BugReport, User
|
||||
from app.db.session import get_session
|
||||
|
||||
router = APIRouter(prefix="/bug-reports", tags=["bug-reports"])
|
||||
|
||||
|
||||
class BugReportCreate(BaseModel):
|
||||
description: str = Field(min_length=1, max_length=2000)
|
||||
contact: str | None = Field(default=None, max_length=256)
|
||||
|
||||
@field_validator("description")
|
||||
@classmethod
|
||||
def _description_not_blank(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("description must not be blank")
|
||||
return value
|
||||
|
||||
@field_validator("contact")
|
||||
@classmethod
|
||||
def _contact_stripped(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class BugReportResponse(BaseModel):
|
||||
id: int
|
||||
|
||||
|
||||
@router.post("", response_model=BugReportResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bug_report(
|
||||
body: BugReportCreate,
|
||||
user: User | None = Depends(get_optional_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> BugReportResponse:
|
||||
report = BugReport(
|
||||
description=body.description,
|
||||
contact=body.contact,
|
||||
user_id=user.id if user is not None else None,
|
||||
)
|
||||
session.add(report)
|
||||
await session.commit()
|
||||
return BugReportResponse(id=report.id)
|
||||
|
||||
|
||||
class MyBugReportResponse(BaseModel):
|
||||
id: int
|
||||
description: str
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get("/mine", response_model=list[MyBugReportResponse])
|
||||
async def list_my_bug_reports(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[MyBugReportResponse]:
|
||||
"""The one user-facing history view for bug reports (anonymous submissions have
|
||||
no user to attribute this to, so this only ever covers ones filed while logged in)."""
|
||||
reports = (
|
||||
await session.scalars(
|
||||
select(BugReport).where(BugReport.user_id == user.id).order_by(BugReport.id.desc())
|
||||
)
|
||||
).all()
|
||||
return [
|
||||
MyBugReportResponse(
|
||||
id=r.id, description=r.description, status=r.status, created_at=isoformat_utc(r.created_at)
|
||||
)
|
||||
for r in reports
|
||||
]
|
||||
+46
-10
@@ -1,22 +1,58 @@
|
||||
"""PNG QR codes for PLM addresses.
|
||||
|
||||
Deliberately unauthenticated: the dashboard renders it with a plain `<img>`
|
||||
tag, which cannot carry the bearer token, and the payload is an address the
|
||||
caller already has. What the endpoint must not be is a free CPU amplifier
|
||||
(B-67), so two things bound the work an anonymous caller can ask for:
|
||||
|
||||
- the address is validated for real (bech32 checksum + PLM HRP) via
|
||||
`is_valid_plm_address`, the same check withdrawals and the admin
|
||||
`fee_address` validator use, instead of a shape-only regex that happily
|
||||
rendered a QR for any `plm1`-prefixed junk string;
|
||||
- the render itself is memoized per address and pushed off the event loop, so
|
||||
a repeat request costs a dict lookup and a first one never blocks the
|
||||
scheduler, the listener or any other request.
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
import qrcode
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi import APIRouter
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.api.errors import http_error
|
||||
from app.wallet.address import is_valid_plm_address
|
||||
|
||||
router = APIRouter(tags=["qr"])
|
||||
|
||||
# PLM P2WPKH addresses: bech32 HRP "plm" + separator + witness program.
|
||||
_ADDRESS_RE = re.compile(r"^plm1[a-z0-9]{10,90}$")
|
||||
# Long enough for any bech32 address, short enough that a multi-kilobyte path
|
||||
# is rejected before embit ever looks at it.
|
||||
_MAX_ADDRESS_LENGTH = 100
|
||||
|
||||
# Bounded on purpose: valid addresses are cheap to generate, so an unbounded
|
||||
# cache would just move the amplification from CPU to memory.
|
||||
_CACHE_SIZE = 512
|
||||
|
||||
|
||||
@lru_cache(maxsize=_CACHE_SIZE)
|
||||
def _render_png(address: str) -> bytes:
|
||||
image = qrcode.make(address)
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@router.get("/qr/{address}")
|
||||
async def address_qr(address: str) -> Response:
|
||||
if not _ADDRESS_RE.match(address):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid address")
|
||||
if len(address) > _MAX_ADDRESS_LENGTH or not is_valid_plm_address(address):
|
||||
raise http_error(400, "invalid_address", "not a valid PLM bech32 address")
|
||||
|
||||
image = qrcode.make(address)
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return Response(content=buf.getvalue(), media_type="image/png")
|
||||
png = await run_in_threadpool(_render_png, address)
|
||||
# An address' QR never changes; let the browser stop asking for it.
|
||||
return Response(
|
||||
content=png,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "private, max-age=86400, immutable"},
|
||||
)
|
||||
|
||||
+48
-10
@@ -15,7 +15,7 @@ from app.db.models import RoundParticipant, User
|
||||
from app.db.session import get_session
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
|
||||
from app.rounds.service import get_active_round
|
||||
from app.rounds.service import get_active_round, round_deadline, rounds_can_open, winner_share
|
||||
|
||||
router = APIRouter(prefix="/rounds", tags=["rounds"])
|
||||
|
||||
@@ -91,9 +91,21 @@ class CurrentRoundResponse(BaseModel):
|
||||
status: str | None = None
|
||||
opened_at: str | None = None
|
||||
closes_at: str | None = None
|
||||
# B-65: confirmed participants only — the ones the draw actually picks from and
|
||||
# whose sats are actually in the pool. The pending_* pair below is the same
|
||||
# confirmed/in-flight split the balance already exposes (see
|
||||
# wallet/balance.py's balance_sats vs pending_balance_sats), and for the same
|
||||
# reason: the authoritative number must be the one that will be paid, while the
|
||||
# player who just bet still needs to see their own bet somewhere.
|
||||
participant_count: int = 0
|
||||
bet_amount_sats: int
|
||||
jackpot_sats: int = 0
|
||||
# Inclusive of bets still building/broadcast, exactly like pending_balance_sats
|
||||
# is inclusive of unconfirmed change — not deltas. Equal to the confirmed
|
||||
# figures above when nothing is in flight, which is what has_pending_bets says.
|
||||
pending_participant_count: int = 0
|
||||
pending_jackpot_sats: int = 0
|
||||
has_pending_bets: bool = False
|
||||
draw_animation_seconds: int
|
||||
winner_user_id: int | None = None
|
||||
winner_amount_sats: int | None = None
|
||||
@@ -105,6 +117,12 @@ class CurrentRoundResponse(BaseModel):
|
||||
draw_waiting_since: str | None = None
|
||||
chain_tip_height: int | None = None
|
||||
lottery_paused: bool = False
|
||||
# B-66: false while the instance is missing configuration a round cannot run
|
||||
# without (today: fee_address) — no round will open until it's set, so this is
|
||||
# the difference between "wait, the next round is coming" and "nothing is coming
|
||||
# until the operator finishes setting this up". Distinct from lottery_paused,
|
||||
# which is a deliberate operator action rather than an unmet prerequisite.
|
||||
lottery_configured: bool = True
|
||||
user_played: bool = False
|
||||
|
||||
|
||||
@@ -126,23 +144,39 @@ async def current_round(
|
||||
draw_animation_seconds=config.draw_animation_seconds,
|
||||
chain_tip_height=chain_tip_height,
|
||||
lottery_paused=config.paused,
|
||||
lottery_configured=rounds_can_open(config),
|
||||
)
|
||||
|
||||
participant_count = await session.scalar(
|
||||
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
|
||||
) or 0
|
||||
# The pool is the sum of what the participants' bets actually paid into the pool
|
||||
# address — each one is already net of that bet's network fee. Deriving it from
|
||||
# participant_count * the *current* bet_amount_sats instead overstated it, and
|
||||
# silently changed the advertised jackpot of a round in progress whenever an
|
||||
# operator edited the bet amount (B-11).
|
||||
pool_amount_sats = await session.scalar(
|
||||
select(func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0)).where(
|
||||
RoundParticipant.round_id == round_.id
|
||||
#
|
||||
# Split confirmed from in-flight (B-65): the draw only picks from confirmed
|
||||
# participants and the payout only spends their sats, so counting every row
|
||||
# advertised a jackpot larger than the one that would be paid, and made a
|
||||
# participant appear and then vanish again if their bet was later abandoned.
|
||||
counts = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(),
|
||||
func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0),
|
||||
func.count().filter(RoundParticipant.status == "confirmed"),
|
||||
func.coalesce(
|
||||
func.sum(RoundParticipant.bet_amount_sats).filter(
|
||||
RoundParticipant.status == "confirmed"
|
||||
),
|
||||
0,
|
||||
),
|
||||
).where(RoundParticipant.round_id == round_.id)
|
||||
)
|
||||
) or 0
|
||||
).one()
|
||||
all_count, all_pool_sats, participant_count, pool_amount_sats = counts
|
||||
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
|
||||
closes_at = opened_at + timedelta(seconds=config.round_duration_seconds)
|
||||
# B-61: from the round's own duration — the countdown clients are watching must
|
||||
# not jump because an operator edited the config mid-round.
|
||||
closes_at = round_deadline(round_)
|
||||
|
||||
# Lets the frontend show the personalized win/lose reveal only to players in
|
||||
# this round — everyone else (not logged in, or logged in but didn't bet)
|
||||
@@ -165,7 +199,7 @@ async def current_round(
|
||||
# upper bound by the payout tx's own fee, which is deducted from the winner's
|
||||
# share and isn't knowable until the payout is built — a few hundred sat on a
|
||||
# 1 sat/vB payout, i.e. invisible at PLM amounts, but it is not exact.
|
||||
jackpot_sats = pool_amount_sats * 70 // 100
|
||||
jackpot_sats = winner_share(pool_amount_sats)
|
||||
|
||||
return CurrentRoundResponse(
|
||||
server_time=datetime.now(timezone.utc).isoformat(),
|
||||
@@ -176,6 +210,9 @@ async def current_round(
|
||||
participant_count=participant_count,
|
||||
bet_amount_sats=config.bet_amount_sats,
|
||||
jackpot_sats=jackpot_sats,
|
||||
pending_participant_count=all_count,
|
||||
pending_jackpot_sats=winner_share(all_pool_sats),
|
||||
has_pending_bets=all_count > participant_count,
|
||||
draw_animation_seconds=config.draw_animation_seconds,
|
||||
winner_user_id=round_.winner_user_id,
|
||||
winner_amount_sats=round_.winner_amount_sats,
|
||||
@@ -184,5 +221,6 @@ async def current_round(
|
||||
draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None,
|
||||
chain_tip_height=chain_tip_height,
|
||||
lottery_paused=config.paused,
|
||||
lottery_configured=rounds_can_open(config),
|
||||
user_played=user_played,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.errors import http_error
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
|
||||
from app.auth.security import (
|
||||
MIN_PASSWORD_LENGTH,
|
||||
create_access_token,
|
||||
hash_password_async,
|
||||
verify_password_async,
|
||||
)
|
||||
from app.db.models import Round, RoundParticipant, User
|
||||
from app.db.session import get_session
|
||||
from app.wallet.balance import compute_pending_balance
|
||||
@@ -59,7 +64,7 @@ async def change_password(
|
||||
"""Self-service password change — requires the current password, unlike the
|
||||
admin-only /admin/users/{id}/reset-password (which is for a user who's
|
||||
actually locked out and can't provide it)."""
|
||||
if not verify_password(body.current_password, user.password_hash):
|
||||
if not await verify_password_async(body.current_password, user.password_hash):
|
||||
raise http_error(
|
||||
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
|
||||
)
|
||||
@@ -71,7 +76,7 @@ async def change_password(
|
||||
minimum=MIN_PASSWORD_LENGTH,
|
||||
)
|
||||
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
user.password_hash = await hash_password_async(body.new_password)
|
||||
# B-34: bumping token_version invalidates every token issued before this
|
||||
# point — including this very request's own bearer token, and any an
|
||||
# attacker who knew the old password might be holding. A fresh token is
|
||||
|
||||
+137
-2
@@ -1,6 +1,15 @@
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
# B-56: bounds on _buckets, which is keyed by strings the caller chooses.
|
||||
# 50k entries is a few MB at ~100 bytes each — far more than any real deployment's
|
||||
# active attacker set, and small enough that filling it isn't a memory attack.
|
||||
_MAX_BUCKETS = 50_000
|
||||
# How often record_failure sweeps out spent entries. Cheap (one pass over a dict
|
||||
# that the sweep itself keeps small) and off the request's critical path in the
|
||||
# normal case, since a successful login records no failure at all.
|
||||
_SWEEP_INTERVAL_SECONDS = 60.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Bucket:
|
||||
@@ -29,22 +38,65 @@ class RateLimiter:
|
||||
base_delay: float = 2.0,
|
||||
max_delay: float = 300.0,
|
||||
decay_seconds: float = 900.0,
|
||||
max_buckets: int = _MAX_BUCKETS,
|
||||
sweep_interval_seconds: float = _SWEEP_INTERVAL_SECONDS,
|
||||
) -> None:
|
||||
self._threshold = threshold
|
||||
self._base_delay = base_delay
|
||||
self._max_delay = max_delay
|
||||
self._decay_seconds = decay_seconds
|
||||
self._max_buckets = max_buckets
|
||||
self._sweep_interval_seconds = sweep_interval_seconds
|
||||
self._buckets: dict[str, _Bucket] = {}
|
||||
self._last_sweep_at = time.monotonic()
|
||||
|
||||
def _is_spent(self, bucket: _Bucket, now: float) -> bool:
|
||||
"""Nothing left to remember: the lockout has expired *and* the failure count
|
||||
would decay to zero on the next failure anyway. Dropping such a bucket is
|
||||
indistinguishable from keeping it — which is what makes eviction safe."""
|
||||
return bucket.locked_until <= now and now - bucket.last_failure_at > self._decay_seconds
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
"""B-56: the dict was keyed by attacker-chosen strings (any username, and via
|
||||
B-54 any IP) and only ever grew — `decay_seconds` aged a bucket's counter but
|
||||
never removed the entry, so hammering login with random usernames was an
|
||||
unbounded memory leak.
|
||||
|
||||
Spent buckets go first, and they carry no information, so that alone keeps
|
||||
the dict at the size of the genuinely active attack surface. The hard cap
|
||||
below is the backstop for a burst faster than the sweep interval: it evicts
|
||||
the entries closest to expiry, i.e. the ones whose loss buys an attacker the
|
||||
least — never the freshest lockouts, which are the ones actually holding an
|
||||
attack back."""
|
||||
for key in [k for k, b in self._buckets.items() if self._is_spent(b, now)]:
|
||||
del self._buckets[key]
|
||||
self._last_sweep_at = now
|
||||
|
||||
excess = len(self._buckets) - self._max_buckets
|
||||
if excess > 0:
|
||||
by_expiry = sorted(
|
||||
self._buckets.items(), key=lambda item: (item[1].locked_until, item[1].last_failure_at)
|
||||
)
|
||||
for key, _ in by_expiry[:excess]:
|
||||
del self._buckets[key]
|
||||
|
||||
def retry_after(self, key: str) -> float:
|
||||
bucket = self._buckets.get(key)
|
||||
if bucket is None:
|
||||
return 0.0
|
||||
remaining = bucket.locked_until - time.monotonic()
|
||||
now = time.monotonic()
|
||||
if self._is_spent(bucket, now):
|
||||
# Self-cleaning read path: a key that's merely being probed never
|
||||
# accumulates an entry that outlives its own usefulness.
|
||||
del self._buckets[key]
|
||||
return 0.0
|
||||
remaining = bucket.locked_until - now
|
||||
return remaining if remaining > 0 else 0.0
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
if now - self._last_sweep_at >= self._sweep_interval_seconds or len(self._buckets) > self._max_buckets:
|
||||
self._prune(now)
|
||||
bucket = self._buckets.setdefault(key, _Bucket())
|
||||
if bucket.failures and now - bucket.last_failure_at > self._decay_seconds:
|
||||
bucket.failures = 0
|
||||
@@ -58,6 +110,84 @@ class RateLimiter:
|
||||
self._buckets.pop(key, None)
|
||||
|
||||
|
||||
class RollingQuota:
|
||||
"""How many times a key may do something in a rolling window — as opposed to
|
||||
RateLimiter above, which punishes *failures* with a growing delay.
|
||||
|
||||
B-58: registration was throttled with the failure limiter, and recorded a
|
||||
"failure" on every attempt, successful ones included. Five legitimate signups
|
||||
from one shared or NAT address locked the sixth real user out for up to 600s,
|
||||
with the backoff doubling from there — while an attacker sidestepped the whole
|
||||
thing through B-54. The intent (bound how many accounts one source can create)
|
||||
is right; failure backoff is the wrong instrument for it, since nothing here is
|
||||
a failed guess at a secret. A quota says exactly what is meant: this many
|
||||
accounts per source per window, and the answer to the one over it is "not yet",
|
||||
with an accurate wait rather than a punishment that grows.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
limit: int,
|
||||
window_seconds: float,
|
||||
max_keys: int = _MAX_BUCKETS,
|
||||
sweep_interval_seconds: float = _SWEEP_INTERVAL_SECONDS,
|
||||
) -> None:
|
||||
self._limit = limit
|
||||
self._window_seconds = window_seconds
|
||||
self._max_keys = max_keys
|
||||
self._sweep_interval_seconds = sweep_interval_seconds
|
||||
self._events: dict[str, list[float]] = {}
|
||||
self._last_sweep_at = time.monotonic()
|
||||
|
||||
def _live_events(self, key: str, now: float) -> list[float]:
|
||||
"""The key's events still inside the window, pruned in place."""
|
||||
events = self._events.get(key)
|
||||
if events is None:
|
||||
return []
|
||||
cutoff = now - self._window_seconds
|
||||
while events and events[0] <= cutoff:
|
||||
events.pop(0)
|
||||
if not events:
|
||||
del self._events[key]
|
||||
return events
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
# Same bound as RateLimiter (B-56): the keys are caller-chosen, so the dict
|
||||
# needs both a sweep and a hard cap. Eviction order is likewise "closest to
|
||||
# leaving the window first" — dropping a key with room left in its quota
|
||||
# changes nothing, dropping a full one hands out free accounts.
|
||||
for key in list(self._events):
|
||||
self._live_events(key, now)
|
||||
self._last_sweep_at = now
|
||||
|
||||
excess = len(self._events) - self._max_keys
|
||||
if excess > 0:
|
||||
by_oldest = sorted(self._events.items(), key=lambda item: item[1][-1])
|
||||
for key, _ in by_oldest[:excess]:
|
||||
del self._events[key]
|
||||
|
||||
def retry_after(self, key: str) -> float:
|
||||
"""Seconds until this key may act again — 0 while it is under quota."""
|
||||
now = time.monotonic()
|
||||
events = self._live_events(key, now)
|
||||
if len(events) < self._limit:
|
||||
return 0.0
|
||||
return events[0] + self._window_seconds - now
|
||||
|
||||
def record(self, key: str) -> None:
|
||||
"""Counts one *completed* action. Attempts that create nothing (a taken
|
||||
username, a validation error) deliberately don't consume the quota — the
|
||||
limit is on accounts that exist, not on requests."""
|
||||
now = time.monotonic()
|
||||
if now - self._last_sweep_at >= self._sweep_interval_seconds or len(self._events) > self._max_keys:
|
||||
self._prune(now)
|
||||
self._events.setdefault(key, []).append(now)
|
||||
|
||||
|
||||
_REGISTRATIONS_PER_IP = 5
|
||||
_REGISTRATION_WINDOW_SECONDS = 3600.0
|
||||
|
||||
|
||||
class AuthRateLimiters:
|
||||
"""The three throttles B-33 needs, bundled so they can live on `app.state`
|
||||
(like `UserLocks`, see app/tx/locks.py) rather than as module globals.
|
||||
@@ -72,4 +202,9 @@ class AuthRateLimiters:
|
||||
def __init__(self) -> None:
|
||||
self.login = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0)
|
||||
self.login_ip = RateLimiter(threshold=20, base_delay=2.0, max_delay=300.0)
|
||||
self.register_ip = RateLimiter(threshold=5, base_delay=5.0, max_delay=600.0)
|
||||
# B-58: a quota, not failure backoff — 5 accounts per IP per hour. The one
|
||||
# over it waits only until the oldest of the five ages out, and a busy NAT
|
||||
# is slowed rather than locked out for progressively longer.
|
||||
self.register_ip = RollingQuota(
|
||||
limit=_REGISTRATIONS_PER_IP, window_seconds=_REGISTRATION_WINDOW_SECONDS
|
||||
)
|
||||
|
||||
+23
-10
@@ -7,7 +7,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.client_ip import client_ip as _client_ip
|
||||
from app.api.errors import http_error
|
||||
from app.auth.rate_limit import AuthRateLimiters
|
||||
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
|
||||
from app.auth.security import (
|
||||
MIN_PASSWORD_LENGTH,
|
||||
create_access_token,
|
||||
hash_password_async,
|
||||
verify_password_async,
|
||||
)
|
||||
from app.db.models import User
|
||||
from app.db.session import get_session
|
||||
from app.wallet.hd import derive_user_address
|
||||
@@ -25,10 +30,11 @@ def _rate_limiters(request: Request) -> AuthRateLimiters:
|
||||
# still catches that). The IP limiter's threshold is deliberately higher
|
||||
# than the username one: a single account should lock out fast, but a
|
||||
# shared/NAT IP hosting several genuine users shouldn't be punished for one
|
||||
# of them mistyping a password a few times. Registration gets its own,
|
||||
# coarser limiter, IP-only — no username exists yet to key on — mainly to
|
||||
# bound how many accounts one IP can spin up (B-31), not to protect a
|
||||
# secret. Lives on app.state (see AuthRateLimiters) rather than a module
|
||||
# of them mistyping a password a few times. Registration gets its own
|
||||
# instrument entirely: a per-IP *quota* on accounts created (B-58), since
|
||||
# bounding how many accounts one source can spin up is not the same problem
|
||||
# as slowing down guesses at a secret, and failure backoff only punished the
|
||||
# honest signups. Lives on app.state (see AuthRateLimiters) rather than a module
|
||||
# global so each app instance gets its own, isolated throttle state.
|
||||
if not hasattr(request.app.state, "auth_rate_limiters"):
|
||||
request.app.state.auth_rate_limiters = AuthRateLimiters()
|
||||
@@ -65,16 +71,22 @@ async def register(
|
||||
) -> TokenResponse:
|
||||
limiters = _rate_limiters(request)
|
||||
ip_key = f"ip:{_client_ip(request)}"
|
||||
# B-58: checked before the Argon2 hash below, so an IP that's out of quota
|
||||
# costs nothing to turn away. Recorded only once an account actually exists —
|
||||
# see the successful path below.
|
||||
retry_after = limiters.register_ip.retry_after(ip_key)
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
limiters.register_ip.record_failure(ip_key)
|
||||
|
||||
existing = await session.scalar(select(User).where(User.username == body.username))
|
||||
# B-57: case-insensitive, matching the unique index on lower(username) — and
|
||||
# matching the throttle key below, which has always been lowercased.
|
||||
existing = await session.scalar(
|
||||
select(User).where(func.lower(User.username) == body.username.lower())
|
||||
)
|
||||
if existing is not None:
|
||||
raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken")
|
||||
|
||||
password_hash = hash_password(body.password)
|
||||
password_hash = await hash_password_async(body.password)
|
||||
|
||||
for _ in range(_MAX_REGISTER_RETRIES):
|
||||
max_index = await session.scalar(select(func.max(User.derivation_index)))
|
||||
@@ -101,6 +113,7 @@ async def register(
|
||||
) from exc
|
||||
continue
|
||||
await session.refresh(user)
|
||||
limiters.register_ip.record(ip_key) # B-58: one account created, one slot used
|
||||
request.app.state.electrum_listener.address_for_new_user(user.id, user.address)
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.token_version), address=user.address
|
||||
@@ -129,8 +142,8 @@ async def login(
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
|
||||
user = await session.scalar(select(User).where(User.username == body.username))
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
user = await session.scalar(select(User).where(func.lower(User.username) == body.username.lower()))
|
||||
if user is None or not await verify_password_async(body.password, user.password_hash):
|
||||
# Same code path (and therefore the same response) whether the username
|
||||
# doesn't exist or the password is wrong — no enumeration oracle here.
|
||||
limiters.login.record_failure(username_key)
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerificationError
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.config import settings
|
||||
|
||||
@@ -39,6 +40,28 @@ def verify_password(password: str, password_hash: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# --- B-55: the two Argon2 calls above must never run on the event loop ----------
|
||||
# Argon2 is deliberately expensive — tens of milliseconds of CPU per call, by
|
||||
# design. Called straight from an async handler that stalls the *whole* process
|
||||
# for that long: every other request, and all six background tasks (scheduler,
|
||||
# confirmation poller, RBF bumper, listener, both reconcilers). A burst of
|
||||
# unauthenticated login attempts was therefore a cheap way to delay draws and
|
||||
# confirmations, not just to slow down logins. The threadpool keeps the cost
|
||||
# where it belongs — on a worker thread, with the loop free to run everything
|
||||
# else meanwhile.
|
||||
#
|
||||
# The synchronous functions stay: they're what the wrappers call, and what tests
|
||||
# and scripts (no running loop) use directly. Every async caller must use these.
|
||||
|
||||
|
||||
async def hash_password_async(password: str) -> str:
|
||||
return await run_in_threadpool(hash_password, password)
|
||||
|
||||
|
||||
async def verify_password_async(password: str, password_hash: str) -> bool:
|
||||
return await run_in_threadpool(verify_password, password, password_hash)
|
||||
|
||||
|
||||
def create_access_token(user_id: int, token_version: int = 0) -> str:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
# "tv" lets get_current_user (app/auth/dependencies.py) reject a token issued
|
||||
|
||||
+72
-5
@@ -1,32 +1,44 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from embit import script
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
|
||||
from app.wallet.balance import recompute_balance
|
||||
from app.wallet.hd import derive_pool_address, derive_user_key
|
||||
from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction
|
||||
from app.wallet.psbt_builder import (
|
||||
MAX_PARTICIPANTS_PER_ROUND,
|
||||
BuiltTransaction,
|
||||
InsufficientFundsError,
|
||||
Utxo,
|
||||
build_signed_transaction,
|
||||
)
|
||||
|
||||
|
||||
class BetError(ApiError):
|
||||
pass
|
||||
|
||||
|
||||
class _RoundClosedDuringBuild(Exception):
|
||||
"""Internal signal (B-53): the round stopped accepting bets while this one was
|
||||
being built. Never leaves place_bet — it becomes a `round_closing` BetError."""
|
||||
|
||||
|
||||
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
if round_ is None:
|
||||
raise BetError("no_round_open", "no round open right now, please try again shortly")
|
||||
|
||||
config = await get_round_config(session)
|
||||
if not round_accepts_bets(round_, config.round_duration_seconds):
|
||||
if not round_accepts_bets(round_):
|
||||
raise BetError("round_closing", "the current round is closing, please try again shortly")
|
||||
|
||||
already_playing = await session.scalar(
|
||||
@@ -37,6 +49,26 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
if already_playing is not None:
|
||||
raise BetError("already_betting", "you already have an active bet in the current round")
|
||||
|
||||
# B-52: "this round can always be paid out" is an invariant, and this is where it
|
||||
# gets enforced — before any of this user's money moves. The payout has to spend
|
||||
# one pool UTXO per bet, so a round that grew past what a single payout
|
||||
# transaction may spend was unpayable: it stayed "paying_out" retrying forever,
|
||||
# and because no new round may open while one is active, the whole lottery
|
||||
# stopped. Refusing the bet costs the player one round of waiting; accepting it
|
||||
# cost everyone the platform. Counted over every participant row, not just the
|
||||
# confirmed ones: a bet that later fails frees a slot, so counting them all is
|
||||
# the conservative direction.
|
||||
participant_count = await session.scalar(
|
||||
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
|
||||
)
|
||||
if participant_count >= MAX_PARTICIPANTS_PER_ROUND:
|
||||
raise BetError(
|
||||
"round_full",
|
||||
f"this round already has its maximum of {MAX_PARTICIPANTS_PER_ROUND} players, "
|
||||
"wait for the next one",
|
||||
max_participants=MAX_PARTICIPANTS_PER_ROUND,
|
||||
)
|
||||
|
||||
bet_amount = config.bet_amount_sats
|
||||
|
||||
unspent = (
|
||||
@@ -89,7 +121,42 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
session.add(participant)
|
||||
pending = _pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
|
||||
# B-53: the deadline check at the top of this function happened before the UTXO
|
||||
# scan and the signing above, so re-check it here against the clock as it is now —
|
||||
# a slow build must not sneak a bet past the round's deadline.
|
||||
#
|
||||
# And then the part the clock can't cover: a compare-and-set on the round's own
|
||||
# row, in the *same* transaction as the participant insert. The scheduler flips
|
||||
# "open" -> "closing" in a transaction of its own and only counts in-flight
|
||||
# participants afterwards, so without this a bet could commit its "building" row
|
||||
# in between and be paid into the pool while the round drew and paid out without
|
||||
# it — money credited to no round, no participant and no refund path. The UPDATE
|
||||
# takes SQLite's write lock, so the two transactions can no longer interleave:
|
||||
# either this commits first and the scheduler's subsequent in-flight count sees
|
||||
# the row, or the flip commits first and this matches zero rows and refuses the
|
||||
# bet before anything is broadcast.
|
||||
try:
|
||||
if not round_accepts_bets(round_):
|
||||
raise _RoundClosedDuringBuild
|
||||
guard = await session.execute(
|
||||
update(Round)
|
||||
.where(Round.id == round_.id, Round.status == "open")
|
||||
.values(status="open")
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if guard.rowcount != 1:
|
||||
raise _RoundClosedDuringBuild
|
||||
await session.commit()
|
||||
except (_RoundClosedDuringBuild, OperationalError) as exc:
|
||||
# OperationalError here is SQLite's write-snapshot conflict: the round row
|
||||
# changed under us, which is the same situation as the guard matching nothing.
|
||||
# Nothing has been broadcast yet, so the rollback undoes phase 1 entirely —
|
||||
# the UTXOs stay unspent and no participant row survives.
|
||||
await session.rollback()
|
||||
raise BetError(
|
||||
"round_closing", "the current round is closing, please try again shortly"
|
||||
) from exc
|
||||
|
||||
# --- Phase 2: broadcast, then promote both rows to their live state ---------
|
||||
try:
|
||||
|
||||
+3
-2
@@ -6,8 +6,9 @@ from app.config import settings
|
||||
|
||||
# How long a writer waits for a lock held by another writer before SQLite raises
|
||||
# "database is locked" (B-39). A few seconds is enough to ride out this app's own
|
||||
# five concurrent background tasks (scheduler, confirmation poller, RBF bumper,
|
||||
# two reconcilers) plus HTTP handlers briefly overlapping a write.
|
||||
# six concurrent background tasks (Electrum listener, scheduler, confirmation
|
||||
# poller, RBF bumper, two reconcilers) plus HTTP handlers briefly overlapping a
|
||||
# write.
|
||||
_SQLITE_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,16 @@ def utcnow() -> datetime:
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
# B-57: usernames are compared case-insensitively, and that has to be the
|
||||
# database's job, not a convention the query layer remembers. "Bob" and "bob"
|
||||
# used to be two accounts sharing one rate-limit bucket (each locking the other
|
||||
# out) and, worse on a custodial system, a ready-made impersonation vector.
|
||||
# A functional unique index rather than a normalized column: the name stays
|
||||
# stored exactly as the user typed it, which is what /admin and the audit log
|
||||
# display. The username pattern (auth/routes.py) is ASCII-only, so lower() is
|
||||
# the whole of the normalization — no Unicode casefolding subtleties apply.
|
||||
__table_args__ = (Index("ix_users_username_lower", text("lower(username)"), unique=True),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(256))
|
||||
@@ -72,6 +82,16 @@ class Round(Base):
|
||||
status: Mapped[str] = mapped_column(String(16), default="open")
|
||||
opened_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
# B-61: the round's own timing, snapshotted from RoundConfig when it opens.
|
||||
# Read live from the config, a mid-round edit applied retroactively: lowering
|
||||
# round_duration_seconds from 600 to 60 while a round was 300s in closed it
|
||||
# instantly, and raising it moved the closes_at every client was already
|
||||
# counting down to. Same class of bug B-11 fixed for the advertised jackpot.
|
||||
# The config row is now what the *next* round opens with; these are what this
|
||||
# round runs by. cooldown_seconds is read off the round that just closed, so
|
||||
# the gap it announced is the gap that's honoured.
|
||||
duration_seconds: Mapped[int] = mapped_column(default=600, server_default="600")
|
||||
cooldown_seconds: Mapped[int] = mapped_column(default=30, server_default="30")
|
||||
# Set once, when status flips to "drawing" (rounds/scheduler.py:_close_and_draw).
|
||||
# Lets both the audit log (B-36's draw_stalled entries) and GET /rounds/current
|
||||
# (draw_waiting_since) measure how long a round has been waiting on a block,
|
||||
@@ -188,6 +208,24 @@ class Withdrawal(Base):
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
|
||||
|
||||
class BugReport(Base):
|
||||
__tablename__ = "bug_reports"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
contact: Mapped[str | None] = mapped_column(String(256), default=None)
|
||||
# Set when the reporter was logged in at submission time; the report page is
|
||||
# reachable both logged-in and logged-out (like GET /rounds/current), so this
|
||||
# stays nullable rather than requiring auth just to file a report.
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
|
||||
# open -> read -> resolved, admin-driven (app/api/routes/admin.py). "read" is a
|
||||
# distinct step from "resolved" so a reporter checking their own status (only
|
||||
# possible when logged in — see GET /bug-reports/mine) can tell "an admin has
|
||||
# seen this" apart from "this has actually been fixed".
|
||||
status: Mapped[str] = mapped_column(String(16), default="open")
|
||||
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
|
||||
@@ -11,6 +11,32 @@ from app.wallet.balance import recompute_balance
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def find_new_credit_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
|
||||
"""The subset of `entries` that would actually credit something: confirmed
|
||||
(height > 0, per the Electrum convention where <= 0 means mempool) and not
|
||||
already recorded.
|
||||
|
||||
Split out from credit_confirmed_utxos so the caller
|
||||
(electrum/listener.py:refresh_user) can corroborate each *new* outpoint
|
||||
against the other configured servers before any of it is written (B-59) —
|
||||
the mirror image of what B-29 already required before a balance may go
|
||||
*down*. Only new ones: corroborating outpoints already credited would open a
|
||||
connection to every other server on every refresh, for an answer that can no
|
||||
longer change what we hold.
|
||||
"""
|
||||
existing_keys = {
|
||||
(txid, vout)
|
||||
for txid, vout in (
|
||||
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user_id))
|
||||
).all()
|
||||
}
|
||||
return [
|
||||
entry
|
||||
for entry in entries
|
||||
if entry["height"] > 0 and (entry["tx_hash"], entry["tx_pos"]) not in existing_keys
|
||||
]
|
||||
|
||||
|
||||
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
|
||||
"""Insert utxo_events for newly-confirmed entries from an Electrum
|
||||
`listunspent` response (idempotent on txid+vout), refresh the user's cached
|
||||
|
||||
+139
-17
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from app.db.models import User
|
||||
from app.deposits.service import (
|
||||
credit_confirmed_utxos,
|
||||
find_new_credit_candidates,
|
||||
find_utxos_missing_from,
|
||||
mark_utxos_spent_externally,
|
||||
reinstate_reappeared_utxos,
|
||||
@@ -159,12 +160,31 @@ class ElectrumListener:
|
||||
reset its backoff), False if it never got that far."""
|
||||
client = self._client_factory(endpoint)
|
||||
await client.connect()
|
||||
self.client = client
|
||||
logger.info("Electrum connected to %s", endpoint)
|
||||
|
||||
try:
|
||||
header = await client.subscribe_headers()
|
||||
self._apply_header(header)
|
||||
if self.tip_header_hex is None:
|
||||
# B-64: the header was unusable (no hex) and we have never had a tip,
|
||||
# so publishing this client would hand every consumer a connection
|
||||
# whose chain position is unknown — B-63 all over again. A server at
|
||||
# or behind a tip we already know is fine and doesn't come through
|
||||
# here: the point is only that *some* tip is established.
|
||||
raise HeaderValidationError(
|
||||
f"{endpoint} announced an unusable initial header ({header!r}) and no tip is known"
|
||||
)
|
||||
# B-63: published only now, never before the first header has been
|
||||
# applied. `self.client is not None` is what every consumer treats as
|
||||
# "the chain is reachable" — including RoundScheduler._tick, which then
|
||||
# reads tip_height as the baseline a draw must find a *later* block than.
|
||||
# Assigning it before this round-trip left a window where the connection
|
||||
# looked alive while tip_height was still 0, so a round closing inside it
|
||||
# recorded a baseline of 0 and the very first header we learned — the
|
||||
# current tip, a block mined *before* the round closed, with a hash
|
||||
# already public while bets were still open — satisfied
|
||||
# `tip_height > tip_at_close` and seeded the draw.
|
||||
self.client = client
|
||||
|
||||
headers_queue = client.notifications("blockchain.headers.subscribe")
|
||||
scripthash_queue = client.notifications("blockchain.scripthash.subscribe")
|
||||
@@ -249,7 +269,7 @@ class ElectrumListener:
|
||||
await self.refresh_user(user_id, scripthash)
|
||||
|
||||
def _apply_header(self, header: dict) -> None:
|
||||
"""Record a new chain tip, refusing to move backwards.
|
||||
"""Record a new chain tip, refusing to move backwards or sideways.
|
||||
|
||||
Full reorg handling is out of scope for v1 by explicit design decision, but
|
||||
the tip must never regress: `_wait_for_next_block` waits for
|
||||
@@ -267,9 +287,29 @@ class ElectrumListener:
|
||||
silently ignoring the header, which (via _consume_headers/_run_once) ends
|
||||
this session the same way a dropped connection would, so run() rotates to
|
||||
the next configured server instead of continuing to trust this one.
|
||||
|
||||
Two more headers are refused without ending the session, since neither is
|
||||
evidence of a hostile server the way the above are (B-64): one carrying no
|
||||
hex, and one at the height we already hold a header for.
|
||||
"""
|
||||
height = header["height"]
|
||||
header_hex = header.get("hex")
|
||||
|
||||
if not header_hex:
|
||||
# Nothing to validate and nothing to draw from — and applying the height
|
||||
# alone would break exactly the pairing this function exists to keep:
|
||||
# tip_height would describe a block tip_header_hex doesn't (and on a
|
||||
# session's first header, would publish a client whose tip is unknown,
|
||||
# which is B-63). Ignored rather than fatal: a server that only ever
|
||||
# pushed heights would freeze the draw — visibly, via B-36's
|
||||
# draw_stalled — instead of costing us the one connection that also
|
||||
# credits deposits and broadcasts transactions. _run_once separately
|
||||
# refuses to publish a client while the tip is still unknown.
|
||||
logger.warning(
|
||||
"ignoring Electrum header at height %s: no header hex to validate or to draw from", height
|
||||
)
|
||||
return
|
||||
|
||||
if height < self.tip_height:
|
||||
logger.warning(
|
||||
"ignoring Electrum header at height %s, below the current tip %s (reorg or server switch?)",
|
||||
@@ -278,19 +318,35 @@ class ElectrumListener:
|
||||
)
|
||||
return
|
||||
|
||||
if header_hex:
|
||||
if not header_meets_its_own_target(header_hex):
|
||||
raise HeaderValidationError(
|
||||
f"header at height {height} does not satisfy its own claimed difficulty target"
|
||||
)
|
||||
if (
|
||||
self.tip_header_hex
|
||||
and height == self.tip_height + 1
|
||||
and header_prev_hash(header_hex) != header_hex_to_block_hash(self.tip_header_hex)
|
||||
):
|
||||
raise HeaderValidationError(
|
||||
f"header at height {height} does not chain from the current tip (height {self.tip_height})"
|
||||
if height == self.tip_height and self.tip_header_hex:
|
||||
# B-64: a second header for the height we already hold one for. Either the
|
||||
# same block re-announced (nothing to do) or a competing one — a reorg at
|
||||
# the tip, or a server swapping out the very hash a draw may be about to
|
||||
# use. The linkage check above cannot speak to this case at all, since
|
||||
# there is no height advance to check. Whichever it is, the hash committed
|
||||
# to for a height is not replaced under us: if ours turns out to be the
|
||||
# orphan, corroborate_header (B-28) refuses to seed a draw from it and the
|
||||
# draw waits for a further block instead.
|
||||
if header_hex != self.tip_header_hex:
|
||||
logger.warning(
|
||||
"ignoring a competing header at the current tip height %s "
|
||||
"(reorg at the tip, or a server disagreeing with the rest)",
|
||||
height,
|
||||
)
|
||||
return
|
||||
|
||||
if not header_meets_its_own_target(header_hex):
|
||||
raise HeaderValidationError(
|
||||
f"header at height {height} does not satisfy its own claimed difficulty target"
|
||||
)
|
||||
if (
|
||||
self.tip_header_hex
|
||||
and height == self.tip_height + 1
|
||||
and header_prev_hash(header_hex) != header_hex_to_block_hash(self.tip_header_hex)
|
||||
):
|
||||
raise HeaderValidationError(
|
||||
f"header at height {height} does not chain from the current tip (height {self.tip_height})"
|
||||
)
|
||||
|
||||
self.tip_height = height
|
||||
self.tip_header_hex = header_hex
|
||||
@@ -381,6 +437,51 @@ 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:
|
||||
"""B-59: the mirror of corroborate_utxo_spent, for money going the other
|
||||
way. A candidate external spend could not reduce a balance without a
|
||||
quorum, but `value` and `height` for a *credit* were taken from the single
|
||||
active connection and written straight to the DB — so one hostile or broken
|
||||
server could inflate a user's displayed balance with outpoints that don't
|
||||
exist. That never spends anyone else's coins (a bet or withdrawal built on
|
||||
a phantom UTXO is refused at broadcast and rolled back), but it wedges the
|
||||
balance display and burns build attempts, and on a custodial platform a
|
||||
balance that isn't real is a support incident either way.
|
||||
|
||||
Agreement means: this server also reports the outpoint as unspent, for the
|
||||
same amount, and considers it confirmed. The height itself is not compared —
|
||||
a server still catching up reports the entry at height 0 and simply doesn't
|
||||
agree, which is the same answer, while for a genuinely confirmed outpoint
|
||||
two honest servers cannot disagree on the height anyway.
|
||||
|
||||
A failure here delays a credit, it never loses one: the next scripthash
|
||||
notification or DepositReconciler sweep (300s) retries it, and a deposit is
|
||||
only credited once the quorum agrees.
|
||||
"""
|
||||
|
||||
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
|
||||
client = self._client_factory(endpoint)
|
||||
try:
|
||||
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
entries = await asyncio.wait_for(
|
||||
client.listunspent(scripthash), timeout=_CORROBORATION_TIMEOUT_SECONDS
|
||||
)
|
||||
return any(
|
||||
e.get("tx_hash") == txid
|
||||
and e.get("tx_pos") == vout
|
||||
and e.get("value") == value
|
||||
and (e.get("height") or 0) > 0
|
||||
for e in entries
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return await self._corroborate_majority(
|
||||
_ask, lambda agrees: agrees, f"credit of {value} sats at {txid}:{vout}"
|
||||
)
|
||||
|
||||
async def _consume_headers(self, queue: asyncio.Queue) -> None:
|
||||
while True:
|
||||
params = await queue.get()
|
||||
@@ -400,20 +501,41 @@ class ElectrumListener:
|
||||
|
||||
async def refresh_user(self, user_id: int, scripthash: str) -> None:
|
||||
"""Three phases, so no DB session is held across a network call (B-18),
|
||||
same shape as _trigger_payout: read what's needed, corroborate any
|
||||
candidate external spends against other servers (B-29), then persist.
|
||||
same shape as _trigger_payout: read what's needed, corroborate every
|
||||
balance-moving candidate against other servers — new credits (B-59) as
|
||||
well as candidate external spends (B-29) — then persist.
|
||||
"""
|
||||
assert self.client is not None
|
||||
entries = await self.client.listunspent(scripthash)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
credited = await credit_confirmed_utxos(session, user_id, entries)
|
||||
credit_candidates = await find_new_credit_candidates(session, user_id, entries)
|
||||
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
|
||||
candidates = [
|
||||
(row.id, row.txid, row.vout)
|
||||
for row in await find_utxos_missing_from(session, user_id, entries)
|
||||
]
|
||||
|
||||
corroborated_credits = [
|
||||
entry
|
||||
for entry in credit_candidates
|
||||
if await self.corroborate_utxo_credit(
|
||||
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"]
|
||||
)
|
||||
]
|
||||
|
||||
credited = 0
|
||||
if corroborated_credits:
|
||||
async with self._session_factory() as session:
|
||||
credited = await credit_confirmed_utxos(session, user_id, corroborated_credits)
|
||||
if len(corroborated_credits) < len(credit_candidates):
|
||||
logger.warning(
|
||||
"%s new UTXO(s) for user_id=%s not corroborated by the other servers — "
|
||||
"not credited yet, will retry on the next refresh",
|
||||
len(credit_candidates) - len(corroborated_credits),
|
||||
user_id,
|
||||
)
|
||||
|
||||
confirmed_ids = [
|
||||
utxo_id
|
||||
for utxo_id, txid, vout in candidates
|
||||
|
||||
@@ -15,6 +15,7 @@ import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmati
|
||||
import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler)
|
||||
from app.api.routes.admin import router as admin_router
|
||||
from app.api.routes.bets import router as bets_router
|
||||
from app.api.routes.bug_reports import router as bug_reports_router
|
||||
from app.api.routes.qr import router as qr_router
|
||||
from app.api.routes.rounds import router as rounds_router
|
||||
from app.api.routes.users import router as users_router
|
||||
@@ -99,6 +100,7 @@ app.include_router(users_router)
|
||||
app.include_router(bets_router)
|
||||
app.include_router(withdrawals_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(bug_reports_router)
|
||||
app.include_router(qr_router)
|
||||
app.include_router(rounds_router)
|
||||
|
||||
|
||||
+77
-10
@@ -14,7 +14,7 @@ from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.draw import draw_winner, header_hex_to_block_hash
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.service import open_new_round_if_needed
|
||||
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
|
||||
from app.wallet.hd import derive_pool_key
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
|
||||
@@ -67,8 +67,11 @@ class RoundScheduler:
|
||||
await session.commit()
|
||||
if round_ is None:
|
||||
return # still in the cooldown window after the last round closed
|
||||
round_id, status, opened_at = round_.id, round_.status, round_.opened_at
|
||||
round_duration_seconds = (await get_round_config(session)).round_duration_seconds
|
||||
round_id, status = round_.id, round_.status
|
||||
# B-61: this round's own snapshotted deadline, not one recomputed from
|
||||
# whatever the config says now — an operator lowering the duration
|
||||
# mid-round used to close the round on the spot.
|
||||
deadline = round_deadline(round_)
|
||||
|
||||
if status == "paying_out":
|
||||
# B-26: _trigger_payout used to run exactly once, from _close_and_draw —
|
||||
@@ -83,8 +86,7 @@ class RoundScheduler:
|
||||
return # "drawing" — progress happens inside the in-flight _close_and_draw call
|
||||
|
||||
if status == "open":
|
||||
opened_at = opened_at.replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds):
|
||||
if datetime.now(timezone.utc) < deadline:
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
@@ -120,6 +122,27 @@ class RoundScheduler:
|
||||
async with self._session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
|
||||
# B-53: re-check for in-flight bets in the *same* session that snapshots
|
||||
# the participants. _tick's check ran in a session of its own, so a bet
|
||||
# committing its "building" row in between was counted by neither: the
|
||||
# round drew and paid out without it, while its sats still landed in the
|
||||
# pool. place_bet's compare-and-set on the round row is what makes that
|
||||
# window unreachable; this is the cheap second lock on the same door, and
|
||||
# it fails safe — the round stays "closing" and the next tick retries.
|
||||
pending_count = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(RoundParticipant)
|
||||
.where(
|
||||
RoundParticipant.round_id == round_id,
|
||||
RoundParticipant.status.in_(("building", "broadcast")),
|
||||
)
|
||||
)
|
||||
if pending_count:
|
||||
logger.info(
|
||||
"round %s: %s bet(s) still in flight at close time, waiting", round_id, pending_count
|
||||
)
|
||||
return
|
||||
|
||||
participants = (
|
||||
await session.scalars(
|
||||
select(RoundParticipant)
|
||||
@@ -201,9 +224,22 @@ class RoundScheduler:
|
||||
draw_stalled audit entry is written (and re-written every threshold
|
||||
interval for as long as the stall continues) so the wait shows up next
|
||||
to the draw_header_corroboration_failed entries above.
|
||||
|
||||
B-63: `tip_at_close` of 0 means the tip was *unknown* when the round closed,
|
||||
not that the chain was at height zero — and "the first block we hear about"
|
||||
is then not necessarily a block mined after the close. Rather than seed the
|
||||
draw from a hash that may already have been public while bets were open, the
|
||||
first height we do learn becomes the baseline and this waits for a block
|
||||
strictly after it. Since the Electrum listener now only publishes its client
|
||||
once a header has been applied, and _tick won't run without one, this should
|
||||
be unreachable — it stays as the local statement of what the draw actually
|
||||
requires, since nothing else in this function would notice if that stopped
|
||||
holding.
|
||||
"""
|
||||
next_progress_log_at = waiting_since + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
|
||||
next_stall_audit_at = waiting_since + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
||||
if tip_at_close <= 0:
|
||||
tip_at_close = await self._adopt_baseline_tip(round_id)
|
||||
while True:
|
||||
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -251,6 +287,33 @@ class RoundScheduler:
|
||||
await session.commit()
|
||||
tip_at_close = height
|
||||
|
||||
async def _adopt_baseline_tip(self, round_id: int) -> int:
|
||||
"""B-63: the height the draw must find a *later* block than, for the case
|
||||
where the tip wasn't known at closing time. Waits for a header to arrive and
|
||||
takes that height as the baseline — the block it describes may predate the
|
||||
close, which is exactly why it is used as the floor rather than as the seed —
|
||||
and records why, since a draw that waits one extra block should be explainable
|
||||
from /admin rather than looking like a stall.
|
||||
"""
|
||||
while not self._listener.tip_header_hex or self._listener.tip_height <= 0:
|
||||
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
|
||||
height = self._listener.tip_height
|
||||
logger.warning(
|
||||
"round %s: chain tip was unknown at closing time; using height %s as the draw baseline "
|
||||
"and waiting for a further block",
|
||||
round_id,
|
||||
height,
|
||||
)
|
||||
async with self._session_factory() as session:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"draw_baseline_tip_unknown",
|
||||
{"baseline_height": height},
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
return height
|
||||
|
||||
async def _retry_payout_if_due(self, round_id: int) -> None:
|
||||
"""B-26: whether a "paying_out" round is due for another payout attempt.
|
||||
|
||||
@@ -338,8 +401,8 @@ class RoundScheduler:
|
||||
await self._log_payout_failure(round_id, winner_user_id, "winner user not found")
|
||||
return
|
||||
|
||||
winner_share = pool_amount_sats * 70 // 100
|
||||
commission_share = pool_amount_sats - winner_share # remainder from rounding goes to fees
|
||||
winner_sats = winner_share(pool_amount_sats)
|
||||
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
|
||||
|
||||
# --- Phase 2: build (network read only, no DB write yet) -----------------
|
||||
try:
|
||||
@@ -358,15 +421,19 @@ class RoundScheduler:
|
||||
from_script=pool_script_obj,
|
||||
utxos=utxos,
|
||||
winner_address=winner_address,
|
||||
winner_share_sats=winner_share,
|
||||
winner_share_sats=winner_sats,
|
||||
fee_address=fee_address,
|
||||
commission_sats=commission_share,
|
||||
change_address=pool_address,
|
||||
fee_rate_sat_vb=fee_rate,
|
||||
)
|
||||
except InsufficientFundsError as exc:
|
||||
# Includes the B-48 "too_many_inputs" case: the pool holds enough, but spread
|
||||
# over more UTXOs than one transaction may spend, so /admin has to say which.
|
||||
# Includes the "too_many_inputs" case: the pool holds enough, but spread over
|
||||
# more UTXOs than one transaction may spend, so /admin has to say which. Since
|
||||
# B-52 that means MAX_PAYOUT_TX_INPUTS, and participants are capped below it at
|
||||
# bet time (bets/service.py), so reaching it now takes pool change accumulated
|
||||
# over many rounds rather than one busy round — an operator consolidation job,
|
||||
# not a dead end for the bets of the round in progress.
|
||||
reason = "insufficient pool UTXOs" if exc.code == "insufficient_balance" else exc.code
|
||||
logger.exception("round %s payout failed: %s", round_id, reason)
|
||||
await self._log_payout_failure(round_id, winner_user_id, reason)
|
||||
|
||||
+59
-10
@@ -5,7 +5,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Round
|
||||
from app.db.models import Round, RoundConfig
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
|
||||
@@ -18,6 +18,14 @@ _ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out")
|
||||
# broken in a way we don't anticipate.
|
||||
_OPEN_ROUND_ATTEMPTS = 3
|
||||
|
||||
# 70% winner / 30% fees. Hardcoded by design (see CLAUDE.md) — changing the split
|
||||
# is a code change, not an admin-editable setting. Single source of truth so the
|
||||
# advertised jackpot (rounds.py) and the actual payout (scheduler.py) can't diverge.
|
||||
|
||||
|
||||
def winner_share(pool_amount_sats: int) -> int:
|
||||
return pool_amount_sats * 70 // 100
|
||||
|
||||
|
||||
async def get_active_round(session: AsyncSession) -> Round | None:
|
||||
"""The round currently in progress (in any non-closed state), if any. Rounds
|
||||
@@ -40,7 +48,15 @@ async def get_active_round(session: AsyncSession) -> Round | None:
|
||||
return active[0] if active else None
|
||||
|
||||
|
||||
def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
|
||||
def round_deadline(round_: Round) -> datetime:
|
||||
"""When this round stops accepting bets. B-61: from the round's own snapshotted
|
||||
duration, not from the live config — an operator editing round_duration_seconds
|
||||
mid-round must not move a deadline clients are already counting down to, nor
|
||||
close an in-progress round on the spot."""
|
||||
return round_.opened_at.replace(tzinfo=timezone.utc) + timedelta(seconds=round_.duration_seconds)
|
||||
|
||||
|
||||
def round_accepts_bets(round_: Round) -> bool:
|
||||
"""The authoritative "yellow light" check: once a round's timer has expired,
|
||||
no new bet may be accepted, even though its DB status is still "open" (the
|
||||
scheduler only flips it to "closing" on its next tick, up to
|
||||
@@ -49,17 +65,32 @@ def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
|
||||
before actually closing."""
|
||||
if round_.status != "open":
|
||||
return False
|
||||
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
|
||||
return datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds)
|
||||
return datetime.now(timezone.utc) < round_deadline(round_)
|
||||
|
||||
|
||||
def rounds_can_open(config: RoundConfig) -> bool:
|
||||
"""Whether the instance is configured well enough to run a round at all (B-66).
|
||||
|
||||
Only fee_address today, and only because a round without one is unpayable: the
|
||||
payout pays the 30% commission to it, so build_payout_transaction cannot even be
|
||||
built. It has no column default for exactly this reason (rounds/config.py) — an
|
||||
operator must set their own, and until they do there is nothing to guess.
|
||||
|
||||
Anything else that would make a round unpayable belongs here too, next to it,
|
||||
rather than being discovered at payout time. Deliberately not about *pausing*,
|
||||
which is a decision an operator took (RoundConfig.paused) rather than a
|
||||
prerequisite they haven't met yet."""
|
||||
return bool(config.fee_address.strip())
|
||||
|
||||
|
||||
async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
|
||||
"""Returns the active round if one exists (whatever its status). Otherwise
|
||||
opens a fresh one, unless the last closed round's cooldown (ROUND_COOLDOWN_SECONDS)
|
||||
hasn't elapsed yet, or the lottery is paused for maintenance — in either case
|
||||
returns None. Callers that need to attach a bet must additionally check the
|
||||
returned round's status == "open" — a round in closing/drawing/paying_out
|
||||
isn't accepting new bets, but a new round can't open until it's done.
|
||||
hasn't elapsed yet, the lottery is paused for maintenance, or the instance isn't
|
||||
configured well enough to pay a winner — in any of those cases returns None.
|
||||
Callers that need to attach a bet must additionally check the returned round's
|
||||
status == "open" — a round in closing/drawing/paying_out isn't accepting new bets,
|
||||
but a new round can't open until it's done.
|
||||
|
||||
Pausing never touches a round already in progress: it only suppresses opening
|
||||
the *next* one, so the current round still closes, draws, and pays out the
|
||||
@@ -71,15 +102,33 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
|
||||
config = await get_round_config(session)
|
||||
if config.paused:
|
||||
return None
|
||||
if not rounds_can_open(config):
|
||||
# B-66: a fresh instance starts with no fee_address, and a round opened
|
||||
# without one takes bets, confirms them, and only then discovers that the
|
||||
# payout cannot be built — leaving the round wedged in "paying_out",
|
||||
# retrying every 60s, with money already in the pool. Every round would
|
||||
# need its own manual recovery. Refusing to open costs nothing by
|
||||
# comparison: no money has moved yet, and it is the operator's own missing
|
||||
# setup, surfaced through GET /rounds/current's lottery_configured and the
|
||||
# admin panel rather than discovered a round too late.
|
||||
return None
|
||||
|
||||
last_closed = await session.scalar(select(Round).where(Round.status == "closed").order_by(Round.id.desc()))
|
||||
if last_closed is not None and last_closed.closed_at is not None:
|
||||
closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=config.round_cooldown_seconds):
|
||||
# B-61: the cooldown the closing round announced is the one honoured, so
|
||||
# editing the config never retroactively shortens or extends a gap already
|
||||
# under way. The new value applies from the next round on.
|
||||
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=last_closed.cooldown_seconds):
|
||||
return None
|
||||
|
||||
for attempt in range(_OPEN_ROUND_ATTEMPTS):
|
||||
round_ = Round(status="open")
|
||||
# B-61: the timing this round will run by, fixed at open time.
|
||||
round_ = Round(
|
||||
status="open",
|
||||
duration_seconds=config.round_duration_seconds,
|
||||
cooldown_seconds=config.round_cooldown_seconds,
|
||||
)
|
||||
session.add(round_)
|
||||
try:
|
||||
await session.flush()
|
||||
|
||||
@@ -133,6 +133,7 @@ table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--color-border); vertical-align: top; }
|
||||
th { color: var(--color-muted-foreground); font-weight: 500; }
|
||||
td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; max-width: 200px; }
|
||||
td.payload-cell { max-width: 360px; white-space: pre-wrap; word-break: break-word; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
.badge {
|
||||
@@ -144,6 +145,13 @@ td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; m
|
||||
background: #FEF3C7; color: #92400E; border-color: #F59E0B;
|
||||
}
|
||||
|
||||
.badge.bug-status-open { background: #FEF3C7; color: #92400E; border-color: #F59E0B; }
|
||||
.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); }
|
||||
.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
|
||||
|
||||
.bug-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||
.bug-actions button { width: auto; margin-top: 0; min-height: 30px; padding: 4px 10px; font-size: 0.78rem; }
|
||||
|
||||
button.reveal {
|
||||
width: auto; margin-top: 0; padding: 4px 10px; min-height: 30px; font-size: 0.78rem;
|
||||
background: var(--color-destructive-bg); color: var(--color-destructive); border: 1px solid var(--color-destructive);
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<span class="nav-tab" id="nav-round" onclick="switchView('round')">Round</span>
|
||||
<span class="nav-tab" id="nav-pending" onclick="switchView('pending')">Transazioni pendenti</span>
|
||||
<span class="nav-tab" id="nav-audit" onclick="switchView('audit')">Audit log</span>
|
||||
<span class="nav-tab" id="nav-bugreports" onclick="switchView('bugreports')">Segnalazioni bug</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="chain-status-pill">
|
||||
<span class="status-dot" id="chain-status-dot"></span>
|
||||
@@ -61,6 +62,13 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<!-- B-66: no fee_address means no round can open at all (the payout pays the
|
||||
30% commission to it, so it cannot even be built). Shown here because
|
||||
this is the one screen that can fix it. -->
|
||||
<div class="warning-banner hidden" id="admin-fee-address-warning">
|
||||
⚠️ Nessun fee address configurato: finché resta vuoto <strong>non si aprirà nessun round</strong>
|
||||
(il payout non sarebbe costruibile). Impostalo qui sotto e salva.
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<label for="admin-fee-address">Fee address (dove finisce il 30% di ogni round)</label>
|
||||
@@ -153,6 +161,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-bugreports">
|
||||
<h2 class="section-title">Segnalazioni bug</h2>
|
||||
<p class="hint">Segnalazioni inviate dagli utenti tramite la pagina "Segnala un bug".</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Descrizione</th><th>Contatto</th><th>Utente</th><th>Quando</th><th>Stato</th></tr>
|
||||
</thead>
|
||||
<tbody id="bugreports-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
+51
-3
@@ -89,8 +89,8 @@ function stopChainStatusPolling() {
|
||||
chainStatusInterval = null;
|
||||
}
|
||||
|
||||
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit'];
|
||||
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog };
|
||||
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit', 'bugreports'];
|
||||
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports };
|
||||
let currentAdminView = 'parametri';
|
||||
|
||||
function switchView(name) {
|
||||
@@ -109,7 +109,7 @@ function showDashboard() {
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog()]);
|
||||
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog(), loadBugReports()]);
|
||||
}
|
||||
|
||||
async function adminLogin() {
|
||||
@@ -141,6 +141,11 @@ async function adminLoadConfig() {
|
||||
try {
|
||||
const data = await callAdmin('GET', '/admin/config');
|
||||
document.getElementById('admin-fee-address').value = data.fee_address;
|
||||
// B-66: an empty fee address blocks every future round, so say so here rather
|
||||
// than leaving an empty field to be noticed.
|
||||
document
|
||||
.getElementById('admin-fee-address-warning')
|
||||
.classList.toggle('hidden', !!(data.fee_address || '').trim());
|
||||
document.getElementById('admin-bet-amount').value = data.bet_amount_sats / SATS_PER_PLM;
|
||||
document.getElementById('admin-round-duration').value = data.round_duration_seconds;
|
||||
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
|
||||
@@ -346,6 +351,49 @@ async function loadAuditLog() {
|
||||
}
|
||||
}
|
||||
|
||||
const BUG_REPORT_STATUS_LABELS = { open: 'Da leggere', read: 'Presa in carico', resolved: 'Risolta' };
|
||||
|
||||
function bugReportBadge(status) {
|
||||
return `<span class="badge bug-status-${escapeHtml(status)}">${escapeHtml(BUG_REPORT_STATUS_LABELS[status] || status)}</span>`;
|
||||
}
|
||||
|
||||
async function loadBugReports() {
|
||||
try {
|
||||
const reports = await callAdmin('GET', '/admin/bug-reports');
|
||||
const tbody = document.getElementById('bugreports-tbody');
|
||||
tbody.innerHTML = reports.map((r) => `
|
||||
<tr>
|
||||
<td>${r.id}</td>
|
||||
<td class="payload-cell">${escapeHtml(r.description)}</td>
|
||||
<td>${r.contact ? escapeHtml(r.contact) : '—'}</td>
|
||||
<td>${r.username ? escapeHtml(r.username) : '—'}</td>
|
||||
<td>${fmtDate(r.created_at)}</td>
|
||||
<td>
|
||||
${bugReportBadge(r.status)}
|
||||
<div class="bug-actions">
|
||||
${r.status === 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'read', this)">Segna come presa in carico</button>` : ''}
|
||||
${r.status !== 'resolved' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'resolved', this)">Segna come risolta</button>` : ''}
|
||||
${r.status !== 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'open', this)">Riapri</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="6" class="hint">Nessuna segnalazione ricevuta.</td></tr>';
|
||||
} catch (e) {
|
||||
toast('Errore nel caricamento segnalazioni: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function setBugReportStatus(reportId, newStatus, button) {
|
||||
await withLoading(button, '…', async () => {
|
||||
try {
|
||||
await callAdmin('POST', '/admin/bug-reports/' + reportId + '/status', { status: newStatus });
|
||||
await loadBugReports();
|
||||
} catch (e) {
|
||||
toast('Errore: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('admin-token').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') adminLogin();
|
||||
});
|
||||
|
||||
+72
-13
@@ -235,7 +235,18 @@ function renderChainStatusBar() {
|
||||
label.textContent = t(CHAIN_STATUS_KEYS[labelKey]);
|
||||
block.textContent = t('chain.block', { n: data.chain_tip_height != null ? '#' + data.chain_tip_height : '—' });
|
||||
|
||||
document.getElementById('maintenance-banner').classList.toggle('hidden', !data.lottery_paused);
|
||||
// Two separate reasons no round will open, and telling them apart matters to the
|
||||
// reader: a pause ends when the operator resumes, while an unconfigured instance
|
||||
// (B-66) won't produce a round at all until it's set up — "come back later" would
|
||||
// be a lie. `=== false` so an older server that doesn't send the field at all
|
||||
// can't flash the banner. A pause takes precedence: it's the deliberate action.
|
||||
const notConfigured = data.lottery_configured === false;
|
||||
const noRoundsComing = !!data.lottery_paused || notConfigured;
|
||||
document.getElementById('maintenance-banner').classList.toggle('hidden', !noRoundsComing);
|
||||
if (noRoundsComing) {
|
||||
document.getElementById('maintenance-banner-text').textContent =
|
||||
data.lottery_paused ? t('maintenance.banner') : t('maintenance.notConfigured');
|
||||
}
|
||||
}
|
||||
|
||||
// After a couple of consecutive failed polls (network blip, server restart,
|
||||
@@ -368,6 +379,49 @@ async function checkLastRoundResult() {
|
||||
|
||||
let lastJackpotValue = null;
|
||||
|
||||
// The round's own confirmed/in-flight split (B-65). The big numbers are the
|
||||
// confirmed ones — the players the draw will pick from and the pool the payout
|
||||
// will actually spend — because a jackpot advertised larger than the one paid out
|
||||
// is the kind of gap nobody forgives. What has been bet but hasn't confirmed yet
|
||||
// is shown next to them instead of being folded in, so the player who just bet
|
||||
// still sees their own bet immediately (the same reasoning as the amber
|
||||
// pending balance, see setBalanceDisplay).
|
||||
//
|
||||
// Kept out of the [data-i18n] mechanism on purpose: these come from server data,
|
||||
// so onLanguageChange() re-renders them through t() like every other dynamic bit.
|
||||
let lastRoundStats = null;
|
||||
|
||||
function renderRoundStats(data) {
|
||||
lastRoundStats = data;
|
||||
document.getElementById('round-players').textContent = data.participant_count;
|
||||
|
||||
const jackpotEl = document.getElementById('round-jackpot');
|
||||
const jackpotValue = data.jackpot_sats / SATS_PER_PLM;
|
||||
jackpotEl.textContent = formatPlm(data.jackpot_sats);
|
||||
if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) {
|
||||
jackpotEl.classList.remove('jackpot-bump');
|
||||
void jackpotEl.offsetWidth; // restart the animation
|
||||
jackpotEl.classList.add('jackpot-bump');
|
||||
}
|
||||
lastJackpotValue = jackpotValue;
|
||||
|
||||
// pending_* are inclusive of the confirmed figures (like pending_balance_sats),
|
||||
// so what's shown alongside is the difference.
|
||||
const pendingPlayers = (data.pending_participant_count || 0) - data.participant_count;
|
||||
const pendingJackpotSats = (data.pending_jackpot_sats || 0) - data.jackpot_sats;
|
||||
const show = !!data.has_pending_bets && pendingPlayers > 0;
|
||||
|
||||
const playersPendingEl = document.getElementById('round-players-pending');
|
||||
playersPendingEl.textContent = show ? t('round.playersPending', { n: pendingPlayers }) : '';
|
||||
playersPendingEl.classList.toggle('hidden', !show);
|
||||
|
||||
const jackpotPendingEl = document.getElementById('round-jackpot-pending');
|
||||
jackpotPendingEl.textContent = show
|
||||
? t('round.jackpotPending', { amount: formatPlm(pendingJackpotSats) })
|
||||
: '';
|
||||
jackpotPendingEl.classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
let timerHitZero = false;
|
||||
|
||||
function updateRoundTimer() {
|
||||
@@ -451,16 +505,7 @@ async function refreshRound() {
|
||||
: t('round.none');
|
||||
betAmountSats = data.bet_amount_sats;
|
||||
renderBetButton();
|
||||
document.getElementById('round-players').textContent = data.participant_count;
|
||||
const jackpotEl = document.getElementById('round-jackpot');
|
||||
const jackpotValue = data.jackpot_sats / SATS_PER_PLM;
|
||||
jackpotEl.textContent = formatPlm(data.jackpot_sats);
|
||||
if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) {
|
||||
jackpotEl.classList.remove('jackpot-bump');
|
||||
void jackpotEl.offsetWidth; // restart the animation
|
||||
jackpotEl.classList.add('jackpot-bump');
|
||||
}
|
||||
lastJackpotValue = jackpotValue;
|
||||
renderRoundStats(data);
|
||||
if (data.server_time) serverTimeOffsetMs = new Date(data.server_time) - new Date();
|
||||
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
|
||||
updateRoundTimer();
|
||||
@@ -488,11 +533,21 @@ async function refreshRound() {
|
||||
// myUserId may not be loaded yet on the very first tick after a reload
|
||||
// (refreshMe() and refreshRound() run concurrently) — fall back to the
|
||||
// persisted result rather than risk showing nothing or the wrong side.
|
||||
// winner_user_id is committed as soon as the draw picks a winner, but
|
||||
// winner_amount_sats isn't set until the payout tx is built afterwards
|
||||
// (a real Electrum round-trip later) — revealing a win before then would
|
||||
// show "+— PLM". Only the winner's own reveal needs to wait for it.
|
||||
const iWon = myUserId != null && data.winner_user_id === myUserId;
|
||||
const amountReady = !iWon || data.winner_amount_sats != null;
|
||||
const canReveal =
|
||||
data.user_played && data.winner_user_id != null && (alreadyKnown || elapsedMs >= minMs) && myUserId != null;
|
||||
data.user_played &&
|
||||
data.winner_user_id != null &&
|
||||
(alreadyKnown || elapsedMs >= minMs) &&
|
||||
myUserId != null &&
|
||||
amountReady;
|
||||
|
||||
if (canReveal) {
|
||||
const won = data.winner_user_id === myUserId;
|
||||
const won = iWon;
|
||||
if (!alreadyKnown) {
|
||||
persistResult(data.round_id, won, data.winner_amount_sats);
|
||||
if (won) {
|
||||
@@ -848,6 +903,10 @@ function connectRoundEvents() {
|
||||
function onLanguageChange() {
|
||||
renderBetButton();
|
||||
renderChainStatusBar(); // repaints from remembered state, without waiting for the next poll
|
||||
// Same reason, for the round's "+N in attesa" suffixes (B-65): repaint from what
|
||||
// was last received rather than leaving them in the old language until the
|
||||
// refreshRound() below happens to come back.
|
||||
if (lastRoundStats) renderRoundStats(lastRoundStats);
|
||||
if (token) {
|
||||
refreshRound();
|
||||
refreshMe();
|
||||
|
||||
@@ -10,6 +10,26 @@ const TRANSLATIONS = {
|
||||
'nav.guideTitle': 'Guide',
|
||||
'nav.guideAria': 'Open the user guide',
|
||||
'nav.bugReport': 'Report a bug',
|
||||
'bugReport.pageTitle': 'Report a bug',
|
||||
'bugReport.heading': 'Report a bug',
|
||||
'bugReport.intro': 'Found a problem? Describe it below — your report goes straight to the admin panel.',
|
||||
'bugReport.englishNotice': "Please write your bug report in English, regardless of the language you're browsing in — this helps us handle it faster.",
|
||||
'bugReport.descriptionLabel': 'What happened?',
|
||||
'bugReport.descriptionPlaceholder': 'Describe the bug: what you were doing, what you expected, and what happened instead.',
|
||||
'bugReport.contactLabel': 'Contact (optional)',
|
||||
'bugReport.contactPlaceholder': "Email or other contact, if you'd like a reply",
|
||||
'bugReport.submitBtn': 'Send report',
|
||||
'bugReport.submitting': 'Sending…',
|
||||
'bugReport.blankError': 'Describe the bug before sending.',
|
||||
'bugReport.successToast': 'Thanks! Report sent.',
|
||||
'bugReport.errorPrefix': 'Error sending: ',
|
||||
'bugReport.myReportsTitle': 'Your reports',
|
||||
'bugReport.myReportsHint': 'Only reports sent from this account, with the status set by the admin team.',
|
||||
'bugReport.myReportsEmpty': "You haven't sent any reports yet.",
|
||||
'bugReport.statusOpen': 'Not read yet',
|
||||
'bugReport.statusRead': 'Acknowledged',
|
||||
'bugReport.statusResolved': 'Resolved',
|
||||
'bugReport.backLink': 'Back to home',
|
||||
'nav.logoutTitle': 'Log out',
|
||||
'nav.logoutAria': 'Log out of your account',
|
||||
'nav.deposit': 'Deposit',
|
||||
@@ -21,6 +41,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Block {n}',
|
||||
'chain.connectionLost': 'Connection to server lost — retrying…',
|
||||
'maintenance.banner': 'Scheduled maintenance: the current round completes normally (winner included), but the next round will not open until maintenance ends.',
|
||||
'maintenance.notConfigured': 'This lottery is not ready to play yet: the operator still has to finish setting it up, and no round will open until then.',
|
||||
|
||||
'hero.lead': 'Deposit PLM, join the round with a fixed entry fee, and if your number is drawn you win the jackpot.',
|
||||
'hero.step1.title': '1. Deposit',
|
||||
@@ -43,6 +64,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': 'Players',
|
||||
'round.jackpot': 'Jackpot',
|
||||
'round.playersPending': '+{n} pending',
|
||||
'round.jackpotPending': '+{amount} pending',
|
||||
'round.status.open': 'open',
|
||||
'round.status.closing': 'closing',
|
||||
'round.status.drawing': 'drawing in progress',
|
||||
@@ -119,8 +142,10 @@ 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.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.',
|
||||
'error.balance_pending_confirmation': 'You have {pending_plm} PLM pending confirmation — it is not spendable yet.',
|
||||
'error.amount_below_network_fee': 'The amount is too small to cover the network fee.',
|
||||
'error.invalid_address': 'Not a valid PLM address (it must start with plm1q…).',
|
||||
@@ -153,6 +178,26 @@ const TRANSLATIONS = {
|
||||
'nav.guideTitle': 'Guida',
|
||||
'nav.guideAria': 'Apri la guida utente',
|
||||
'nav.bugReport': 'Segnala un bug',
|
||||
'bugReport.pageTitle': 'Segnala un bug',
|
||||
'bugReport.heading': 'Segnala un bug',
|
||||
'bugReport.intro': 'Hai trovato un problema? Descrivilo qui sotto: la segnalazione arriva direttamente al pannello di amministrazione.',
|
||||
'bugReport.englishNotice': "Scrivi la segnalazione in inglese, indipendentemente dalla lingua che stai usando per navigare: questo ci aiuta a gestirla più velocemente.",
|
||||
'bugReport.descriptionLabel': 'Cosa è successo?',
|
||||
'bugReport.descriptionPlaceholder': 'Descrivi il bug: cosa stavi facendo, cosa ti aspettavi e cosa è successo invece.',
|
||||
'bugReport.contactLabel': 'Contatto (opzionale)',
|
||||
'bugReport.contactPlaceholder': 'Email o altro recapito, se vuoi essere ricontattato',
|
||||
'bugReport.submitBtn': 'Invia segnalazione',
|
||||
'bugReport.submitting': 'Invio…',
|
||||
'bugReport.blankError': 'Descrivi il bug prima di inviare.',
|
||||
'bugReport.successToast': 'Grazie! Segnalazione inviata.',
|
||||
'bugReport.errorPrefix': "Errore nell'invio: ",
|
||||
'bugReport.myReportsTitle': 'Le tue segnalazioni',
|
||||
'bugReport.myReportsHint': "Solo le segnalazioni inviate da questo account, con lo stato aggiornato dall'amministrazione.",
|
||||
'bugReport.myReportsEmpty': 'Non hai ancora inviato segnalazioni.',
|
||||
'bugReport.statusOpen': 'Da leggere',
|
||||
'bugReport.statusRead': 'Presa in carico',
|
||||
'bugReport.statusResolved': 'Risolta',
|
||||
'bugReport.backLink': 'Torna alla home',
|
||||
'nav.logoutTitle': 'Esci',
|
||||
'nav.logoutAria': "Esci dall'account",
|
||||
'nav.deposit': 'Deposito',
|
||||
@@ -164,6 +209,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Blocco {n}',
|
||||
'chain.connectionLost': 'Connessione al server persa — riprovo…',
|
||||
'maintenance.banner': 'Manutenzione in programma: il round in corso viene completato regolarmente (vincitore incluso), ma il round successivo non si aprirà finché la manutenzione non sarà terminata.',
|
||||
'maintenance.notConfigured': 'Questa lotteria non è ancora pronta: l\'operatore deve completare la configurazione, e fino a quel momento non si aprirà nessun round.',
|
||||
|
||||
'hero.lead': 'Deposita PLM, entra nel round con una quota fissa, e se viene estratto il tuo numero vinci il montepremi.',
|
||||
'hero.step1.title': '1. Deposita',
|
||||
@@ -186,6 +232,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': 'Giocatori',
|
||||
'round.jackpot': 'Jackpot',
|
||||
'round.playersPending': '+{n} in attesa',
|
||||
'round.jackpotPending': '+{amount} in attesa',
|
||||
'round.status.open': 'aperto',
|
||||
'round.status.closing': 'in chiusura',
|
||||
'round.status.drawing': 'estrazione in corso',
|
||||
@@ -259,8 +307,10 @@ 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.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.',
|
||||
'error.balance_pending_confirmation': 'Hai {pending_plm} PLM in attesa di conferma — non ancora disponibili per la spesa.',
|
||||
'error.amount_below_network_fee': "L'importo è troppo basso per coprire la fee di rete.",
|
||||
'error.invalid_address': 'Indirizzo PLM non valido (deve iniziare con plm1q…).',
|
||||
@@ -293,6 +343,26 @@ const TRANSLATIONS = {
|
||||
'nav.guideTitle': 'Guía',
|
||||
'nav.guideAria': 'Abrir la guía del usuario',
|
||||
'nav.bugReport': 'Reportar un error',
|
||||
'bugReport.pageTitle': 'Reportar un error',
|
||||
'bugReport.heading': 'Reportar un error',
|
||||
'bugReport.intro': '¿Encontraste un problema? Descríbelo a continuación: el informe llega directamente al panel de administración.',
|
||||
'bugReport.englishNotice': 'Escribe el informe en inglés, independientemente del idioma que estés usando para navegar: esto nos ayuda a gestionarlo más rápido.',
|
||||
'bugReport.descriptionLabel': '¿Qué pasó?',
|
||||
'bugReport.descriptionPlaceholder': 'Describe el error: qué estabas haciendo, qué esperabas y qué sucedió en su lugar.',
|
||||
'bugReport.contactLabel': 'Contacto (opcional)',
|
||||
'bugReport.contactPlaceholder': 'Correo u otro contacto, si quieres que te respondamos',
|
||||
'bugReport.submitBtn': 'Enviar informe',
|
||||
'bugReport.submitting': 'Enviando…',
|
||||
'bugReport.blankError': 'Describe el error antes de enviarlo.',
|
||||
'bugReport.successToast': '¡Gracias! Informe enviado.',
|
||||
'bugReport.errorPrefix': 'Error al enviar: ',
|
||||
'bugReport.myReportsTitle': 'Tus informes',
|
||||
'bugReport.myReportsHint': 'Solo los informes enviados desde esta cuenta, con el estado actualizado por el equipo de administración.',
|
||||
'bugReport.myReportsEmpty': 'Todavía no has enviado ningún informe.',
|
||||
'bugReport.statusOpen': 'Sin leer',
|
||||
'bugReport.statusRead': 'En curso',
|
||||
'bugReport.statusResolved': 'Resuelto',
|
||||
'bugReport.backLink': 'Volver al inicio',
|
||||
'nav.logoutTitle': 'Salir',
|
||||
'nav.logoutAria': 'Cerrar sesión',
|
||||
'nav.deposit': 'Depósito',
|
||||
@@ -304,6 +374,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Bloque {n}',
|
||||
'chain.connectionLost': 'Conexión con el servidor perdida — reintentando…',
|
||||
'maintenance.banner': 'Mantenimiento programado: la ronda actual se completa con normalidad (ganador incluido), pero la siguiente ronda no se abrirá hasta que finalice el mantenimiento.',
|
||||
'maintenance.notConfigured': 'Esta lotería todavía no está lista: el operador tiene que terminar de configurarla y, hasta entonces, no se abrirá ninguna ronda.',
|
||||
|
||||
'hero.lead': 'Deposita PLM, únete a la ronda con una cuota fija de entrada, y si sale tu número ganas el bote.',
|
||||
'hero.step1.title': '1. Deposita',
|
||||
@@ -326,6 +397,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': 'Jugadores',
|
||||
'round.jackpot': 'Bote',
|
||||
'round.playersPending': '+{n} pendientes',
|
||||
'round.jackpotPending': '+{amount} pendientes',
|
||||
'round.status.open': 'abierta',
|
||||
'round.status.closing': 'cerrando',
|
||||
'round.status.drawing': 'sorteo en curso',
|
||||
@@ -399,8 +472,10 @@ 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.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.',
|
||||
'error.balance_pending_confirmation': 'Tienes {pending_plm} PLM pendientes de confirmación — todavía no se pueden gastar.',
|
||||
'error.amount_below_network_fee': 'El importe es demasiado pequeño para cubrir la comisión de red.',
|
||||
'error.invalid_address': 'Dirección PLM no válida (debe empezar por plm1q…).',
|
||||
@@ -433,6 +508,26 @@ const TRANSLATIONS = {
|
||||
'nav.guideTitle': 'Guide',
|
||||
'nav.guideAria': "Ouvrir le guide de l'utilisateur",
|
||||
'nav.bugReport': 'Signaler un bug',
|
||||
'bugReport.pageTitle': 'Signaler un bug',
|
||||
'bugReport.heading': 'Signaler un bug',
|
||||
'bugReport.intro': "Vous avez trouvé un problème ? Décrivez-le ci-dessous : le signalement arrive directement dans le panneau d'administration.",
|
||||
'bugReport.englishNotice': "Rédigez votre signalement en anglais, quelle que soit la langue que vous utilisez pour naviguer : cela nous aide à le traiter plus rapidement.",
|
||||
'bugReport.descriptionLabel': "Que s'est-il passé ?",
|
||||
'bugReport.descriptionPlaceholder': "Décrivez le bug : ce que vous faisiez, ce que vous attendiez et ce qui s'est passé à la place.",
|
||||
'bugReport.contactLabel': 'Contact (facultatif)',
|
||||
'bugReport.contactPlaceholder': 'Email ou autre contact, si vous souhaitez une réponse',
|
||||
'bugReport.submitBtn': 'Envoyer le signalement',
|
||||
'bugReport.submitting': 'Envoi…',
|
||||
'bugReport.blankError': "Décrivez le bug avant d'envoyer.",
|
||||
'bugReport.successToast': 'Merci ! Signalement envoyé.',
|
||||
'bugReport.errorPrefix': "Erreur lors de l'envoi : ",
|
||||
'bugReport.myReportsTitle': 'Vos signalements',
|
||||
'bugReport.myReportsHint': "Seulement les signalements envoyés depuis ce compte, avec le statut mis à jour par l'équipe d'administration.",
|
||||
'bugReport.myReportsEmpty': "Vous n'avez encore envoyé aucun signalement.",
|
||||
'bugReport.statusOpen': 'Non lu',
|
||||
'bugReport.statusRead': 'Prise en charge',
|
||||
'bugReport.statusResolved': 'Résolu',
|
||||
'bugReport.backLink': "Retour à l'accueil",
|
||||
'nav.logoutTitle': 'Se déconnecter',
|
||||
'nav.logoutAria': 'Se déconnecter du compte',
|
||||
'nav.deposit': 'Dépôt',
|
||||
@@ -444,6 +539,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Bloc {n}',
|
||||
'chain.connectionLost': 'Connexion au serveur perdue — nouvelle tentative…',
|
||||
'maintenance.banner': "Maintenance programmée : le round en cours se termine normalement (gagnant inclus), mais le round suivant ne s'ouvrira qu'une fois la maintenance terminée.",
|
||||
'maintenance.notConfigured': "Cette loterie n'est pas encore prête : l'opérateur doit terminer la configuration, et aucun round ne s'ouvrira avant.",
|
||||
|
||||
'hero.lead': 'Déposez des PLM, rejoignez le round avec une mise fixe, et si votre numéro est tiré vous remportez le jackpot.',
|
||||
'hero.step1.title': '1. Déposez',
|
||||
@@ -466,6 +562,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': 'Joueurs',
|
||||
'round.jackpot': 'Jackpot',
|
||||
'round.playersPending': '+{n} en attente',
|
||||
'round.jackpotPending': '+{amount} en attente',
|
||||
'round.status.open': 'ouvert',
|
||||
'round.status.closing': 'en fermeture',
|
||||
'round.status.drawing': 'tirage en cours',
|
||||
@@ -539,8 +637,10 @@ 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.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.',
|
||||
'error.balance_pending_confirmation': 'Vous avez {pending_plm} PLM en attente de confirmation — pas encore disponibles.',
|
||||
'error.amount_below_network_fee': 'Le montant est trop faible pour couvrir les frais de réseau.',
|
||||
'error.invalid_address': 'Adresse PLM invalide (elle doit commencer par plm1q…).',
|
||||
@@ -573,6 +673,26 @@ const TRANSLATIONS = {
|
||||
'nav.guideTitle': 'Anleitung',
|
||||
'nav.guideAria': 'Benutzerhandbuch öffnen',
|
||||
'nav.bugReport': 'Fehler melden',
|
||||
'bugReport.pageTitle': 'Fehler melden',
|
||||
'bugReport.heading': 'Fehler melden',
|
||||
'bugReport.intro': 'Ein Problem gefunden? Beschreibe es unten — die Meldung geht direkt an das Admin-Panel.',
|
||||
'bugReport.englishNotice': 'Bitte schreibe die Fehlermeldung auf Englisch, unabhängig von der Sprache, die du gerade verwendest — das hilft uns, sie schneller zu bearbeiten.',
|
||||
'bugReport.descriptionLabel': 'Was ist passiert?',
|
||||
'bugReport.descriptionPlaceholder': 'Beschreibe den Fehler: was du getan hast, was du erwartet hast und was stattdessen passiert ist.',
|
||||
'bugReport.contactLabel': 'Kontakt (optional)',
|
||||
'bugReport.contactPlaceholder': 'E-Mail oder anderer Kontakt, falls du eine Antwort möchtest',
|
||||
'bugReport.submitBtn': 'Meldung senden',
|
||||
'bugReport.submitting': 'Senden…',
|
||||
'bugReport.blankError': 'Beschreibe den Fehler, bevor du sendest.',
|
||||
'bugReport.successToast': 'Danke! Meldung gesendet.',
|
||||
'bugReport.errorPrefix': 'Fehler beim Senden: ',
|
||||
'bugReport.myReportsTitle': 'Deine Meldungen',
|
||||
'bugReport.myReportsHint': 'Nur Meldungen, die von diesem Konto gesendet wurden, mit dem vom Admin-Team aktualisierten Status.',
|
||||
'bugReport.myReportsEmpty': 'Du hast noch keine Meldungen gesendet.',
|
||||
'bugReport.statusOpen': 'Ungelesen',
|
||||
'bugReport.statusRead': 'In Bearbeitung',
|
||||
'bugReport.statusResolved': 'Gelöst',
|
||||
'bugReport.backLink': 'Zurück zur Startseite',
|
||||
'nav.logoutTitle': 'Abmelden',
|
||||
'nav.logoutAria': 'Vom Konto abmelden',
|
||||
'nav.deposit': 'Einzahlung',
|
||||
@@ -584,6 +704,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Block {n}',
|
||||
'chain.connectionLost': 'Verbindung zum Server verloren — erneuter Versuch…',
|
||||
'maintenance.banner': 'Geplante Wartung: Die laufende Runde wird regulär abgeschlossen (Gewinner inklusive), aber die nächste Runde öffnet erst, wenn die Wartung beendet ist.',
|
||||
'maintenance.notConfigured': 'Diese Lotterie ist noch nicht spielbereit: der Betreiber muss die Einrichtung abschließen, bis dahin öffnet keine Runde.',
|
||||
|
||||
'hero.lead': 'Zahle PLM ein, nimm mit einem festen Einsatz an der Runde teil, und wenn deine Zahl gezogen wird, gewinnst du den Jackpot.',
|
||||
'hero.step1.title': '1. Einzahlen',
|
||||
@@ -606,6 +727,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': 'Spieler',
|
||||
'round.jackpot': 'Jackpot',
|
||||
'round.playersPending': '+{n} ausstehend',
|
||||
'round.jackpotPending': '+{amount} ausstehend',
|
||||
'round.status.open': 'offen',
|
||||
'round.status.closing': 'wird geschlossen',
|
||||
'round.status.drawing': 'Ziehung läuft',
|
||||
@@ -679,8 +802,10 @@ 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.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.',
|
||||
'error.balance_pending_confirmation': 'Sie haben {pending_plm} PLM, die noch auf Bestätigung warten — noch nicht verfügbar.',
|
||||
'error.amount_below_network_fee': 'Der Betrag ist zu klein, um die Netzwerkgebühr zu decken.',
|
||||
'error.invalid_address': 'Keine gültige PLM-Adresse (sie muss mit plm1q… beginnen).',
|
||||
@@ -713,6 +838,26 @@ const TRANSLATIONS = {
|
||||
'nav.guideTitle': 'Инструкция',
|
||||
'nav.guideAria': 'Открыть руководство пользователя',
|
||||
'nav.bugReport': 'Сообщить об ошибке',
|
||||
'bugReport.pageTitle': 'Сообщить об ошибке',
|
||||
'bugReport.heading': 'Сообщить об ошибке',
|
||||
'bugReport.intro': 'Нашли проблему? Опишите её ниже — сообщение сразу попадёт в панель администратора.',
|
||||
'bugReport.englishNotice': 'Пожалуйста, опишите ошибку на английском языке, независимо от языка интерфейса — это поможет нам обработать её быстрее.',
|
||||
'bugReport.descriptionLabel': 'Что произошло?',
|
||||
'bugReport.descriptionPlaceholder': 'Опишите ошибку: что вы делали, что ожидали и что произошло вместо этого.',
|
||||
'bugReport.contactLabel': 'Контакт (необязательно)',
|
||||
'bugReport.contactPlaceholder': 'Email или другой контакт, если хотите получить ответ',
|
||||
'bugReport.submitBtn': 'Отправить сообщение',
|
||||
'bugReport.submitting': 'Отправка…',
|
||||
'bugReport.blankError': 'Опишите ошибку перед отправкой.',
|
||||
'bugReport.successToast': 'Спасибо! Сообщение отправлено.',
|
||||
'bugReport.errorPrefix': 'Ошибка отправки: ',
|
||||
'bugReport.myReportsTitle': 'Ваши сообщения',
|
||||
'bugReport.myReportsHint': 'Только сообщения, отправленные с этого аккаунта, со статусом, обновлённым администрацией.',
|
||||
'bugReport.myReportsEmpty': 'Вы ещё не отправляли сообщений.',
|
||||
'bugReport.statusOpen': 'Не прочитано',
|
||||
'bugReport.statusRead': 'В обработке',
|
||||
'bugReport.statusResolved': 'Решено',
|
||||
'bugReport.backLink': 'Назад на главную',
|
||||
'nav.logoutTitle': 'Выйти',
|
||||
'nav.logoutAria': 'Выйти из аккаунта',
|
||||
'nav.deposit': 'Депозит',
|
||||
@@ -724,6 +869,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Блок {n}',
|
||||
'chain.connectionLost': 'Соединение с сервером потеряно — повторная попытка…',
|
||||
'maintenance.banner': 'Запланировано техобслуживание: текущий раунд завершится в обычном порядке (включая победителя), но следующий раунд не откроется до окончания техобслуживания.',
|
||||
'maintenance.notConfigured': 'Эта лотерея пока не готова: оператору нужно завершить настройку, до этого ни один раунд не откроется.',
|
||||
|
||||
'hero.lead': 'Внесите PLM, вступите в раунд с фиксированной ставкой, и если выпадет ваш номер — вы выиграете джекпот.',
|
||||
'hero.step1.title': '1. Внесите депозит',
|
||||
@@ -746,6 +892,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': 'Игроки',
|
||||
'round.jackpot': 'Джекпот',
|
||||
'round.playersPending': '+{n} в ожидании',
|
||||
'round.jackpotPending': '+{amount} в ожидании',
|
||||
'round.status.open': 'открыт',
|
||||
'round.status.closing': 'закрывается',
|
||||
'round.status.drawing': 'идёт розыгрыш',
|
||||
@@ -819,8 +967,10 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Нет соединения с сетью, повторите попытку чуть позже.',
|
||||
'error.no_round_open': 'Сейчас нет открытого раунда, повторите попытку чуть позже.',
|
||||
'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.',
|
||||
'error.round_full': 'В этом раунде достигнут максимум участников ({max_participants}) — дождитесь следующего, он начнётся совсем скоро.',
|
||||
'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.',
|
||||
'error.insufficient_balance': 'Недостаточно средств.',
|
||||
'error.balance_leaves_no_change': 'Ваш баланс слишком близок к сумме ставки: нужно ещё {required_extra_plm} PLM, чтобы в транзакции остался выход сдачи и её можно было переотправить с более высокой комиссией, если сеть работает медленно.',
|
||||
'error.balance_pending_confirmation': 'У вас есть {pending_plm} PLM, ожидающих подтверждения — они пока недоступны для расходования.',
|
||||
'error.amount_below_network_fee': 'Сумма слишком мала, чтобы покрыть комиссию сети.',
|
||||
'error.invalid_address': 'Некорректный адрес PLM (он должен начинаться с plm1q…).',
|
||||
@@ -853,6 +1003,26 @@ const TRANSLATIONS = {
|
||||
'nav.guideTitle': '指南',
|
||||
'nav.guideAria': '打开用户指南',
|
||||
'nav.bugReport': '报告问题',
|
||||
'bugReport.pageTitle': '报告问题',
|
||||
'bugReport.heading': '报告问题',
|
||||
'bugReport.intro': '发现问题了吗?请在下面描述——您的反馈会直接发送到管理员面板。',
|
||||
'bugReport.englishNotice': '请用英文描述问题,无论您当前使用的是哪种语言界面——这有助于我们更快处理。',
|
||||
'bugReport.descriptionLabel': '发生了什么?',
|
||||
'bugReport.descriptionPlaceholder': '描述问题:您当时在做什么、期望的结果是什么,以及实际发生了什么。',
|
||||
'bugReport.contactLabel': '联系方式(可选)',
|
||||
'bugReport.contactPlaceholder': '如果希望得到回复,请留下邮箱或其他联系方式',
|
||||
'bugReport.submitBtn': '发送反馈',
|
||||
'bugReport.submitting': '发送中…',
|
||||
'bugReport.blankError': '请先描述问题再发送。',
|
||||
'bugReport.successToast': '谢谢!反馈已发送。',
|
||||
'bugReport.errorPrefix': '发送出错:',
|
||||
'bugReport.myReportsTitle': '您的反馈',
|
||||
'bugReport.myReportsHint': '仅显示此账户发送的反馈,状态由管理团队更新。',
|
||||
'bugReport.myReportsEmpty': '您还没有发送过反馈。',
|
||||
'bugReport.statusOpen': '待处理',
|
||||
'bugReport.statusRead': '处理中',
|
||||
'bugReport.statusResolved': '已解决',
|
||||
'bugReport.backLink': '返回首页',
|
||||
'nav.logoutTitle': '退出登录',
|
||||
'nav.logoutAria': '退出账户',
|
||||
'nav.deposit': '存款',
|
||||
@@ -864,6 +1034,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': '区块 {n}',
|
||||
'chain.connectionLost': '与服务器的连接已断开——正在重试…',
|
||||
'maintenance.banner': '计划维护:当前回合将照常完成(包括中奖者),但下一回合要等维护结束后才会开启。',
|
||||
'maintenance.notConfigured': '本彩票尚未就绪:运营方还需完成配置,在此之前不会开启任何回合。',
|
||||
|
||||
'hero.lead': '存入 PLM,以固定金额参与本回合,若抽中你的号码即可赢得奖池。',
|
||||
'hero.step1.title': '1. 存款',
|
||||
@@ -886,6 +1057,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': '参与人数',
|
||||
'round.jackpot': '奖池',
|
||||
'round.playersPending': '+{n} 待确认',
|
||||
'round.jackpotPending': '+{amount} 待确认',
|
||||
'round.status.open': '进行中',
|
||||
'round.status.closing': '即将结束',
|
||||
'round.status.drawing': '正在开奖',
|
||||
@@ -959,8 +1132,10 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': '未连接到网络,请稍后重试。',
|
||||
'error.no_round_open': '当前没有开放的回合,请稍后重试。',
|
||||
'error.round_closing': '当前回合正在结束,请稍后重试。',
|
||||
'error.round_full': '本轮已达到 {max_participants} 名玩家的上限 —— 请等待下一轮,很快就会开始。',
|
||||
'error.already_betting': '你在当前回合已有一笔有效下注。',
|
||||
'error.insufficient_balance': '余额不足。',
|
||||
'error.balance_leaves_no_change': '您的余额与投注金额过于接近:还需要 {required_extra_plm} PLM,交易才能保留找零输出,并在网络拥堵时提高手续费重新广播。',
|
||||
'error.balance_pending_confirmation': '您有 {pending_plm} PLM 待确认 —— 尚不可用于支出。',
|
||||
'error.amount_below_network_fee': '金额太小,不足以支付网络手续费。',
|
||||
'error.invalid_address': 'PLM 地址无效(必须以 plm1q… 开头)。',
|
||||
|
||||
@@ -83,7 +83,10 @@
|
||||
|
||||
<div class="maintenance-banner hidden" id="maintenance-banner">
|
||||
<span>⚠️</span>
|
||||
<span data-i18n="maintenance.banner">Manutenzione in programma: il round in corso viene completato regolarmente (vincitore incluso), ma il round successivo non si aprirà finché la manutenzione non sarà terminata.</span>
|
||||
<!-- Filled by renderChainStatusBar(): the text depends on *why* no round will
|
||||
open — a deliberate pause, or an instance the operator hasn't finished
|
||||
configuring (B-66) — so it renders through t() and carries no data-i18n. -->
|
||||
<span id="maintenance-banner-text"></span>
|
||||
</div>
|
||||
|
||||
<section id="landing-hero" class="hero">
|
||||
@@ -150,11 +153,15 @@
|
||||
<div class="row-between" style="margin-top:10px" id="round-stats-row">
|
||||
<div>
|
||||
<div class="hint" style="margin-bottom:2px" data-i18n="round.players">Giocatori</div>
|
||||
<span class="mono" id="round-players">—</span>
|
||||
<!-- The two -pending spans are filled from server data by renderRoundStats()
|
||||
(B-65), so they carry no data-i18n: an element belongs to one
|
||||
translation mechanism or the other, never both. -->
|
||||
<span class="mono" id="round-players">—</span> <span class="pending-suffix hidden" id="round-players-pending"></span>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="hint" style="margin-bottom:2px" data-i18n="round.jackpot">Jackpot</div>
|
||||
<span class="mono" id="round-jackpot">—</span> <span class="balance-unit">PLM</span>
|
||||
<span class="pending-suffix hidden" id="round-jackpot-pending"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+159
-6
@@ -1,16 +1,169 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Segnala un bug — PLM Lottery</title>
|
||||
<title data-i18n="bugReport.pageTitle">Report a bug</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<h1>Segnala un bug</h1>
|
||||
<p>Questa pagina è un placeholder. Il modulo per la segnalazione dei bug sarà disponibile qui a breve.</p>
|
||||
<p><a class="link" href="/">← Torna alla home</a></p>
|
||||
<div class="app-shell app-shell-bugreport">
|
||||
|
||||
<div class="bugreport-topbar">
|
||||
<a class="brand" href="/">
|
||||
<img class="brand-mark" src="/logo.svg" alt="">
|
||||
PLM Lottery
|
||||
</a>
|
||||
<div class="bugreport-topbar-right">
|
||||
<a class="back-home-btn" href="/">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
<span data-i18n="bugReport.backLink">Back to home</span>
|
||||
</a>
|
||||
<select id="lang-switcher" class="lang-switcher" onchange="setLanguage(this.value)" aria-label="Language">
|
||||
<option value="en">English</option>
|
||||
<option value="it">Italiano</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bugreport-hero">
|
||||
<div class="bugreport-hero-icon">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v5"/><path d="M12 16h.01"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 data-i18n="bugReport.heading">Report a bug</h1>
|
||||
<p data-i18n="bugReport.intro">Found a problem? Describe it below — your report goes straight to the admin panel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="report-form">
|
||||
<div class="field-note" id="english-notice">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
<span data-i18n="bugReport.englishNotice">Please write your bug report in English, regardless of the language you're browsing in — this helps us handle it faster.</span>
|
||||
</div>
|
||||
|
||||
<label for="bug-description" data-i18n="bugReport.descriptionLabel">What happened?</label>
|
||||
<textarea id="bug-description" rows="6" maxlength="2000" data-i18n-placeholder="bugReport.descriptionPlaceholder" oninput="updateCharCount()"></textarea>
|
||||
<div class="char-count" id="char-count">0 / 2000</div>
|
||||
|
||||
<label for="bug-contact" data-i18n="bugReport.contactLabel">Contact (optional)</label>
|
||||
<input id="bug-contact" type="text" maxlength="256" data-i18n-placeholder="bugReport.contactPlaceholder">
|
||||
|
||||
<button onclick="submitBugReport()" id="bug-submit-btn" data-i18n="bugReport.submitBtn">Send report</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden" id="my-reports-section">
|
||||
<p class="section-label" data-i18n="bugReport.myReportsTitle">Your reports</p>
|
||||
<div class="card">
|
||||
<p class="hint" data-i18n="bugReport.myReportsHint">Only reports sent from this account, with the status set by the admin team.</p>
|
||||
<div id="my-reports-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="toast-container" aria-live="polite"></div>
|
||||
|
||||
<script src="/i18n.js"></script>
|
||||
<script>
|
||||
function toast(message, type) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
el.textContent = message;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
function updateCharCount() {
|
||||
const field = document.getElementById('bug-description');
|
||||
document.getElementById('char-count').textContent = field.value.length + ' / ' + field.maxLength;
|
||||
}
|
||||
|
||||
async function submitBugReport() {
|
||||
const btn = document.getElementById('bug-submit-btn');
|
||||
const description = document.getElementById('bug-description').value.trim();
|
||||
const contact = document.getElementById('bug-contact').value.trim();
|
||||
if (!description) {
|
||||
toast(t('bugReport.blankError'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const token = localStorage.getItem('plm_token');
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
btn.disabled = true;
|
||||
const original = btn.textContent;
|
||||
btn.textContent = t('bugReport.submitting');
|
||||
try {
|
||||
const res = await fetch('/bug-reports', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ description, contact: contact || null }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText);
|
||||
document.getElementById('bug-description').value = '';
|
||||
document.getElementById('bug-contact').value = '';
|
||||
updateCharCount();
|
||||
toast(t('bugReport.successToast'), 'success');
|
||||
loadMyBugReports();
|
||||
} catch (e) {
|
||||
toast(t('bugReport.errorPrefix') + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
applyStaticTranslations(btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyBugReports() {
|
||||
const token = localStorage.getItem('plm_token');
|
||||
const section = document.getElementById('my-reports-section');
|
||||
if (!token) {
|
||||
section.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/bug-reports/mine', { headers: { Authorization: 'Bearer ' + token } });
|
||||
if (!res.ok) {
|
||||
section.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
const reports = await res.json();
|
||||
section.classList.remove('hidden');
|
||||
const list = document.getElementById('my-reports-list');
|
||||
list.innerHTML = reports.map((r) => `
|
||||
<div class="report-row">
|
||||
<span class="badge bug-status-${escapeHtml(r.status)}">${escapeHtml(t('bugReport.status' + r.status.charAt(0).toUpperCase() + r.status.slice(1)))}</span>
|
||||
<span class="report-row-desc" title="${escapeHtml(r.description)}">${escapeHtml(r.description)}</span>
|
||||
<span class="report-row-date">${new Date(r.created_at).toLocaleDateString(currentDateLocale(), { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
||||
</div>
|
||||
`).join('') || `<p class="hint report-empty">${escapeHtml(t('bugReport.myReportsEmpty'))}</p>`;
|
||||
} catch (e) {
|
||||
section.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-renders server-rendered content (my reports list) on a language switch,
|
||||
// the same split app.js uses between data-i18n (static markup) and t() (data).
|
||||
function onLanguageChange() {
|
||||
loadMyBugReports();
|
||||
}
|
||||
|
||||
updateCharCount();
|
||||
loadMyBugReports();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+97
-3
@@ -74,16 +74,17 @@ h1, h2, h3 { font-family: inherit; letter-spacing: -0.01em; }
|
||||
label { display: block; font-size: 0.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 14px; margin-bottom: 6px; }
|
||||
label:first-child { margin-top: 0; }
|
||||
|
||||
input {
|
||||
input, textarea {
|
||||
width: 100%; min-height: 44px; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
|
||||
border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface);
|
||||
color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms;
|
||||
}
|
||||
input:focus {
|
||||
textarea { resize: vertical; }
|
||||
input:focus, textarea:focus {
|
||||
outline: none; border-color: var(--color-ring);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent);
|
||||
}
|
||||
input:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
|
||||
input:disabled, textarea:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
|
||||
|
||||
button {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
@@ -214,6 +215,12 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
|
||||
.balance-confirmed { color: var(--color-success); }
|
||||
.balance-pending { color: var(--color-primary); }
|
||||
|
||||
/* The in-flight part of the round's own figures (B-65): the players/jackpot next
|
||||
to it are the confirmed ones the draw and the payout will actually use, and this
|
||||
is what has been bet but hasn't confirmed yet. Same amber as .balance-pending,
|
||||
for the same "not settled" meaning. */
|
||||
.pending-suffix { color: var(--color-primary); font-size: 0.8rem; font-weight: 600; }
|
||||
|
||||
.icon { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
|
||||
.dash-panel { display: none; }
|
||||
@@ -253,6 +260,93 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
|
||||
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* --- /report-bug: a standalone page (no logged-in navbar), so it gets its
|
||||
own slim top bar rather than the app's bottom tab bar / sticky header. --- */
|
||||
.app-shell-bugreport { padding-bottom: 32px; }
|
||||
|
||||
.bugreport-topbar {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 4px 0 20px; margin-bottom: 20px; border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.bugreport-topbar .brand {
|
||||
display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1rem;
|
||||
letter-spacing: -0.01em; color: var(--color-foreground); text-decoration: none;
|
||||
}
|
||||
.bugreport-topbar .brand-mark { width: 26px; height: 26px; border-radius: 50%; flex-shrink: 0; display: block; }
|
||||
.bugreport-topbar-right { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
/* Pill button, same idiom as .trust-pill / .chain-status-pill elsewhere on the
|
||||
site: a bordered chip rather than a bare text link, so "go back" reads as an
|
||||
actual control instead of fading into the surrounding copy. */
|
||||
.back-home-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 0.8rem; font-weight: 500; color: var(--color-muted-foreground);
|
||||
background: var(--color-surface); border: 1px solid var(--color-border);
|
||||
padding: 6px 12px 6px 10px; border-radius: 999px; text-decoration: none;
|
||||
transition: color 150ms, border-color 150ms, background 150ms;
|
||||
}
|
||||
.back-home-btn .icon { width: 15px; height: 15px; }
|
||||
.back-home-btn:hover {
|
||||
color: var(--color-foreground); background: var(--color-surface-inset);
|
||||
border-color: color-mix(in srgb, var(--color-ring) 40%, var(--color-border));
|
||||
}
|
||||
.back-home-btn:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
|
||||
|
||||
.bugreport-hero { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; }
|
||||
.bugreport-hero-icon {
|
||||
width: 44px; height: 44px; flex-shrink: 0; border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
color: var(--color-primary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.bugreport-hero-icon .icon { width: 22px; height: 22px; }
|
||||
.bugreport-hero h1 { font-size: 1.3rem; font-weight: 700; margin: 2px 0 4px; text-wrap: balance; }
|
||||
.bugreport-hero p { color: var(--color-muted-foreground); font-size: 0.9rem; margin: 0; max-width: 46ch; }
|
||||
|
||||
/* Info callout, anchored inside the form card right above the field it
|
||||
applies to — not a warning (that's what the amber status badges below are
|
||||
for), so it gets the accent hue instead, keeping the two meanings visually
|
||||
distinct. */
|
||||
.field-note {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 28%, transparent);
|
||||
color: color-mix(in srgb, var(--color-accent) 75%, var(--color-foreground));
|
||||
border-radius: var(--radius-sm); padding: 10px 12px; font-size: 0.82rem; line-height: 1.4;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.field-note .icon { width: 16px; height: 16px; margin-top: 1px; flex-shrink: 0; }
|
||||
|
||||
.char-count {
|
||||
font-variant-numeric: tabular-nums; text-align: right;
|
||||
font-size: 0.75rem; color: var(--color-muted-foreground); margin-top: 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block; font-size: 0.72rem; font-weight: 600; padding: 2px 8px;
|
||||
border-radius: 999px; background: var(--color-background); border: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge.bug-status-open { background: color-mix(in srgb, var(--color-primary) 16%, transparent); color: #92400E; border-color: color-mix(in srgb, var(--color-primary) 55%, transparent); }
|
||||
.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); }
|
||||
.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
|
||||
|
||||
.report-row {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
|
||||
padding: 12px 0; border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.report-row:first-child { padding-top: 0; }
|
||||
.report-row:last-child { padding-bottom: 0; border-bottom: none; }
|
||||
.report-row-desc {
|
||||
flex: 1 1 200px; font-size: 0.88rem;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.report-row-date {
|
||||
font-size: 0.78rem; color: var(--color-muted-foreground); white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.report-empty { margin: 0; }
|
||||
|
||||
/* --- landing hero (shown only when logged out) --- */
|
||||
body {
|
||||
position: relative;
|
||||
|
||||
+4
-2
@@ -202,8 +202,10 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str)
|
||||
elif row.kind == "payout":
|
||||
# Payout funds come from the pool address, which isn't tracked in
|
||||
# utxo_events, so there's nothing to release. Clearing payout_txid leaves the
|
||||
# round in "paying_out" with no tx attached, which is the state an operator
|
||||
# (or a future payout-retry routine — still an open gap) can act on.
|
||||
# round in "paying_out" with no tx attached, which is exactly the state the
|
||||
# scheduler's payout retry picks up (B-26: `_retry_payout_if_due`, one attempt
|
||||
# per 60s), so an abandoned payout is rebuilt on its own rather than waiting
|
||||
# for an operator — the log line below is the alert, not the recovery path.
|
||||
round_ = await session.get(Round, row.round_id) if row.round_id else None
|
||||
if round_ is not None and round_.status == "paying_out":
|
||||
round_.payout_txid = None
|
||||
|
||||
+19
-1
@@ -38,6 +38,15 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
|
||||
user's own address. Adding that to cached_balance_sats gives the balance the
|
||||
user will end up with once everything currently in flight confirms.
|
||||
|
||||
The change output's own confirmation is credited by two independent, unordered
|
||||
paths: the Electrum listener (event-driven, near-instant — app/deposits/service.py
|
||||
turns it into a UtxoEvent and folds it into cached_balance_sats via
|
||||
recompute_balance) and this module's PendingTransaction.status flip
|
||||
(app/tx/confirmation.py, polled every 10s). The listener usually wins that race,
|
||||
so for the gap until the poller catches up the row is still "pending" here while
|
||||
the same sats are already inside cached_balance_sats — double-counting the
|
||||
change unless excluded below.
|
||||
|
||||
Returns (pending_inclusive_balance_sats, has_pending) — has_pending tells the
|
||||
caller whether this differs from the confirmed-only balance at all.
|
||||
"""
|
||||
@@ -54,10 +63,19 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
|
||||
)
|
||||
).all()
|
||||
|
||||
already_credited = {
|
||||
(txid, vout)
|
||||
for txid, vout in (
|
||||
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user.id))
|
||||
).all()
|
||||
}
|
||||
|
||||
pending_change_sats = 0
|
||||
for row in pending:
|
||||
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
|
||||
for out in tx.vout:
|
||||
for vout, out in enumerate(tx.vout):
|
||||
if (row.current_txid, vout) in already_credited:
|
||||
continue
|
||||
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
|
||||
pending_change_sats += out.value
|
||||
|
||||
|
||||
+83
-21
@@ -33,14 +33,37 @@ DUST_LIMIT_SATS = 294
|
||||
# eating further and further into the sender's change with no limit.
|
||||
MAX_FEE_RATE_SAT_VB = 10_000
|
||||
|
||||
# Ceiling on how many UTXOs one transaction may spend (B-48). Every extra input costs
|
||||
# ~68 vbytes of fee, and that fee comes out of the amount being moved — so an address
|
||||
# fragmented into hundreds of small deposits would silently erode its own bet (shrinking
|
||||
# the user's share of the pool) or withdrawal, and past a few hundred inputs the tx also
|
||||
# stops being standard and gets refused at broadcast. Failing the build with a
|
||||
# translatable error is the honest outcome; consolidating the address is the way out.
|
||||
# Ceiling on how many UTXOs one *user* transaction (bet, withdrawal) may spend (B-48).
|
||||
# Every extra input costs ~68 vbytes of fee, and that fee comes out of the amount being
|
||||
# moved — so an address fragmented into hundreds of small deposits would silently erode
|
||||
# its own bet (shrinking the user's share of the pool) or withdrawal. Failing the build
|
||||
# with a translatable error is the honest outcome; consolidating the address is the way
|
||||
# out. This is a *user-protection* limit, which is why the payout gets its own, far
|
||||
# higher one below.
|
||||
MAX_TX_INPUTS = 50
|
||||
|
||||
# Ceiling on the payout's inputs (B-52). The payout is not a user spending their own
|
||||
# fragmented balance: it drains the pool, whose UTXO count is simply the number of bets
|
||||
# in the round, and its fee comes out of a 70% share of that whole pool. So the erosion
|
||||
# argument behind MAX_TX_INPUTS doesn't apply here — 400 inputs at 1 sat/vB cost ~27_300
|
||||
# sat, i.e. ~0.00027 PLM out of the winner's share — and reusing that limit was what made
|
||||
# any round past ~50 participants unpayable: select_utxos raised too_many_inputs, the
|
||||
# round stayed "paying_out" retrying forever, and since no new round may open while one
|
||||
# is active, the whole lottery stopped with the pool stuck (B-52).
|
||||
#
|
||||
# What actually bounds this is relay policy: a non-standard transaction is refused at
|
||||
# broadcast past 100 kvB, which at ~68 vbytes per input is ~1470 inputs. 500 stays at
|
||||
# roughly a third of that budget, and signing that many inputs costs ~0.4s of event loop
|
||||
# (measured), once per round, inside a background task.
|
||||
MAX_PAYOUT_TX_INPUTS = 500
|
||||
|
||||
# The most participants one round may hold (B-52). Enforced where the money is not yet
|
||||
# committed — app/bets/service.py refuses the bet — instead of being discovered at payout
|
||||
# time, when the bets are already in the pool and there is no way back. Deliberately below
|
||||
# MAX_PAYOUT_TX_INPUTS: the payout also has to be able to spend whatever change UTXOs
|
||||
# earlier rounds left in the pool, so the gap is the headroom for those.
|
||||
MAX_PARTICIPANTS_PER_ROUND = 400
|
||||
|
||||
|
||||
class InsufficientFundsError(Exception):
|
||||
"""`code` is the machine-readable identifier the API layer forwards to the
|
||||
@@ -80,24 +103,32 @@ def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
|
||||
return _TX_OVERHEAD_VBYTES + n_inputs * _P2WPKH_INPUT_VBYTES + n_outputs * _P2WPKH_OUTPUT_VBYTES
|
||||
|
||||
|
||||
def select_utxos(utxos: list[Utxo], target_sats: int) -> tuple[list[Utxo], int]:
|
||||
def select_utxos(
|
||||
utxos: list[Utxo], target_sats: int, max_inputs: int = MAX_TX_INPUTS
|
||||
) -> tuple[list[Utxo], int]:
|
||||
"""Greedily select UTXOs (largest first, to minimize input count) covering
|
||||
target_sats — the amount deducted from the sender's balance. The fee is paid
|
||||
out of target_sats (see build_signed_transaction), not added on top of it.
|
||||
|
||||
At most MAX_TX_INPUTS are ever selected (B-48): if the largest MAX_TX_INPUTS
|
||||
At most `max_inputs` are ever selected (B-48): if the largest `max_inputs`
|
||||
UTXOs don't cover the target, the balance is there but too fragmented to spend
|
||||
in one transaction, which is a different failure from having no funds at all
|
||||
and gets its own code."""
|
||||
and gets its own code.
|
||||
|
||||
The cap is a parameter, not the constant it used to be, because the two callers
|
||||
want different ones (B-52): MAX_TX_INPUTS protects a user from a fee that would
|
||||
eat into their own bet/withdrawal, while the payout drains a pool holding one
|
||||
UTXO per bet and needs MAX_PAYOUT_TX_INPUTS to be able to pay a full round at
|
||||
all."""
|
||||
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
|
||||
selected: list[Utxo] = []
|
||||
total = 0
|
||||
for utxo in ordered:
|
||||
if len(selected) == MAX_TX_INPUTS:
|
||||
if len(selected) == max_inputs:
|
||||
raise InsufficientFundsError(
|
||||
f"balance too fragmented: more than {MAX_TX_INPUTS} inputs would be needed",
|
||||
f"balance too fragmented: more than {max_inputs} inputs would be needed",
|
||||
code="too_many_inputs",
|
||||
max_inputs=MAX_TX_INPUTS,
|
||||
max_inputs=max_inputs,
|
||||
)
|
||||
selected.append(utxo)
|
||||
total += utxo.amount_sats
|
||||
@@ -115,6 +146,7 @@ def build_signed_transaction(
|
||||
amount_sats: int,
|
||||
change_address: str,
|
||||
fee_rate_sat_vb: int,
|
||||
reduce_amount_to_keep_change: bool = False,
|
||||
) -> BuiltTransaction:
|
||||
"""Build, sign and finalize a single-recipient P2WPKH transaction with change
|
||||
back to change_address.
|
||||
@@ -124,21 +156,47 @@ def build_signed_transaction(
|
||||
spec everywhere a single-recipient tx is used (bet, withdrawal): "fee deducted
|
||||
from the amount being moved", not paid on top by the sender.
|
||||
|
||||
A change amount below DUST_LIMIT_SATS is dropped and left to the fee — paying it
|
||||
back to ourselves would produce an unrelayable transaction. The fee estimate
|
||||
already assumes two outputs, so dropping one never underpays.
|
||||
B-62: the transaction always keeps a change output of at least DUST_LIMIT_SATS.
|
||||
Change used to be folded into the fee whenever it came out below the dust limit,
|
||||
which for an amount equal to the whole input total (the UI's "withdraw
|
||||
everything" checkbox, or a bet from a balance exactly equal to the bet amount)
|
||||
produced a single-output transaction — and `tx/broadcast.py:bump_fee` has nothing
|
||||
to shrink there, so it raised RbfError every 30s until the reconciler abandoned
|
||||
the row hours later. Adding inputs instead is no answer for this case in
|
||||
particular: the transaction already spends every UTXO the sender has.
|
||||
|
||||
What happens when the change would be too small depends on who's asking, hence
|
||||
`reduce_amount_to_keep_change`:
|
||||
|
||||
- withdrawals pass True — the amount moved is reduced just enough to leave a
|
||||
dust-limit change output. The fee already comes out of the withdrawn amount by
|
||||
design, so this is the same rule applied a little harder, and the caller
|
||||
records what was actually sent (`Withdrawal.amount_sent_sats`).
|
||||
- bets pass False (the default) and get an InsufficientFundsError instead: the
|
||||
bet is a fixed price that cannot be quietly reduced, and "a user's balance must
|
||||
never exactly equal the bet" is a documented invariant of the PLAY phase. The
|
||||
player needs a little more than the bet amount, which is what the error says.
|
||||
"""
|
||||
selected, total_in = select_utxos(utxos, amount_sats)
|
||||
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
|
||||
|
||||
change = total_in - amount_sats
|
||||
if change < DUST_LIMIT_SATS:
|
||||
if not reduce_amount_to_keep_change:
|
||||
raise InsufficientFundsError(
|
||||
f"the amount leaves no change output: {DUST_LIMIT_SATS - change} more sats are "
|
||||
"needed for the transaction to stay fee-bumpable",
|
||||
code="balance_leaves_no_change",
|
||||
required_extra_sats=DUST_LIMIT_SATS - change,
|
||||
)
|
||||
amount_sats -= DUST_LIMIT_SATS - change
|
||||
change = DUST_LIMIT_SATS
|
||||
|
||||
recipient_amount = amount_sats - fee
|
||||
if recipient_amount <= 0:
|
||||
raise InsufficientFundsError(
|
||||
"amount too small to cover the network fee", code="amount_below_network_fee"
|
||||
)
|
||||
change = total_in - amount_sats
|
||||
if change < DUST_LIMIT_SATS:
|
||||
fee += change # dust change is unspendable and unrelayable — miners get it
|
||||
change = 0
|
||||
if recipient_amount < DUST_LIMIT_SATS:
|
||||
raise InsufficientFundsError(
|
||||
"amount too small to be sent (dust)", code="amount_below_dust_limit"
|
||||
@@ -203,9 +261,13 @@ def build_payout_transaction(
|
||||
absorbs the tx fee — the commission (fee_address) output is untouched.
|
||||
|
||||
As in build_signed_transaction, dust-sized pool change is left to the fee
|
||||
rather than creating an unrelayable output (B-06)."""
|
||||
rather than creating an unrelayable output (B-06).
|
||||
|
||||
Selection uses MAX_PAYOUT_TX_INPUTS, not the much stricter user-facing
|
||||
MAX_TX_INPUTS (B-52) — the pool holds one UTXO per bet, so the user-protection
|
||||
cap made every round past ~50 participants impossible to pay."""
|
||||
target = winner_share_sats + commission_sats
|
||||
selected, total_in = select_utxos(utxos, target)
|
||||
selected, total_in = select_utxos(utxos, target, max_inputs=MAX_PAYOUT_TX_INPUTS)
|
||||
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
|
||||
winner_amount = winner_share_sats - fee
|
||||
if winner_amount < DUST_LIMIT_SATS:
|
||||
|
||||
@@ -87,6 +87,13 @@ async def request_withdrawal(
|
||||
amount_sats=amount_sats,
|
||||
change_address=user.address,
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
# B-62: "withdraw everything" asks for the whole confirmed balance, which
|
||||
# would leave no change output and therefore nothing bump_fee could
|
||||
# shrink — the one tx shape RBF cannot rescue, and the UI's default
|
||||
# withdrawal path at that. Move a dust limit less instead of producing an
|
||||
# unbumpable transaction; amount_sent_sats below records what actually
|
||||
# went out, which is already how a fee-deducted withdrawal is reported.
|
||||
reduce_amount_to_keep_change=True,
|
||||
)
|
||||
except InsufficientFundsError as exc:
|
||||
raise WithdrawalError(exc.code, str(exc), **exc.params) from exc
|
||||
|
||||
+10
-1
@@ -36,10 +36,18 @@ business — quelli si toccano solo da qui.
|
||||
|
||||
| Campo | Significato |
|
||||
|---|---|
|
||||
| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: i payout **non partono** se questo campo è vuoto. |
|
||||
| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: finché è vuoto **non si apre nessun round** (il payout non sarebbe costruibile, quindi il round accetterebbe scommesse per poi restare bloccato con i soldi già nel montepremi). Il pannello lo segnala con un avviso in cima ai Parametri, e la pagina utente mostra un banner "lotteria non ancora pronta". Un round già in corso non viene interrotto se svuoti il campo: chiude, estrae e paga normalmente. |
|
||||
| **Bet amount (PLM)** | Il costo fisso d'ingresso per round. È anche l'importo minimo prelevabile: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). |
|
||||
| **Durata round (secondi)** | Quanto resta aperto un round prima di chiudersi ed estrarre il vincitore. Il taglio per le nuove giocate scatta esattamente allo scadere di questo tempo (verificato ad ogni bet, non dipende dal ciclo dello scheduler) — è un "semaforo giallo": nessuna nuova entrata, ma le bet già trasmesse prima dello scadere hanno comunque tempo di confermarsi prima che il round chiuda ed estragga. |
|
||||
| **Pausa tra un round e il successivo (secondi)** | Cooldown dopo la chiusura di un round, prima che il successivo si apra — dà tempo ai giocatori di vedere l'esito. |
|
||||
|
||||
Durata e cooldown si applicano **dal round successivo**, non a quello già in
|
||||
corso: ogni round si porta dietro i valori con cui è stato aperto, così
|
||||
abbassare la durata mentre un round è a metà non lo chiude di colpo, e alzarla
|
||||
non sposta il countdown che i giocatori stanno già guardando. Gli altri
|
||||
parametri (bet amount, fee rate, RBF timeout) restano invece a effetto
|
||||
immediato.
|
||||
|
||||
| **Durata animazione estrazione (secondi)** | Tempo minimo per cui la dashboard di ogni utente mostra l'animazione "Estrazione in corso" dopo la chiusura del round, prima di rivelare il vincitore. È solo un minimo: il processo reale aspetta fino a 3 blocchi confermati in sequenza (ultima bet in sospeso, estrazione, payout — ~2 minuti l'uno), quindi l'animazione può durare più a lungo di questo valore, mai meno. |
|
||||
| **Fee rate di rete (sat/vB)** | Fee per byte usata per costruire bet, payout e prelievi. |
|
||||
| **Timeout prima del fee-bump RBF (secondi)** | Dopo quanto tempo senza conferma una transazione viene ritrasmessa con fee più alta. |
|
||||
@@ -116,6 +124,7 @@ Eventi a cui vale la pena prestare attenzione:
|
||||
| `bet_broadcast_failed` / `withdrawal_broadcast_failed` | La rete ha rifiutato la transazione. Non è stato speso nulla: gli UTXO sono stati liberati e il saldo dell'utente è tornato come prima. |
|
||||
| `pending_tx_abandoned` | Una transazione trasmessa è scomparsa dalla catena e il sistema l'ha dichiarata persa: UTXO liberati, bet rimossa o prelievo segnato `failed`. Se capita spesso, la fee rate configurata è probabilmente troppo bassa. |
|
||||
| `pending_tx_recovered` | Una transazione che si credeva incompleta è invece finita in catena (tipicamente dopo un riavvio a metà invio) e il sistema l'ha ripresa da sé. |
|
||||
| `bug_report_status_changed` | Un admin ha cambiato lo stato di una segnalazione (payload: `report_id`, stato precedente e nuovo). Con un token admin unico e condiviso, questa riga è l'unica traccia di chi tocca le segnalazioni. |
|
||||
| `payout_failed` | Il payout di un round non è partito. Il round resta in `paying_out` e **richiede intervento manuale**: non esiste un retry automatico. Controlla `fee_address`, il saldo dell'indirizzo pool e la connessione Electrum. |
|
||||
|
||||
## Alternative all'interfaccia grafica
|
||||
|
||||
@@ -128,6 +128,12 @@ scalata dall'importo richiesto (non si aggiunge separatamente). L'importo
|
||||
minimo prelevabile è pari alla quota fissa di ingresso al round (mostrata
|
||||
nella sezione Bet).
|
||||
|
||||
Con "Preleva l'intero importo" restano sul tuo saldo pochi satoshi (294, cioè
|
||||
0,00000294 PLM): senza quel resto la transazione non potrebbe essere
|
||||
ritrasmessa con una fee più alta se la rete fosse lenta, e resterebbe bloccata
|
||||
per ore. La cifra effettivamente inviata è quindi il saldo meno quei satoshi e
|
||||
meno la fee di rete.
|
||||
|
||||
> **Nota**: attualmente è supportato solo l'indirizzo esterno in formato
|
||||
> **P2WPKH bech32** (quelli che iniziano con `plm1q...`). Non inserire
|
||||
> indirizzi legacy (quelli che iniziano con `P...`) o P2SH: al momento
|
||||
|
||||
@@ -36,7 +36,7 @@ flowchart LR
|
||||
|
||||
subgraph WITHDRAW["FASE 5 - Prelievo"]
|
||||
direction TB
|
||||
E1["L'utente richiede un prelievo:\nindirizzo esterno + importo\n(non puo' avvenire insieme\na una scommessa in corso)"] --> E2["Si prepara e firma la transazione:\ndal suo indirizzo verso\nl'indirizzo esterno indicato\n(con resto che torna a lui)"]
|
||||
E1["L'utente richiede un prelievo:\nindirizzo esterno + importo\n(spende solo saldo confermato\ne non ancora impegnato: una scommessa\nin attesa di conferma non lo blocca,\nma le due operazioni non vengono\nmai preparate nello stesso momento)"] --> E2["Si prepara e firma la transazione:\ndal suo indirizzo verso\nl'indirizzo esterno indicato\n(con resto che torna a lui)"]
|
||||
E2 --> E3["Transazione inviata\nalla rete"]
|
||||
E3 --> E4{"Confermata?"}
|
||||
E4 -- "No, troppo tempo" --> E5["Si aumenta la commissione\ne si reinvia"]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""snapshot round timing onto the round row (B-61)
|
||||
|
||||
Revision ID: 283844a44b4a
|
||||
Revises: c1d4a97b5e10
|
||||
Create Date: 2026-08-03 23:05:04.996492
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '283844a44b4a'
|
||||
down_revision: Union[str, Sequence[str], None] = 'c1d4a97b5e10'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column('rounds', sa.Column('duration_seconds', sa.Integer(), server_default='600', nullable=False))
|
||||
op.add_column('rounds', sa.Column('cooldown_seconds', sa.Integer(), server_default='30', nullable=False))
|
||||
|
||||
# Backfill from the live config rather than leaving the column defaults: an
|
||||
# instance running with, say, a 300s round would otherwise see every existing
|
||||
# row — including the round currently in progress — jump to 600s the moment
|
||||
# this migration lands, which is exactly the retroactive change B-61 is about.
|
||||
op.execute(
|
||||
"UPDATE rounds SET "
|
||||
"duration_seconds = coalesce((SELECT round_duration_seconds FROM round_config LIMIT 1), 600), "
|
||||
"cooldown_seconds = coalesce((SELECT round_cooldown_seconds FROM round_config LIMIT 1), 30)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('rounds', 'cooldown_seconds')
|
||||
op.drop_column('rounds', 'duration_seconds')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,41 @@
|
||||
"""replace bug_reports.resolved with a three-state status
|
||||
|
||||
Revision ID: be71fdac734e
|
||||
Revises: ee8508d98d34
|
||||
Create Date: 2026-07-31 15:33:51.780062
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'be71fdac734e'
|
||||
down_revision: Union[str, Sequence[str], None] = 'ee8508d98d34'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema, preserving existing rows: resolved=True -> 'resolved', else 'open'.
|
||||
|
||||
'read' has no equivalent in the old boolean, so nothing backfills into it —
|
||||
every previously-open report starts the new lifecycle at 'open', which is
|
||||
correct (nobody had acknowledged it yet)."""
|
||||
op.add_column('bug_reports', sa.Column('status', sa.String(length=16), nullable=True))
|
||||
op.execute("UPDATE bug_reports SET status = CASE WHEN resolved THEN 'resolved' ELSE 'open' END")
|
||||
with op.batch_alter_table('bug_reports') as batch_op:
|
||||
batch_op.alter_column('status', nullable=False)
|
||||
batch_op.drop_column('resolved')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema. 'read' collapses back into resolved=False — the same loss
|
||||
of information any boolean-from-enum downgrade has."""
|
||||
op.add_column('bug_reports', sa.Column('resolved', sa.BOOLEAN(), nullable=True))
|
||||
op.execute("UPDATE bug_reports SET resolved = (status = 'resolved')")
|
||||
with op.batch_alter_table('bug_reports') as batch_op:
|
||||
batch_op.alter_column('resolved', nullable=False)
|
||||
batch_op.drop_column('status')
|
||||
@@ -0,0 +1,46 @@
|
||||
"""case-insensitive usernames (B-57)
|
||||
|
||||
Revision ID: c1d4a97b5e10
|
||||
Revises: be71fdac734e
|
||||
Create Date: 2026-08-03 18:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c1d4a97b5e10'
|
||||
down_revision: Union[str, Sequence[str], None] = 'be71fdac734e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# The index cannot be created while two accounts differ only by case, and
|
||||
# nothing here may guess which of them is the "real" one: both are custodial
|
||||
# accounts that may hold funds, so merging or renaming one automatically would
|
||||
# be the migration silently deciding who owns what. Fail loudly instead, naming
|
||||
# the collisions, and let the operator rename one account (and tell that user)
|
||||
# before retrying. The container runs `alembic upgrade head` at startup, so this
|
||||
# surfaces as a refusal to start rather than as a half-applied schema.
|
||||
collisions = op.get_bind().exec_driver_sql(
|
||||
"SELECT group_concat(username, ', ') FROM users "
|
||||
"GROUP BY lower(username) HAVING count(*) > 1"
|
||||
).fetchall()
|
||||
if collisions:
|
||||
groups = "; ".join(row[0] for row in collisions)
|
||||
raise RuntimeError(
|
||||
"cannot enforce case-insensitive usernames: these accounts differ only "
|
||||
f"by case and must be resolved by hand first — {groups}"
|
||||
)
|
||||
|
||||
op.create_index("ix_users_username_lower", "users", [sa.text("lower(username)")], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index("ix_users_username_lower", table_name="users")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add bug_reports table
|
||||
|
||||
Revision ID: ee8508d98d34
|
||||
Revises: 87a0c640355c
|
||||
Create Date: 2026-07-31 15:14:14.288552
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ee8508d98d34'
|
||||
down_revision: Union[str, Sequence[str], None] = '87a0c640355c'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('bug_reports',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('contact', sa.String(length=256), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('resolved', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('bug_reports')
|
||||
# ### end Alembic commands ###
|
||||
+81
-1
@@ -112,7 +112,9 @@ async def test_successful_login_resets_the_username_bucket(client):
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
async def test_registration_is_rate_limited_per_ip(client):
|
||||
async def test_registration_is_quota_limited_per_ip(client): # B-58
|
||||
"""Five accounts per IP per hour. The sixth is told to wait, not punished with a
|
||||
backoff that doubles from there."""
|
||||
for i in range(5):
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": f"user{i}", "password": "a-strong-password"}
|
||||
@@ -124,3 +126,81 @@ async def test_registration_is_rate_limited_per_ip(client):
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
assert resp.json()["detail"]["code"] == "rate_limited"
|
||||
|
||||
|
||||
# --- B-57: usernames are one namespace, case included ---------------------------
|
||||
|
||||
|
||||
async def test_registration_refuses_a_username_differing_only_by_case(client):
|
||||
""""Bob" and "bob" used to be two accounts. On a custodial system that's an
|
||||
impersonation vector — and the two also shared a single rate-limit bucket, since
|
||||
the throttle key has always been lowercased, so each could lock the other out."""
|
||||
await _register(client, username="Bob")
|
||||
|
||||
resp = await client.post("/auth/register", json={"username": "bob", "password": "another-password"})
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["code"] == "username_taken"
|
||||
|
||||
|
||||
async def test_login_accepts_the_username_in_any_case(client):
|
||||
"""The flip side of the same rule: one account, reachable however it's typed."""
|
||||
await _register(client, username="Alice", password="original-password")
|
||||
|
||||
resp = await client.post("/auth/login", json={"username": "ALICE", "password": "original-password"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["access_token"]
|
||||
|
||||
|
||||
async def test_the_database_itself_rejects_a_case_variant(client):
|
||||
"""Not just the pre-check in the handler: two requests racing between the SELECT
|
||||
and the INSERT must still leave only one account, which is what the unique index
|
||||
on lower(username) guarantees."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.db.base import AsyncSessionLocal
|
||||
from app.db.models import User
|
||||
|
||||
await _register(client, username="Carol")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
session.add(
|
||||
User(username="CAROL", password_hash="x", derivation_index=999, address="plm1-unused")
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_failed_registrations_do_not_consume_the_quota(client): # B-58
|
||||
"""The limit is on accounts that exist, not on requests: record_failure used to
|
||||
fire on every attempt, so five signups — successful ones included — locked the
|
||||
sixth real user out for up to 600s from a shared or NAT address. Attempts that
|
||||
create nothing must leave the quota untouched."""
|
||||
await _register(client, username="taken")
|
||||
|
||||
for _ in range(10):
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": "taken", "password": "a-strong-password"}
|
||||
)
|
||||
assert resp.status_code == 409 # username_taken, no account created
|
||||
|
||||
# Four slots left out of five, all still usable.
|
||||
for i in range(4):
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": f"genuine{i}", "password": "a-strong-password"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
async def test_the_quota_reports_how_long_to_wait(client): # B-58
|
||||
for i in range(5):
|
||||
await _register(client, username=f"quotauser{i}", password="a-strong-password")
|
||||
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": "one-too-many", "password": "a-strong-password"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 429
|
||||
retry_after = resp.json()["detail"]["params"]["retry_after_seconds"]
|
||||
assert 0 < retry_after <= 3601 # bounded by the window, not by a growing penalty
|
||||
|
||||
@@ -5,11 +5,14 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from app.bets.service import place_bet
|
||||
from app.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.models import PendingTransaction, User, UtxoEvent
|
||||
from app.db.models import PendingTransaction, RoundConfig, User, UtxoEvent
|
||||
from app.wallet.balance import compute_pending_balance, recompute_balance
|
||||
from app.wallet.hd import derive_user_address
|
||||
|
||||
|
||||
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
||||
|
||||
|
||||
class FakeElectrumClient:
|
||||
async def broadcast(self, raw_tx_hex: str) -> str:
|
||||
return "fake-network-txid"
|
||||
@@ -31,6 +34,13 @@ async def session_factory(tmp_path, monkeypatch):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# B-66: a round only opens on an instance that could actually pay a winner, so
|
||||
# every test that expects one needs a fee address configured — the column has no
|
||||
# default on purpose (an operator must set their own).
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||
session.add(RoundConfig(fee_address=_FEE_ADDRESS))
|
||||
await session.commit()
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
hd._account_key = None
|
||||
@@ -91,6 +101,54 @@ async def test_pending_balance_matches_confirmed_when_nothing_in_flight(session_
|
||||
assert pending_balance == 2_000_000_000
|
||||
|
||||
|
||||
async def test_pending_balance_does_not_double_count_change_already_credited(session_factory):
|
||||
"""The Electrum listener (event-driven) and the confirmation poller (10s
|
||||
cadence) independently react to the same change output confirming. When the
|
||||
listener wins that race — the common case — the change is already a
|
||||
UtxoEvent inside cached_balance_sats while the PendingTransaction row is
|
||||
still "pending". compute_pending_balance must not add the change a second
|
||||
time in that window."""
|
||||
user_id = await _make_funded_user(session_factory, 4, 1_500_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
await place_bet(session, client, user)
|
||||
|
||||
async with session_factory() as session:
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
from embit.transaction import Transaction
|
||||
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
|
||||
tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex))
|
||||
change_vout, change_out = next(
|
||||
(i, out) for i, out in enumerate(tx.vout) if out.script_pubkey.address(network=PLM_MAINNET) == user.address
|
||||
)
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
# Simulate the listener having already credited the change output as
|
||||
# confirmed, before the poller has flipped `pending.status`.
|
||||
session.add(
|
||||
UtxoEvent(
|
||||
user_id=user_id,
|
||||
txid=pending.current_txid,
|
||||
vout=change_vout,
|
||||
amount_sats=change_out.value,
|
||||
confirmed_height=101,
|
||||
)
|
||||
)
|
||||
await recompute_balance(session, user_id)
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
pending_balance, has_pending = await compute_pending_balance(session, user)
|
||||
|
||||
assert has_pending is True # the PendingTransaction row is still "pending"
|
||||
assert pending_balance == user.cached_balance_sats # already-credited change isn't added again
|
||||
|
||||
|
||||
async def test_pending_balance_ignores_other_users_pending_transactions(session_factory):
|
||||
user_id = await _make_funded_user(session_factory, 2, 2_000_000_000)
|
||||
other_user_id = await _make_funded_user(session_factory, 3, 1_500_000_000)
|
||||
|
||||
+182
-3
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.bets.service import BetError, place_bet
|
||||
@@ -11,7 +11,10 @@ from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, Roun
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.service import open_new_round_if_needed
|
||||
from app.wallet.hd import derive_user_address
|
||||
from app.wallet.psbt_builder import MAX_TX_INPUTS
|
||||
from app.wallet.psbt_builder import MAX_PARTICIPANTS_PER_ROUND, MAX_TX_INPUTS
|
||||
|
||||
|
||||
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
||||
|
||||
|
||||
class FakeElectrumClient:
|
||||
@@ -35,6 +38,13 @@ async def session_factory(tmp_path, monkeypatch):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# B-66: a round only opens on an instance that could actually pay a winner, so
|
||||
# every test that expects one needs a fee address configured — the column has no
|
||||
# default on purpose (an operator must set their own).
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||
session.add(RoundConfig(fee_address=_FEE_ADDRESS))
|
||||
await session.commit()
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
hd._account_key = None
|
||||
@@ -122,6 +132,74 @@ async def test_place_bet_reports_a_too_fragmented_balance_distinctly(session_fac
|
||||
assert not client.broadcasted
|
||||
|
||||
|
||||
async def _fill_round_with_participants(session_factory, round_id: int, count: int) -> None:
|
||||
"""Participant rows only, no real bets: what the cap counts is rows, and building
|
||||
`count` genuine transactions would just make the test slow without exercising
|
||||
anything the other tests don't already cover."""
|
||||
async with session_factory() as session:
|
||||
for i in range(count):
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=round_id,
|
||||
user_id=10_000 + i, # placeholder ids; the cap check never joins users
|
||||
bet_amount_sats=1_000_000_000,
|
||||
bet_txid=f"{i:064x}",
|
||||
status="confirmed",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_place_bet_rejects_the_bet_past_the_participant_cap(session_factory): # B-52
|
||||
"""The payout has to spend one pool UTXO per bet, so a round is only ever allowed
|
||||
to grow to what a single payout transaction can drain. Enforced here, before the
|
||||
player's money moves — not discovered at payout time, when the bets are already in
|
||||
the pool and the round can no longer be paid at all."""
|
||||
user_id = await _make_funded_user(session_factory, 30, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
round_id = round_.id
|
||||
await _fill_round_with_participants(session_factory, round_id, MAX_PARTICIPANTS_PER_ROUND)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, client, user)
|
||||
|
||||
assert excinfo.value.code == "round_full"
|
||||
assert excinfo.value.params == {"max_participants": MAX_PARTICIPANTS_PER_ROUND}
|
||||
assert not client.broadcasted
|
||||
|
||||
# Refused cleanly: no participant row, and the user's UTXO is still spendable.
|
||||
async with session_factory() as session:
|
||||
assert await session.scalar(
|
||||
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_id)
|
||||
) == MAX_PARTICIPANTS_PER_ROUND
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
assert utxo.spent_txid is None
|
||||
|
||||
|
||||
async def test_place_bet_still_accepts_the_last_slot_under_the_cap(session_factory): # B-52
|
||||
user_id = await _make_funded_user(session_factory, 31, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
round_id = round_.id
|
||||
await _fill_round_with_participants(session_factory, round_id, MAX_PARTICIPANTS_PER_ROUND - 1)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(session, client, user)
|
||||
|
||||
assert participant.status == "broadcast"
|
||||
assert client.broadcasted
|
||||
|
||||
|
||||
async def test_place_bet_rejects_second_bet_same_round(session_factory):
|
||||
user_id = await _make_funded_user(session_factory, 2, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
@@ -149,7 +227,8 @@ async def test_place_bet_rejects_after_timer_expires_even_if_still_open(session_
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address="", round_duration_seconds=60))
|
||||
config = (await session.scalars(select(RoundConfig))).one() # seeded by the fixture
|
||||
config.round_duration_seconds = 60
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
round_.opened_at = datetime.now(timezone.utc) - timedelta(seconds=61)
|
||||
await session.commit()
|
||||
@@ -260,3 +339,103 @@ async def test_bet_is_persisted_before_it_is_broadcast(session_factory):
|
||||
|
||||
assert seen["pending"] == [("bet", "building")]
|
||||
assert seen["participants"] == ["building"]
|
||||
|
||||
|
||||
# --- B-53: a bet must never pay into the pool of a round it was left out of ------
|
||||
|
||||
|
||||
async def _assert_bet_left_no_trace(session_factory, user_id: int, balance_before: int) -> None:
|
||||
async with session_factory() as session:
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
assert utxo.spent_txid is None # nothing reserved, so the user can bet next round
|
||||
assert (await session.scalars(select(RoundParticipant))).all() == []
|
||||
assert (await session.scalars(select(PendingTransaction))).all() == []
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == balance_before # the rollback undid the recompute too
|
||||
|
||||
|
||||
async def test_place_bet_refuses_when_the_round_closed_between_the_check_and_the_commit(
|
||||
session_factory, monkeypatch
|
||||
): # B-53
|
||||
"""The scheduler flips "open" -> "closing" in a transaction of its own and only
|
||||
then counts in-flight bets. A bet whose deadline check passed just before that
|
||||
flip must not be able to commit its participant row afterwards: it would be
|
||||
excluded from the draw (only "confirmed" participants are drawn) while its sats
|
||||
still landed in the pool address — credited to no round, with no refund path.
|
||||
|
||||
round_accepts_bets is forced to pass so the refusal can only come from the
|
||||
compare-and-set on the round row, which is the part that survives the race the
|
||||
wall-clock check cannot see."""
|
||||
user_id = await _make_funded_user(session_factory, 40, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
round_id = round_.id
|
||||
|
||||
monkeypatch.setattr("app.bets.service.round_accepts_bets", lambda *args, **kwargs: True)
|
||||
|
||||
async with session_factory() as session:
|
||||
# What the scheduler's own tick would have committed a moment earlier.
|
||||
(await session.get(Round, round_id)).status = "closing"
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
balance_before = user.cached_balance_sats
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, client, user)
|
||||
|
||||
assert excinfo.value.code == "round_closing"
|
||||
assert not client.broadcasted # refused before any money moved
|
||||
await _assert_bet_left_no_trace(session_factory, user_id, balance_before)
|
||||
|
||||
|
||||
async def test_place_bet_rechecks_the_deadline_after_building_the_transaction(
|
||||
session_factory, monkeypatch
|
||||
): # B-53
|
||||
"""The first deadline check happens before the UTXO scan and the signing, so a
|
||||
slow build could carry a bet past the round's deadline. It is re-checked against
|
||||
the clock as it is at commit time."""
|
||||
user_id = await _make_funded_user(session_factory, 41, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
|
||||
checks: list[bool] = []
|
||||
|
||||
def _accepts_then_expires(*args, **kwargs) -> bool:
|
||||
checks.append(True)
|
||||
return len(checks) == 1 # open when the bet arrived, expired by the time it was built
|
||||
|
||||
monkeypatch.setattr("app.bets.service.round_accepts_bets", _accepts_then_expires)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
balance_before = user.cached_balance_sats
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, client, user)
|
||||
|
||||
assert len(checks) == 2 # the re-check really ran
|
||||
assert excinfo.value.code == "round_closing"
|
||||
assert not client.broadcasted
|
||||
await _assert_bet_left_no_trace(session_factory, user_id, balance_before)
|
||||
|
||||
|
||||
async def test_place_bet_still_succeeds_while_the_round_is_open(session_factory): # B-53
|
||||
"""The guard must not refuse the normal path: an open, in-time round still takes
|
||||
bets, and the round's status is left untouched by the compare-and-set."""
|
||||
user_id = await _make_funded_user(session_factory, 42, 3_000_000_000)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(session, client, user)
|
||||
|
||||
assert participant.status == "broadcast"
|
||||
async with session_factory() as session:
|
||||
round_ = (await session.scalars(select(Round))).one()
|
||||
assert round_.status == "open"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from embit import script
|
||||
@@ -215,24 +216,27 @@ async def test_bump_fee_leaves_broadcast_at_untouched(session_factory):
|
||||
|
||||
|
||||
async def test_bump_fee_raises_when_no_change_output(session_factory):
|
||||
"""The guard still matters after B-62 even though the builder no longer produces
|
||||
this shape: a single-output transaction broadcast before that change can still be
|
||||
sitting in `pending` across the deploy, and it must fail loudly rather than
|
||||
silently shrink the recipient's output. Hence a hand-built tx here — the point is
|
||||
exactly that build_signed_transaction won't make one any more."""
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
|
||||
from app.wallet.hd import derive_user_address, derive_user_key
|
||||
from app.wallet.psbt_builder import RBF_SEQUENCE
|
||||
|
||||
signer = derive_user_key(0)
|
||||
my_address = derive_user_address(0)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
to_address = script.p2wpkh(_key(98).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
utxo_amount = 10_000_000 # exact amount, no change output
|
||||
utxo_amount = 10_000_000 # entirely consumed by the single recipient output
|
||||
utxo_txid = "22" * 32
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
|
||||
to_address=to_address,
|
||||
amount_sats=10_000_000,
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
legacy_tx = Transaction(
|
||||
vin=[TransactionInput(bytes.fromhex(utxo_txid), 0, sequence=RBF_SEQUENCE)],
|
||||
vout=[TransactionOutput(utxo_amount - 141, script.Script.from_address(to_address))],
|
||||
)
|
||||
built = SimpleNamespace(raw_hex=legacy_tx.serialize().hex(), txid=legacy_tx.txid().hex())
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(username="bob", password_hash="x", derivation_index=0, address=my_address)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
|
||||
monkeypatch.setattr(settings, "admin_token", "test-admin-token")
|
||||
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
|
||||
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
|
||||
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
||||
|
||||
import app.wallet.hd as hd
|
||||
|
||||
hd._account_key = None
|
||||
hd.generate_master_key()
|
||||
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db import base as db_base
|
||||
|
||||
import app.db.models # noqa: F401
|
||||
|
||||
db_base.engine = create_async_engine(settings.database_url)
|
||||
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
|
||||
|
||||
from app.db import session as db_session
|
||||
|
||||
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
|
||||
|
||||
async with db_base.engine.begin() as conn:
|
||||
await conn.run_sync(db_base.Base.metadata.create_all)
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.routes.admin import router as admin_router
|
||||
from app.api.routes.bug_reports import router as bug_reports_router
|
||||
from app.auth.routes import router as auth_router
|
||||
from app.electrum.listener import ElectrumListener
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router)
|
||||
app.include_router(bug_reports_router)
|
||||
app.include_router(admin_router)
|
||||
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
await db_base.engine.dispose()
|
||||
|
||||
|
||||
_ADMIN_HEADERS = {"X-Admin-Token": "test-admin-token"}
|
||||
|
||||
|
||||
async def _register(client, username="alice", password="original-password"):
|
||||
resp = await client.post("/auth/register", json={"username": username, "password": password})
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
async def test_anonymous_bug_report_has_no_user(client):
|
||||
resp = await client.post("/bug-reports", json={"description": "the bet button does nothing"})
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = await client.get("/admin/bug-reports", headers=_ADMIN_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
reports = resp.json()
|
||||
assert len(reports) == 1
|
||||
assert reports[0]["description"] == "the bet button does nothing"
|
||||
assert reports[0]["user_id"] is None
|
||||
assert reports[0]["username"] is None
|
||||
assert reports[0]["status"] == "open"
|
||||
|
||||
|
||||
async def test_logged_in_bug_report_is_attributed_to_the_user(client):
|
||||
token = await _register(client)
|
||||
|
||||
resp = await client.post(
|
||||
"/bug-reports",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"description": "withdrawal amount looks wrong", "contact": "alice@example.com"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = await client.get("/admin/bug-reports", headers=_ADMIN_HEADERS)
|
||||
reports = resp.json()
|
||||
assert reports[0]["username"] == "alice"
|
||||
assert reports[0]["contact"] == "alice@example.com"
|
||||
|
||||
|
||||
async def test_user_can_see_own_report_status(client):
|
||||
token = await _register(client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
resp = await client.post("/bug-reports", headers=headers, json={"description": "some bug"})
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
resp = await client.get("/bug-reports/mine", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
reports = resp.json()
|
||||
assert len(reports) == 1
|
||||
assert reports[0]["id"] == report_id
|
||||
assert reports[0]["status"] == "open"
|
||||
|
||||
await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||
)
|
||||
resp = await client.get("/bug-reports/mine", headers=headers)
|
||||
assert resp.json()[0]["status"] == "read"
|
||||
|
||||
|
||||
async def test_bug_reports_mine_requires_auth(client):
|
||||
resp = await client.get("/bug-reports/mine")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_bug_reports_mine_only_returns_own_reports(client):
|
||||
alice_token = await _register(client, username="alice")
|
||||
bob_token = await _register(client, username="bob", password="bob-password")
|
||||
|
||||
await client.post(
|
||||
"/bug-reports", headers={"Authorization": f"Bearer {alice_token}"}, json={"description": "alice's bug"}
|
||||
)
|
||||
|
||||
resp = await client.get("/bug-reports/mine", headers={"Authorization": f"Bearer {bob_token}"})
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
async def test_blank_description_is_rejected(client):
|
||||
resp = await client.post("/bug-reports", json={"description": " "})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_admin_bug_reports_requires_token(client):
|
||||
resp = await client.get("/admin/bug-reports")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_can_move_through_open_read_resolved(client):
|
||||
resp = await client.post("/bug-reports", json={"description": "some bug"})
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "read"
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "resolved"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "resolved"
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "open"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "open"
|
||||
|
||||
|
||||
async def test_update_status_rejects_unknown_value(client):
|
||||
resp = await client.post("/bug-reports", json={"description": "some bug"})
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "bogus"}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_update_status_unknown_report_is_404(client):
|
||||
resp = await client.post(
|
||||
"/admin/bug-reports/999/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- B-60: the status change is an admin mutation, so it leaves a trace ----------
|
||||
|
||||
|
||||
async def _audit_entries(event_type: str) -> list:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import AsyncSessionLocal
|
||||
from app.db.models import AuditLog
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
return (
|
||||
await session.scalars(select(AuditLog).where(AuditLog.event_type == event_type))
|
||||
).all()
|
||||
|
||||
|
||||
async def test_status_change_is_audit_logged(client): # B-60
|
||||
"""One shared ADMIN_TOKEN and no per-admin identity means the audit log is the
|
||||
only accountability there is — a report could be silently marked resolved."""
|
||||
token = await _register(client, username="reporter")
|
||||
resp = await client.post(
|
||||
"/bug-reports",
|
||||
json={"description": "some bug"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "resolved"}
|
||||
)
|
||||
|
||||
entries = await _audit_entries("bug_report_status_changed")
|
||||
assert len(entries) == 1
|
||||
import json
|
||||
|
||||
payload = json.loads(entries[0].payload_json)
|
||||
assert payload == {"report_id": report_id, "from": "open", "to": "resolved"}
|
||||
assert entries[0].user_id is not None # the report's author, so it's traceable both ways
|
||||
|
||||
|
||||
async def test_setting_the_status_it_already_has_logs_nothing(client): # B-60
|
||||
"""Same rule as config_updated: an edit that changes nothing isn't an event, or
|
||||
the log fills with noise that hides the real changes."""
|
||||
resp = await client.post("/bug-reports", json={"description": "some bug"})
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
for _ in range(3):
|
||||
await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "open"}
|
||||
)
|
||||
|
||||
assert await _audit_entries("bug_report_status_changed") == []
|
||||
@@ -33,3 +33,12 @@ def test_referrer_policy_is_set():
|
||||
def test_csp_default_src_is_self():
|
||||
assert "Content-Security-Policy" in CADDYFILE
|
||||
assert "default-src 'self'" in CADDYFILE
|
||||
|
||||
|
||||
def test_forwarded_for_is_overwritten_with_the_real_peer(): # B-54
|
||||
"""Caddy appends to a client-supplied X-Forwarded-For instead of replacing it,
|
||||
so without this directive the header's first element is whatever the caller
|
||||
claimed. app/api/client_ip.py reads the last hop and so holds on its own, but
|
||||
this is what makes the header itself trustworthy — losing it silently weakens
|
||||
every IP-keyed control (B-33's throttles, B-38's SSE cap)."""
|
||||
assert "header_up X-Forwarded-For {remote_host}" in CADDYFILE
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""app.api.client_ip is shared by the login/registration throttles (B-33) and
|
||||
the SSE per-IP subscriber cap (B-38) — both depend on it correctly preferring
|
||||
X-Forwarded-For (Caddy reverse-proxies every request, see Caddyfile) over
|
||||
request.client.host, which would otherwise be the proxy's own address."""
|
||||
request.client.host, which would otherwise be the proxy's own address.
|
||||
|
||||
Which *element* of that header it reads is a security property, not a detail:
|
||||
B-54 below is the whole reason all three controls hold at all."""
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
@@ -23,14 +26,33 @@ def test_client_ip_prefers_x_forwarded_for():
|
||||
assert client_ip(request) == "5.6.7.8"
|
||||
|
||||
|
||||
def test_client_ip_takes_the_first_hop_of_a_forwarded_chain():
|
||||
def test_client_ip_takes_the_last_hop_of_a_forwarded_chain(): # B-54
|
||||
"""The hop closest to us — the one our own proxy appended. Exactly one trusted
|
||||
proxy sits in front of the app, so this is the real peer."""
|
||||
request = _request(forwarded="5.6.7.8, 10.0.0.1, 172.17.0.1")
|
||||
assert client_ip(request) == "5.6.7.8"
|
||||
assert client_ip(request) == "172.17.0.1"
|
||||
|
||||
|
||||
def test_client_ip_ignores_a_client_supplied_prefix(): # B-54
|
||||
"""Caddy *appends* to whatever the client sent, so the front of the header is
|
||||
attacker-controlled. Reading it from the front let anyone mint a fresh identity
|
||||
per request and walk straight through the login/registration throttles (B-33)
|
||||
and the SSE per-IP subscriber cap (B-38). Two requests spoofing different
|
||||
values must still key to the same real IP."""
|
||||
first = _request(forwarded="1.1.1.1, 203.0.113.9")
|
||||
second = _request(forwarded="2.2.2.2, 203.0.113.9")
|
||||
assert client_ip(first) == client_ip(second) == "203.0.113.9"
|
||||
|
||||
|
||||
def test_client_ip_strips_whitespace():
|
||||
request = _request(forwarded=" 5.6.7.8 , 10.0.0.1")
|
||||
assert client_ip(request) == "5.6.7.8"
|
||||
request = _request(forwarded=" 5.6.7.8 , 10.0.0.1 ")
|
||||
assert client_ip(request) == "10.0.0.1"
|
||||
|
||||
|
||||
def test_client_ip_falls_back_when_the_header_is_empty():
|
||||
"""An empty or comma-only header used to yield "" — a single shared bucket every
|
||||
caller lands in, which is its own throttle-evasion trick."""
|
||||
assert client_ip(_request(forwarded=" , ", client_host="10.0.0.1")) == "10.0.0.1"
|
||||
|
||||
|
||||
def test_client_ip_falls_back_to_request_client_without_the_header():
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""B-69: in-code comments that describe the rest of the system must stay true.
|
||||
|
||||
Three had rotted: the reconciler still called the payout retry "a future
|
||||
payout-retry routine — still an open gap" long after B-26 shipped it,
|
||||
app/db/base.py sized the SQLite busy timeout against "five" background tasks
|
||||
when there are six, and app/auth/routes.py cited the wrong B-nn. A comment is
|
||||
invisible to every other test in the suite, so the claims are pinned here.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _read(relative: str) -> str:
|
||||
return (_ROOT / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_background_task_count_in_the_busy_timeout_comment_is_right():
|
||||
started = len(re.findall(r"asyncio\.create_task\(", _read("app/main.py")))
|
||||
assert started == 6, "the lifespan's task count changed — update app/db/base.py's comment"
|
||||
|
||||
comment = _read("app/db/base.py").split("_SQLITE_BUSY_TIMEOUT_MS", 1)[0]
|
||||
match = re.search(r"(\w+) concurrent background tasks", comment)
|
||||
assert match, "app/db/base.py no longer explains what the busy timeout is sized for"
|
||||
words = {"four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8}
|
||||
assert words.get(match.group(1)) == started
|
||||
|
||||
|
||||
def test_the_reconciler_does_not_call_the_payout_retry_an_open_gap():
|
||||
source = _read("app/tx/reconcile.py")
|
||||
assert "still an open gap" not in source
|
||||
# It exists (B-26) and is what actually recovers an abandoned payout, so the
|
||||
# comment must point at it rather than at an operator.
|
||||
assert "B-26" in source
|
||||
|
||||
|
||||
def test_the_payout_retry_the_comment_points_at_still_exists():
|
||||
assert "_retry_payout_if_due" in _read("app/rounds/scheduler.py")
|
||||
|
||||
|
||||
def test_the_login_throttle_comment_cites_its_own_finding():
|
||||
limiters = _read("app/auth/routes.py").split("_rate_limiters", 1)[1][:1500]
|
||||
assert "B-33" in limiters
|
||||
assert "B-31" not in limiters # B-31 is the resubscribe fan-out, a different fix
|
||||
@@ -184,3 +184,26 @@ async def test_reinstate_reappeared_utxos_ignores_unmarked_rows(session_factory,
|
||||
async with session_factory() as session:
|
||||
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
|
||||
assert reinstated == 0
|
||||
|
||||
|
||||
async def test_find_new_credit_candidates_skips_unconfirmed_and_already_known(session_factory, user_id): # B-59
|
||||
"""What the caller has to corroborate before crediting: only entries that would
|
||||
actually write something. Re-corroborating what we already hold would open a
|
||||
connection to every other server on every refresh, for an answer that can no
|
||||
longer change anything."""
|
||||
from app.deposits.service import find_new_credit_candidates
|
||||
|
||||
async with session_factory() as session:
|
||||
await credit_confirmed_utxos(
|
||||
session, user_id, [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 1_000}]
|
||||
)
|
||||
|
||||
entries = [
|
||||
{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # already credited
|
||||
{"tx_hash": "bb" * 32, "tx_pos": 0, "height": 0, "value": 2_000}, # still in the mempool
|
||||
{"tx_hash": "cc" * 32, "tx_pos": 1, "height": 101, "value": 3_000}, # genuinely new
|
||||
]
|
||||
async with session_factory() as session:
|
||||
candidates = await find_new_credit_candidates(session, user_id, entries)
|
||||
|
||||
assert [(c["tx_hash"], c["tx_pos"]) for c in candidates] == [("cc" * 32, 1)]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""B-68: CLAUDE.md and README must describe the code as it is now.
|
||||
|
||||
The audit found both files still asserting things the code had moved past —
|
||||
JWT "no revocation" after token_version implemented exactly that, /report-bug
|
||||
"a placeholder" after it shipped with admin triage, three stale test counts, a
|
||||
code map missing three modules, and README links to a file and an anchor that
|
||||
no longer exist. None of that is catchable by reading the code, so it is
|
||||
pinned here instead.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
CLAUDE_MD = (_ROOT / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
README = (_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _collected_test_count() -> int:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "--collect-only", "-q"],
|
||||
cwd=_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
match = re.search(r"(\d+) tests? collected", result.stdout)
|
||||
assert match, f"could not parse the collection summary:\n{result.stdout[-2000:]}"
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def test_documented_test_counts_match_reality():
|
||||
actual = _collected_test_count()
|
||||
documented = [int(n) for n in re.findall(r"(\d+) tests\b", CLAUDE_MD)]
|
||||
documented += [int(n) for n in re.findall(r"(\d+) unit tests\b", README)]
|
||||
assert documented, "no test count found in CLAUDE.md or README — did the wording change?"
|
||||
for count in documented:
|
||||
assert count == actual, (
|
||||
f"docs claim {count} tests, the suite collects {actual} — "
|
||||
"update the counts in CLAUDE.md (twice) and README.md"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stale",
|
||||
[
|
||||
"no revocation", # token_version implements it (app/auth/dependencies.py)
|
||||
"`/report-bug` are placeholders", # /report-bug shipped, only /guida is a stub
|
||||
],
|
||||
)
|
||||
def test_claude_md_has_no_stale_claims(stale):
|
||||
assert stale not in CLAUDE_MD
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module",
|
||||
["rate_limit.py", "client_ip.py", "bug_reports"],
|
||||
)
|
||||
def test_code_map_covers_every_package_member(module):
|
||||
code_map = CLAUDE_MD.split("## Code map", 1)[1].split("## Background tasks", 1)[0]
|
||||
assert module in code_map
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"link",
|
||||
["(flowchart.mmd)", "CLAUDE.md#tech-stack-mvp"],
|
||||
)
|
||||
def test_readme_has_no_dead_links(link):
|
||||
assert link not in README
|
||||
|
||||
|
||||
def test_readme_relative_links_resolve():
|
||||
for target in re.findall(r"\]\(([^)#]+)(?:#[^)]*)?\)", README):
|
||||
if target.startswith(("http://", "https://", "mailto:")):
|
||||
continue
|
||||
assert (_ROOT / target).exists(), f"README links {target}, which does not exist"
|
||||
|
||||
|
||||
def test_claude_md_anchors_into_itself_resolve():
|
||||
headings = {
|
||||
re.sub(r"[^a-z0-9 -]", "", line.lstrip("# ").lower()).replace(" ", "-")
|
||||
for line in CLAUDE_MD.splitlines()
|
||||
if line.startswith("#")
|
||||
}
|
||||
for anchor in re.findall(r"\(CLAUDE\.md#([a-z0-9-]+)\)", README + CLAUDE_MD):
|
||||
assert anchor in headings, f"anchor #{anchor} matches no CLAUDE.md heading"
|
||||
@@ -247,6 +247,52 @@ def test_apply_header_rejects_one_that_does_not_chain_from_the_tip(session_facto
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched
|
||||
|
||||
|
||||
def test_apply_header_ignores_a_competing_header_at_the_current_tip_height(session_factory, caplog): # B-64
|
||||
"""The linkage check only fires on a single-block advance, so a header at the
|
||||
height we already hold one for used to be applied on nothing but its own
|
||||
self-consistency — replacing the very hash a draw may be about to be seeded
|
||||
with. Whichever it is (a reorg at the tip, or a server disagreeing with the
|
||||
rest), the hash committed to for a height is not swapped under us; if ours is
|
||||
the orphan, corroborate_header refuses to draw from it anyway."""
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
header_100 = _mine_header("00" * 32)
|
||||
listener._apply_header({"height": 100, "hex": header_100})
|
||||
|
||||
competing_100 = _mine_header("11" * 32) # same height, well-formed, different block
|
||||
assert competing_100 != header_100
|
||||
with caplog.at_level(logging.WARNING):
|
||||
listener._apply_header({"height": 100, "hex": competing_100})
|
||||
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched
|
||||
assert "competing header" in caplog.text
|
||||
|
||||
|
||||
def test_apply_header_treats_the_same_header_re_announced_as_a_no_op(session_factory): # B-64
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
header_100 = _mine_header("00" * 32)
|
||||
listener._apply_header({"height": 100, "hex": header_100})
|
||||
|
||||
listener._apply_header({"height": 100, "hex": header_100}) # must not raise
|
||||
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100)
|
||||
|
||||
|
||||
def test_apply_header_ignores_one_carrying_no_hex(session_factory, caplog): # B-64
|
||||
"""A hex-less header can be neither validated nor drawn from, and applying its
|
||||
height alone used to *clear* the hex we already had — leaving tip_height and
|
||||
tip_header_hex describing different blocks, which is the one thing this function
|
||||
exists to prevent."""
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
header_100 = _mine_header("00" * 32)
|
||||
listener._apply_header({"height": 100, "hex": header_100})
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
listener._apply_header({"height": 101}) # height only, no hex
|
||||
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # pair intact
|
||||
assert "no header hex" in caplog.text
|
||||
|
||||
|
||||
def test_apply_header_skips_linkage_check_across_a_height_gap(session_factory):
|
||||
"""A reconnect (or the very first header of a session) hands us whatever the
|
||||
server's current tip is — which is legitimately not a single-block advance
|
||||
@@ -446,8 +492,14 @@ _UNRELATED_ENTRY = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value":
|
||||
async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(session_factory):
|
||||
user_id = await _seed_funded_user(session_factory, username="bob", address="plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd")
|
||||
|
||||
# The others agree the tracked UTXO is gone, and agree about the unrelated
|
||||
# entry — which B-59 now requires before that one may be credited.
|
||||
others_factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": [], "third.example": []}
|
||||
{
|
||||
"first.example": _UNRELATED_ENTRY,
|
||||
"second.example": _UNRELATED_ENTRY,
|
||||
"third.example": _UNRELATED_ENTRY,
|
||||
}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.client = _ActiveClient(_UNRELATED_ENTRY) # our own connection no longer sees the UTXO either
|
||||
@@ -471,7 +523,7 @@ async def test_refresh_user_does_not_mark_when_corroboration_fails(session_facto
|
||||
bad reply."""
|
||||
user_id = await _seed_funded_user(session_factory, username="carol", address="plm1qtest2")
|
||||
|
||||
still_there = [{"tx_hash": "dd" * 32, "tx_pos": 0}]
|
||||
still_there = [{"tx_hash": "dd" * 32, "tx_pos": 0}] + _UNRELATED_ENTRY
|
||||
others_factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": still_there, "third.example": still_there}
|
||||
)
|
||||
@@ -595,6 +647,74 @@ async def _wait_until(predicate, *, timeout: float = 2.0, interval: float = 0.01
|
||||
await asyncio.wait_for(_poll(), timeout=timeout)
|
||||
|
||||
|
||||
async def test_run_once_publishes_the_client_only_once_the_tip_is_known(session_factory): # B-63
|
||||
"""`self.client is not None` is what every consumer reads as "the chain is
|
||||
reachable" — RoundScheduler._tick included, which then takes tip_height as the
|
||||
baseline a draw must find a *later* block than. Publishing the client before the
|
||||
first header left a window where the connection looked alive at tip_height 0, so a
|
||||
round closing inside it would have seeded its draw from a block mined before the
|
||||
close, whose hash was already public while bets were open."""
|
||||
header_hex = _mine_header("00" * 32)
|
||||
client = _FakeConnectClient({"height": 100, "hex": header_hex})
|
||||
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
|
||||
|
||||
seen_while_subscribing: list[tuple[object, int]] = []
|
||||
original_subscribe_headers = client.subscribe_headers
|
||||
|
||||
async def observing_subscribe_headers():
|
||||
# Exactly the window that used to be exposed: connected, but no header yet.
|
||||
seen_while_subscribing.append((listener.client, listener.tip_height))
|
||||
return await original_subscribe_headers()
|
||||
|
||||
client.subscribe_headers = observing_subscribe_headers
|
||||
|
||||
run_once_task = asyncio.create_task(listener._run_once(_ENDPOINTS[0]))
|
||||
try:
|
||||
await _wait_until(lambda: listener.client is not None)
|
||||
# Whenever the client is visible, the tip is already known — never 0.
|
||||
assert listener.tip_height == 100
|
||||
assert listener.tip_header_hex == header_hex
|
||||
assert seen_while_subscribing == [(None, 0)]
|
||||
finally:
|
||||
await client.close()
|
||||
await run_once_task
|
||||
|
||||
|
||||
async def test_run_once_refuses_a_session_whose_initial_header_carries_no_hex(session_factory): # B-64
|
||||
"""A hex-less header is ignored rather than fatal (a server that only pushes
|
||||
heights must not cost us the connection that also credits deposits) — but on the
|
||||
*first* header of a process there is no tip to fall back on, and publishing the
|
||||
client anyway would hand consumers a connection whose chain position is unknown,
|
||||
which is exactly what B-63 closed."""
|
||||
client = _FakeConnectClient({"height": 100}) # no "hex"
|
||||
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
|
||||
|
||||
# Bounded: without the guard _run_once goes on to wait on the session's tasks,
|
||||
# which nothing in this test ever ends — a regression must fail, not hang.
|
||||
with pytest.raises(HeaderValidationError):
|
||||
await asyncio.wait_for(listener._run_once(_ENDPOINTS[0]), timeout=5)
|
||||
|
||||
assert listener.client is None
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (0, None)
|
||||
|
||||
|
||||
async def test_run_once_leaves_no_client_published_when_the_first_header_is_rejected(
|
||||
session_factory,
|
||||
): # B-63
|
||||
"""A fabricated first header ends the session (B-28). The client must never
|
||||
become visible on the way out either, or consumers would briefly see a
|
||||
connection whose tip was never established."""
|
||||
client = _FakeConnectClient({"height": 100, "hex": "00" * 80}) # fails its own target
|
||||
|
||||
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
|
||||
|
||||
with pytest.raises(HeaderValidationError):
|
||||
await asyncio.wait_for(listener._run_once(_ENDPOINTS[0]), timeout=5)
|
||||
|
||||
assert listener.client is None
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (0, None)
|
||||
|
||||
|
||||
async def test_run_once_keeps_consuming_headers_while_resubscribing(session_factory):
|
||||
"""The core B-31 fix: before this, _subscribe_all_users ran to completion
|
||||
*before* the header-consuming task even started, so a reconnect with many
|
||||
@@ -630,3 +750,95 @@ async def test_run_once_keeps_consuming_headers_while_resubscribing(session_fact
|
||||
finally:
|
||||
await client.close()
|
||||
await run_once_task
|
||||
|
||||
|
||||
# --- B-59: a credit must clear the same quorum a debit already had to (B-29) -----
|
||||
|
||||
_PHANTOM = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 200, "value": 5_000_000}]
|
||||
|
||||
|
||||
async def test_corroborate_utxo_credit_true_when_others_report_the_same_outpoint(session_factory):
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": _PHANTOM, "second.example": _PHANTOM, "third.example": _PHANTOM}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_credit("scripthash", "77" * 32, 0, 5_000_000) is True
|
||||
|
||||
|
||||
async def test_corroborate_utxo_credit_false_when_the_amount_differs(session_factory):
|
||||
"""Agreement is on the amount too, not just on the outpoint existing — the
|
||||
inflated number is the whole point of the attack."""
|
||||
smaller = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 200, "value": 1_000}]
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": smaller, "second.example": smaller, "third.example": _PHANTOM}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_credit("scripthash", "77" * 32, 0, 5_000_000) is False
|
||||
|
||||
|
||||
async def test_corroborate_utxo_credit_false_when_others_call_it_unconfirmed(session_factory):
|
||||
"""height <= 0 is Electrum's "still in the mempool" — a server that hasn't seen
|
||||
the block yet doesn't corroborate a 1-conf credit."""
|
||||
mempool = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 0, "value": 5_000_000}]
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": mempool, "second.example": mempool, "third.example": _PHANTOM}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_credit("scripthash", "77" * 32, 0, 5_000_000) is False
|
||||
|
||||
|
||||
async def test_corroborate_utxo_credit_false_when_nobody_responds(session_factory):
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": None, "second.example": None, "third.example": ConnectionRefusedError("down")}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_utxo_credit("scripthash", "77" * 32, 0, 5_000_000) is False
|
||||
|
||||
|
||||
async def test_refresh_user_does_not_credit_a_utxo_only_our_own_server_reports(session_factory):
|
||||
"""The mirror of B-29's headline case: before this, one hostile or broken
|
||||
server could inflate a user's displayed balance with an outpoint that doesn't
|
||||
exist. The credit is withheld, not lost — the next refresh retries it."""
|
||||
user_id = await _seed_funded_user(session_factory, username="dave", address="plm1qtest3")
|
||||
|
||||
others_factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": [], "third.example": []}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
# Our own connection reports the tracked UTXO plus a phantom one nobody else has.
|
||||
listener.client = _ActiveClient(
|
||||
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
|
||||
)
|
||||
|
||||
await listener.refresh_user(user_id, "scripthash")
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (
|
||||
await session.scalars(select(UtxoEvent).where(UtxoEvent.txid == "77" * 32))
|
||||
).all() == []
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 20_000_000 # unchanged, not inflated
|
||||
|
||||
|
||||
async def test_refresh_user_credits_once_the_others_corroborate(session_factory):
|
||||
user_id = await _seed_funded_user(session_factory, username="erin", address="plm1qtest4")
|
||||
|
||||
# first.example is the active endpoint, which _corroborate_majority never asks —
|
||||
# the quorum here is second + third.
|
||||
others_factory = await _listunspent_client_factory(
|
||||
{"first.example": [], "second.example": _PHANTOM, "third.example": _PHANTOM}
|
||||
)
|
||||
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
|
||||
listener.client = _ActiveClient(
|
||||
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
|
||||
)
|
||||
|
||||
await listener.refresh_user(user_id, "scripthash")
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 25_000_000
|
||||
|
||||
@@ -4,7 +4,15 @@ from embit.bip32 import HDKey
|
||||
from embit.transaction import Transaction
|
||||
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction, estimate_vsize
|
||||
from app.wallet.psbt_builder import (
|
||||
MAX_PARTICIPANTS_PER_ROUND,
|
||||
MAX_PAYOUT_TX_INPUTS,
|
||||
MAX_TX_INPUTS,
|
||||
InsufficientFundsError,
|
||||
Utxo,
|
||||
build_payout_transaction,
|
||||
estimate_vsize,
|
||||
)
|
||||
|
||||
|
||||
def _key(seed_byte: int) -> HDKey:
|
||||
@@ -75,6 +83,55 @@ def test_payout_adds_change_output_when_pool_utxos_exceed_target():
|
||||
assert len(parsed.vout) == 3
|
||||
|
||||
|
||||
def test_payout_spends_more_utxos_than_a_user_transaction_may(): # B-52
|
||||
"""The pool holds one UTXO per bet, so a round with more participants than
|
||||
MAX_TX_INPUTS used to be impossible to pay out: select_utxos raised
|
||||
too_many_inputs, the round stayed "paying_out" retrying every 60s forever, and
|
||||
since no new round may open while one is active, the lottery stopped for good.
|
||||
The payout gets its own, far higher cap for exactly this reason."""
|
||||
pool_key = _key(40)
|
||||
pool_script = script.p2wpkh(pool_key.to_public())
|
||||
pool_address = pool_script.address(network=PLM_MAINNET)
|
||||
winner_address = script.p2wpkh(_key(41).to_public()).address(network=PLM_MAINNET)
|
||||
fee_address = script.p2wpkh(_key(42).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
# One 10 PLM bet per participant, one UTXO each, just past the user-facing cap.
|
||||
participants = MAX_TX_INPUTS + 1
|
||||
bet_sats = 1_000_000_000
|
||||
pool_amount = bet_sats * participants
|
||||
winner_share = pool_amount * 70 // 100
|
||||
commission = pool_amount - winner_share
|
||||
utxos = [Utxo(f"{i:064x}", 0, bet_sats) for i in range(participants)]
|
||||
|
||||
built = build_payout_transaction(
|
||||
signing_key=pool_key,
|
||||
from_script=pool_script,
|
||||
utxos=utxos,
|
||||
winner_address=winner_address,
|
||||
winner_share_sats=winner_share,
|
||||
fee_address=fee_address,
|
||||
commission_sats=commission,
|
||||
change_address=pool_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
assert len(built.spent_utxos) == participants # every bet had to be spent
|
||||
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
|
||||
assert len(parsed.vin) == participants
|
||||
assert built.fee_sats == estimate_vsize(participants, 3)
|
||||
assert built.winner_sats == winner_share - built.fee_sats
|
||||
assert built.commission_sats == commission # still untouched by the fee
|
||||
|
||||
|
||||
def test_payout_at_the_participant_cap_stays_well_inside_relay_limits(): # B-52
|
||||
"""MAX_PARTICIPANTS_PER_ROUND is only safe if the payout it implies is still a
|
||||
standard transaction. A full round is one input per bet plus the pool's own
|
||||
change, and relay policy refuses anything past 100 kvB."""
|
||||
inputs_needed = MAX_PARTICIPANTS_PER_ROUND + 1 # + one accumulated pool change UTXO
|
||||
assert inputs_needed <= MAX_PAYOUT_TX_INPUTS # headroom for pool change exists
|
||||
assert estimate_vsize(MAX_PAYOUT_TX_INPUTS, 3) < 100_000
|
||||
|
||||
|
||||
def test_payout_raises_when_winner_share_too_small():
|
||||
pool_key = _key(30)
|
||||
pool_script = script.p2wpkh(pool_key.to_public())
|
||||
|
||||
@@ -5,6 +5,7 @@ from embit.transaction import Transaction
|
||||
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import (
|
||||
MAX_PAYOUT_TX_INPUTS,
|
||||
MAX_TX_INPUTS,
|
||||
InsufficientFundsError,
|
||||
Utxo,
|
||||
@@ -53,6 +54,33 @@ def test_select_utxos_allows_exactly_the_input_cap():
|
||||
assert total == 100_000 * MAX_TX_INPUTS
|
||||
|
||||
|
||||
def test_select_utxos_honours_a_caller_supplied_cap(): # B-52
|
||||
"""The cap is per-caller: MAX_TX_INPUTS protects a user from a fee eating into
|
||||
their own bet/withdrawal, while the payout needs MAX_PAYOUT_TX_INPUTS to be able
|
||||
to drain a pool holding one UTXO per bet at all."""
|
||||
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(MAX_TX_INPUTS + 10)]
|
||||
target = 100_000 * (MAX_TX_INPUTS + 10)
|
||||
|
||||
with pytest.raises(InsufficientFundsError):
|
||||
select_utxos(utxos, target_sats=target) # default cap: too fragmented
|
||||
|
||||
selected, total = select_utxos(utxos, target_sats=target, max_inputs=MAX_PAYOUT_TX_INPUTS)
|
||||
assert len(selected) == MAX_TX_INPUTS + 10
|
||||
assert total == target
|
||||
|
||||
|
||||
def test_select_utxos_still_caps_at_the_payout_limit(): # B-52
|
||||
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(MAX_PAYOUT_TX_INPUTS + 5)]
|
||||
with pytest.raises(InsufficientFundsError) as excinfo:
|
||||
select_utxos(
|
||||
utxos,
|
||||
target_sats=100_000 * (MAX_PAYOUT_TX_INPUTS + 1),
|
||||
max_inputs=MAX_PAYOUT_TX_INPUTS,
|
||||
)
|
||||
assert excinfo.value.code == "too_many_inputs"
|
||||
assert excinfo.value.params == {"max_inputs": MAX_PAYOUT_TX_INPUTS}
|
||||
|
||||
|
||||
def test_build_signed_transaction_deducts_fee_from_amount_not_change():
|
||||
signer = _key(1)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
@@ -86,28 +114,62 @@ def test_build_signed_transaction_deducts_fee_from_amount_not_change():
|
||||
assert len(parsed.vout) == 2
|
||||
|
||||
|
||||
def test_build_signed_transaction_omits_change_output_when_exact_amount():
|
||||
def test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change(): # B-62
|
||||
"""A single-output transaction is the one shape RBF cannot rescue: bump_fee has
|
||||
no change to shrink, and adding inputs is no answer either since this spends
|
||||
every UTXO the sender has. The bet is a fixed price, so it is refused rather
|
||||
than quietly reduced."""
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
signer = _key(3)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
my_address = from_script.address(network=PLM_MAINNET)
|
||||
to_address = script.p2wpkh(_key(4).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
utxos = [Utxo("22" * 32, 0, 10_000_000)] # exactly amount_sats, zero change
|
||||
with pytest.raises(InsufficientFundsError) as excinfo:
|
||||
build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=utxos,
|
||||
to_address=to_address,
|
||||
amount_sats=10_000_000,
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
)
|
||||
|
||||
assert excinfo.value.code == "balance_leaves_no_change"
|
||||
assert excinfo.value.params == {"required_extra_sats": DUST_LIMIT_SATS}
|
||||
|
||||
|
||||
def test_build_signed_transaction_can_reduce_the_amount_to_keep_change(): # B-62
|
||||
"""What "withdraw everything" does instead: move a dust limit less and stay
|
||||
fee-bumpable. The caller records the reduced amount as what was actually sent."""
|
||||
from embit.transaction import Transaction
|
||||
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
signer = _key(3)
|
||||
from_script = script.p2wpkh(signer.to_public())
|
||||
my_address = from_script.address(network=PLM_MAINNET)
|
||||
to_address = script.p2wpkh(_key(4).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
built = build_signed_transaction(
|
||||
signing_key=signer,
|
||||
from_script=from_script,
|
||||
utxos=utxos,
|
||||
utxos=[Utxo("22" * 32, 0, 10_000_000)],
|
||||
to_address=to_address,
|
||||
amount_sats=10_000_000,
|
||||
change_address=my_address,
|
||||
fee_rate_sat_vb=1,
|
||||
reduce_amount_to_keep_change=True,
|
||||
)
|
||||
assert built.change_sats == 0
|
||||
|
||||
from embit.transaction import Transaction
|
||||
|
||||
assert built.change_sats == DUST_LIMIT_SATS
|
||||
assert built.recipient_sats == 10_000_000 - DUST_LIMIT_SATS - built.fee_sats
|
||||
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
|
||||
assert len(parsed.vout) == 1
|
||||
assert len(parsed.vout) == 2
|
||||
assert built.recipient_sats + built.change_sats + built.fee_sats == 10_000_000
|
||||
|
||||
|
||||
def test_build_signed_transaction_raises_when_amount_smaller_than_fee():
|
||||
@@ -131,10 +193,13 @@ def test_build_signed_transaction_raises_when_amount_smaller_than_fee():
|
||||
)
|
||||
|
||||
|
||||
def test_dust_change_is_left_to_the_fee():
|
||||
def test_a_below_dust_change_output_is_never_created():
|
||||
"""B-06: `if change > 0` created change outputs below the dust limit, which makes
|
||||
the whole transaction unrelayable — the bet or withdrawal then failed at broadcast
|
||||
with an opaque error the user could do nothing about."""
|
||||
with an opaque error the user could do nothing about. B-62 changed the remedy
|
||||
(the change is topped up to the dust limit by moving slightly less, instead of
|
||||
being folded into the fee and leaving an unbumpable single-output tx) but not
|
||||
this rule: an output below DUST_LIMIT_SATS is never produced."""
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
signer = _key(1)
|
||||
@@ -152,13 +217,16 @@ def test_dust_change_is_left_to_the_fee():
|
||||
amount_sats=amount,
|
||||
change_address=change_address,
|
||||
fee_rate_sat_vb=1,
|
||||
reduce_amount_to_keep_change=True,
|
||||
)
|
||||
|
||||
tx = Transaction.parse(bytes.fromhex(built.raw_hex))
|
||||
assert len(tx.vout) == 1 # no dust output
|
||||
assert built.change_sats == 0
|
||||
# Nothing vanishes: the dust ends up in the fee, and inputs still equal outputs+fee.
|
||||
assert built.fee_sats >= dust_change
|
||||
assert len(tx.vout) == 2
|
||||
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
|
||||
assert built.change_sats == DUST_LIMIT_SATS
|
||||
# Nothing vanishes: inputs still equal outputs + fee, the recipient just gets
|
||||
# the one satoshi that was missing from a relayable change output.
|
||||
assert built.recipient_sats == amount - 1 - built.fee_sats
|
||||
assert built.recipient_sats + built.change_sats + built.fee_sats == amount + dust_change
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""B-67: /qr/{address} must validate the address for real and must not render
|
||||
QR codes on the event loop for every anonymous request."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.routes import qr
|
||||
|
||||
|
||||
VALID_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
qr._render_png.cache_clear()
|
||||
app = FastAPI()
|
||||
app.include_router(qr.router)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def test_valid_address_renders_a_png(client):
|
||||
resp = await client.get(f"/qr/{VALID_ADDRESS}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
assert resp.content.startswith(b"\x89PNG")
|
||||
assert "max-age" in resp.headers["cache-control"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
"plm1qbogus0000000000000000000000000000000000", # right shape, broken checksum
|
||||
"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", # valid bech32, wrong chain
|
||||
"plm1q", # too short to be anything
|
||||
"P" + "a" * 40, # not bech32 at all
|
||||
"plm1" + "q" * 200, # over the length guard
|
||||
],
|
||||
)
|
||||
async def test_non_addresses_are_rejected_without_rendering(client, address, monkeypatch):
|
||||
def explode(*args, **kwargs): # pragma: no cover - must never run
|
||||
raise AssertionError("rendered a QR for a non-address")
|
||||
|
||||
monkeypatch.setattr(qr.qrcode, "make", explode)
|
||||
|
||||
resp = await client.get(f"/qr/{address}")
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"]["code"] == "invalid_address"
|
||||
|
||||
|
||||
async def test_render_is_memoized_per_address(client):
|
||||
calls = 0
|
||||
original = qr.qrcode.make
|
||||
|
||||
def counting_make(data, *args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return original(data, *args, **kwargs)
|
||||
|
||||
qr.qrcode.make = counting_make
|
||||
try:
|
||||
for _ in range(3):
|
||||
assert (await client.get(f"/qr/{VALID_ADDRESS}")).status_code == 200
|
||||
finally:
|
||||
qr.qrcode.make = original
|
||||
|
||||
assert calls == 1
|
||||
|
||||
|
||||
async def test_render_does_not_block_the_event_loop(client):
|
||||
"""The render runs in a threadpool, so the loop stays responsive while it does."""
|
||||
ticks = 0
|
||||
|
||||
async def ticker():
|
||||
nonlocal ticks
|
||||
while True:
|
||||
ticks += 1
|
||||
await asyncio.sleep(0)
|
||||
|
||||
original = qr.qrcode.make
|
||||
|
||||
def slow_make(data, *args, **kwargs):
|
||||
# Blocking sleep: on the event loop this would freeze the ticker.
|
||||
import time
|
||||
|
||||
time.sleep(0.05)
|
||||
return original(data, *args, **kwargs)
|
||||
|
||||
qr.qrcode.make = slow_make
|
||||
task = asyncio.create_task(ticker())
|
||||
try:
|
||||
assert (await client.get(f"/qr/{VALID_ADDRESS}")).status_code == 200
|
||||
finally:
|
||||
qr.qrcode.make = original
|
||||
task.cancel()
|
||||
|
||||
assert ticks > 1
|
||||
@@ -0,0 +1,140 @@
|
||||
"""B-56: RateLimiter._buckets is keyed by strings the caller chooses — any
|
||||
username, and (before B-54) any IP — and used to only ever grow. decay_seconds
|
||||
aged a bucket's *counter* but never removed the entry, so hammering login with
|
||||
random usernames was an unbounded memory leak. These tests pin both halves of
|
||||
the bound: spent entries are swept, and the dict has a hard cap.
|
||||
|
||||
The throttling behaviour itself is exercised end-to-end in test_auth.py; what
|
||||
matters here is that pruning never hands an attacker a free pass.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from app.auth.rate_limit import RateLimiter, RollingQuota
|
||||
|
||||
|
||||
def _limiter(**kwargs) -> RateLimiter:
|
||||
# sweep_interval_seconds=0 makes every record_failure sweep, so the tests are
|
||||
# deterministic instead of depending on wall-clock timing.
|
||||
kwargs.setdefault("sweep_interval_seconds", 0.0)
|
||||
return RateLimiter(**kwargs)
|
||||
|
||||
|
||||
def test_spent_buckets_are_swept_on_the_next_failure():
|
||||
limiter = _limiter(decay_seconds=0.05)
|
||||
for i in range(50):
|
||||
limiter.record_failure(f"user:{i}")
|
||||
assert len(limiter._buckets) == 50
|
||||
|
||||
time.sleep(0.06) # every bucket is now past decay_seconds and unlocked
|
||||
limiter.record_failure("user:fresh")
|
||||
|
||||
assert list(limiter._buckets) == ["user:fresh"]
|
||||
|
||||
|
||||
def test_a_bucket_still_locking_someone_out_is_never_swept():
|
||||
"""The whole point of the entry: evicting it would reset the backoff and let the
|
||||
attacker start over from a free attempt."""
|
||||
limiter = _limiter(threshold=1, base_delay=300.0, decay_seconds=0.05)
|
||||
limiter.record_failure("user:victim")
|
||||
assert limiter.retry_after("user:victim") > 0
|
||||
|
||||
time.sleep(0.06) # past decay_seconds, but the lockout is still running
|
||||
limiter.record_failure("user:someone-else")
|
||||
|
||||
assert "user:victim" in limiter._buckets
|
||||
assert limiter.retry_after("user:victim") > 0
|
||||
|
||||
|
||||
def test_the_dict_is_capped_even_within_one_sweep_interval():
|
||||
"""The cap is the backstop for a burst faster than the sweep interval, where
|
||||
nothing has had time to expire yet."""
|
||||
limiter = _limiter(max_buckets=10, sweep_interval_seconds=3600.0, decay_seconds=3600.0)
|
||||
for i in range(200):
|
||||
limiter.record_failure(f"ip:{i}")
|
||||
|
||||
assert len(limiter._buckets) <= 11 # the cap, plus the entry recorded after the last prune
|
||||
|
||||
|
||||
def test_the_cap_evicts_the_entries_closest_to_expiry_first():
|
||||
"""What gets dropped under pressure must buy an attacker the least. The deepest
|
||||
lockout — the one built up over the most failures, and so the one actually
|
||||
holding an attack back — has to be the last thing evicted, not collateral of a
|
||||
flood of one-failure keys."""
|
||||
limiter = _limiter(max_buckets=3, threshold=1, base_delay=1.0, max_delay=600.0)
|
||||
for _ in range(10):
|
||||
limiter.record_failure("ip:persistent") # backoff doubles: locked for ~512s
|
||||
for i in range(20):
|
||||
limiter.record_failure(f"ip:filler{i}") # one failure each: locked for ~1s
|
||||
|
||||
assert "ip:persistent" in limiter._buckets
|
||||
assert limiter.retry_after("ip:persistent") > 100
|
||||
|
||||
|
||||
def test_retry_after_drops_a_spent_bucket_it_looks_at():
|
||||
limiter = _limiter(decay_seconds=0.05)
|
||||
limiter.record_failure("user:probe")
|
||||
|
||||
time.sleep(0.06)
|
||||
assert limiter.retry_after("user:probe") == 0.0
|
||||
assert limiter._buckets == {}
|
||||
|
||||
|
||||
def test_pruning_does_not_reset_a_live_failure_count():
|
||||
"""A bucket below the lockout threshold still carries state worth keeping: the
|
||||
next failure must count as the second, not the first."""
|
||||
limiter = _limiter(threshold=2, base_delay=300.0, decay_seconds=3600.0)
|
||||
limiter.record_failure("user:a")
|
||||
limiter.record_failure("user:b") # triggers a sweep
|
||||
|
||||
limiter.record_failure("user:a")
|
||||
assert limiter.retry_after("user:a") > 0
|
||||
|
||||
|
||||
# --- B-58: registration is a quota, not failure backoff -------------------------
|
||||
|
||||
|
||||
def test_quota_allows_up_to_the_limit_then_asks_for_a_wait():
|
||||
quota = RollingQuota(limit=3, window_seconds=60.0)
|
||||
for _ in range(3):
|
||||
assert quota.retry_after("ip:1") == 0.0
|
||||
quota.record("ip:1")
|
||||
|
||||
wait = quota.retry_after("ip:1")
|
||||
assert 0 < wait <= 60.0
|
||||
|
||||
|
||||
def test_quota_is_per_key():
|
||||
quota = RollingQuota(limit=1, window_seconds=60.0)
|
||||
quota.record("ip:1")
|
||||
|
||||
assert quota.retry_after("ip:1") > 0
|
||||
assert quota.retry_after("ip:2") == 0.0
|
||||
|
||||
|
||||
def test_quota_frees_a_slot_once_the_oldest_event_leaves_the_window():
|
||||
"""The point of a rolling window over failure backoff: the caller waits exactly
|
||||
until there's room again, and waiting doesn't make the next wait longer."""
|
||||
quota = RollingQuota(limit=2, window_seconds=0.05)
|
||||
quota.record("ip:1")
|
||||
quota.record("ip:1")
|
||||
assert quota.retry_after("ip:1") > 0
|
||||
|
||||
time.sleep(0.06)
|
||||
assert quota.retry_after("ip:1") == 0.0
|
||||
|
||||
|
||||
def test_quota_prunes_spent_keys_and_caps_its_dict():
|
||||
"""Same bound as the failure limiter (B-56): caller-chosen keys, so both a sweep
|
||||
and a hard cap."""
|
||||
quota = RollingQuota(limit=1, window_seconds=0.05, sweep_interval_seconds=0.0)
|
||||
for i in range(50):
|
||||
quota.record(f"ip:{i}")
|
||||
time.sleep(0.06)
|
||||
quota.record("ip:fresh")
|
||||
assert list(quota._events) == ["ip:fresh"]
|
||||
|
||||
capped = RollingQuota(limit=1, window_seconds=3600.0, max_keys=10, sweep_interval_seconds=0.0)
|
||||
for i in range(200):
|
||||
capped.record(f"ip:{i}")
|
||||
assert len(capped._events) <= 11
|
||||
@@ -134,6 +134,92 @@ async def test_jackpot_comes_from_the_participants_actual_bets(client):
|
||||
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
|
||||
|
||||
|
||||
async def test_advertised_jackpot_covers_only_the_bets_that_will_be_paid(client): # B-65
|
||||
"""The draw picks from confirmed participants and the payout spends only their
|
||||
sats, so counting every participant row advertised a jackpot bigger than the one
|
||||
that would actually be paid — and let a player appear in the count and then
|
||||
vanish again if their bet was abandoned. The confirmed figures are the headline
|
||||
ones; what's in flight is reported alongside, never folded in."""
|
||||
from app.db.models import Round, RoundConfig, RoundParticipant
|
||||
|
||||
ac, session_factory = client
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
|
||||
session.add(Round(id=60, status="open"))
|
||||
await session.flush()
|
||||
session.add(
|
||||
RoundParticipant(round_id=60, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
|
||||
)
|
||||
# One mid-broadcast and one written but not yet broadcast: both in flight,
|
||||
# neither drawn from nor spent by the payout as things stand.
|
||||
session.add(
|
||||
RoundParticipant(round_id=60, user_id=2, bet_amount_sats=999_800_000, bet_txid="b", status="broadcast")
|
||||
)
|
||||
session.add(
|
||||
RoundParticipant(round_id=60, user_id=3, bet_amount_sats=999_800_000, bet_txid="c", status="building")
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
body = (await ac.get("/rounds/current")).json()
|
||||
|
||||
assert body["participant_count"] == 1
|
||||
assert body["jackpot_sats"] == 999_800_000 * 70 // 100
|
||||
# Inclusive, like pending_balance_sats — not a delta.
|
||||
assert body["pending_participant_count"] == 3
|
||||
assert body["pending_jackpot_sats"] == (999_800_000 * 3) * 70 // 100
|
||||
assert body["has_pending_bets"] is True
|
||||
|
||||
|
||||
async def test_no_pending_bets_reported_once_every_bet_has_confirmed(client): # B-65
|
||||
from app.db.models import Round, RoundConfig, RoundParticipant
|
||||
|
||||
ac, session_factory = client
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
|
||||
session.add(Round(id=61, status="open"))
|
||||
await session.flush()
|
||||
session.add(
|
||||
RoundParticipant(round_id=61, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
body = (await ac.get("/rounds/current")).json()
|
||||
|
||||
assert body["has_pending_bets"] is False
|
||||
assert body["pending_participant_count"] == body["participant_count"] == 1
|
||||
assert body["pending_jackpot_sats"] == body["jackpot_sats"]
|
||||
|
||||
|
||||
async def test_lottery_configured_flags_a_missing_fee_address(client): # B-66
|
||||
"""The frontend has to tell "the next round is coming" apart from "nothing is
|
||||
coming until the operator finishes setting this up" — the banner says different
|
||||
things, and only one of them is worth waiting for."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import RoundConfig
|
||||
|
||||
ac, session_factory = client
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address=""))
|
||||
await session.commit()
|
||||
|
||||
body = (await ac.get("/rounds/current")).json()
|
||||
assert body["lottery_configured"] is False
|
||||
assert body["lottery_paused"] is False # not a pause: a prerequisite that isn't met
|
||||
assert body["round_id"] is None # and indeed no round was opened
|
||||
|
||||
async with session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().fee_address = (
|
||||
"plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert (await ac.get("/rounds/current")).json()["lottery_configured"] is True
|
||||
|
||||
|
||||
async def test_draw_waiting_since_is_exposed_only_while_drawing(client):
|
||||
"""B-36: the "drawing" wait on a future block has no timeout, so the frontend
|
||||
needs draw_waiting_since to show "still waiting" instead of implying a bounded
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.db.models import Round, RoundConfig
|
||||
from app.rounds.service import get_active_round, open_new_round_if_needed
|
||||
|
||||
ROUND_COOLDOWN_SECONDS = 30 # matches RoundConfig.round_cooldown_seconds' column default
|
||||
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -16,6 +17,14 @@ async def session_factory():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# B-66: no fee address means no round may open at all, which would make most of
|
||||
# the assertions below pass for the wrong reason. Seeded once here so every test
|
||||
# in this file runs against an instance that could actually pay a winner, and the
|
||||
# ones that care about other config values edit this same single row.
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||
session.add(RoundConfig(fee_address=_FEE_ADDRESS))
|
||||
await session.commit()
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
|
||||
@@ -88,7 +97,7 @@ async def test_withholds_new_round_while_paused(session_factory):
|
||||
stale_close = datetime.now(timezone.utc) - timedelta(seconds=ROUND_COOLDOWN_SECONDS + 1)
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="closed", closed_at=stale_close))
|
||||
session.add(RoundConfig(fee_address="", paused=True))
|
||||
(await session.scalars(select(RoundConfig))).one().paused = True
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
@@ -99,7 +108,7 @@ async def test_withholds_new_round_while_paused(session_factory):
|
||||
async def test_pause_does_not_interrupt_a_round_in_progress(session_factory):
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="drawing"))
|
||||
session.add(RoundConfig(fee_address="", paused=True))
|
||||
(await session.scalars(select(RoundConfig))).one().paused = True
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
@@ -171,3 +180,111 @@ async def test_closed_rounds_can_coexist_with_an_active_one(session_factory):
|
||||
|
||||
async with session_factory() as session:
|
||||
assert len((await session.scalars(select(Round))).all()) == 3
|
||||
|
||||
|
||||
# --- B-66: no round opens on an instance that could not pay its winner ------------
|
||||
|
||||
|
||||
async def test_withholds_new_round_while_no_fee_address_is_configured(session_factory): # B-66
|
||||
"""A fresh instance starts with no fee_address, and the payout pays the 30%
|
||||
commission to it — so a round opened without one takes bets, confirms them, and
|
||||
only then discovers it cannot be paid, wedging in "paying_out" with money already
|
||||
in the pool and needing manual recovery. Every round, until an operator notices."""
|
||||
async with session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().fee_address = ""
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
assert await open_new_round_if_needed(session) is None
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (await session.scalars(select(Round))).all() == [] # nothing opened at all
|
||||
|
||||
|
||||
async def test_opens_a_round_as_soon_as_a_fee_address_is_set(session_factory): # B-66
|
||||
async with session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().fee_address = ""
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
assert await open_new_round_if_needed(session) is None
|
||||
|
||||
async with session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().fee_address = _FEE_ADDRESS
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
assert round_ is not None and round_.status == "open"
|
||||
|
||||
|
||||
async def test_a_round_in_progress_survives_the_fee_address_being_cleared(session_factory): # B-66
|
||||
"""Same rule as pausing: an unmet prerequisite only stops the *next* round. The
|
||||
one in progress keeps its participants and still has to be drawn and paid — and
|
||||
clearing the address is exactly the mistake an operator might make mid-round."""
|
||||
async with session_factory() as session:
|
||||
session.add(Round(status="open"))
|
||||
(await session.scalars(select(RoundConfig))).one().fee_address = ""
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
returned = await open_new_round_if_needed(session)
|
||||
assert returned is not None and returned.status == "open"
|
||||
|
||||
|
||||
def test_rounds_can_open_ignores_a_whitespace_only_fee_address(): # B-66
|
||||
from app.rounds.service import rounds_can_open
|
||||
|
||||
assert rounds_can_open(RoundConfig(fee_address=_FEE_ADDRESS)) is True
|
||||
assert rounds_can_open(RoundConfig(fee_address="")) is False
|
||||
assert rounds_can_open(RoundConfig(fee_address=" ")) is False
|
||||
|
||||
|
||||
# --- B-61: a round runs by the timing it opened with, not by the live config ------
|
||||
|
||||
|
||||
async def test_a_new_round_snapshots_the_current_config_timing(session_factory):
|
||||
async with session_factory() as session:
|
||||
config = (await session.scalars(select(RoundConfig))).one()
|
||||
config.round_duration_seconds = 120
|
||||
config.round_cooldown_seconds = 45
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
|
||||
assert round_.duration_seconds == 120
|
||||
assert round_.cooldown_seconds == 45
|
||||
|
||||
|
||||
async def test_round_accepts_bets_uses_the_rounds_own_duration(session_factory):
|
||||
from app.rounds.service import round_accepts_bets
|
||||
|
||||
opened_at = datetime.now(timezone.utc) - timedelta(seconds=100)
|
||||
still_open = Round(status="open", opened_at=opened_at, duration_seconds=600)
|
||||
expired = Round(status="open", opened_at=opened_at, duration_seconds=60)
|
||||
|
||||
assert round_accepts_bets(still_open) is True
|
||||
assert round_accepts_bets(expired) is False
|
||||
|
||||
|
||||
async def test_cooldown_comes_from_the_round_that_closed(session_factory):
|
||||
"""The gap a closing round announced is the gap that's honoured: shortening
|
||||
round_cooldown_seconds afterwards must not open the next round early, nor
|
||||
lengthening it hold the lottery shut."""
|
||||
async with session_factory() as session:
|
||||
(await session.scalars(select(RoundConfig))).one().round_cooldown_seconds = 0 # just lowered
|
||||
session.add(
|
||||
Round(
|
||||
status="closed",
|
||||
opened_at=datetime.now(timezone.utc) - timedelta(seconds=200),
|
||||
closed_at=datetime.now(timezone.utc) - timedelta(seconds=10),
|
||||
cooldown_seconds=300, # what that round ran with
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
assert await open_new_round_if_needed(session) is None # still cooling down
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User
|
||||
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User
|
||||
from app.rounds.scheduler import RoundScheduler, _reserved_payout_outpoints
|
||||
from app.wallet.psbt_builder import MAX_TX_INPUTS
|
||||
|
||||
|
||||
class FakeListener:
|
||||
@@ -44,7 +46,8 @@ async def test_tick_closes_round_with_no_participants_once_due(session_factory,
|
||||
past = datetime.now(timezone.utc) - timedelta(seconds=10)
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address="", round_duration_seconds=1))
|
||||
session.add(Round(status="open", opened_at=past))
|
||||
# B-61: the deadline comes from the round's own snapshot, not from the config.
|
||||
session.add(Round(status="open", opened_at=past, duration_seconds=1))
|
||||
await session.commit()
|
||||
|
||||
scheduler = RoundScheduler(session_factory, FakeListener())
|
||||
@@ -155,6 +158,40 @@ async def test_trigger_payout_persists_before_broadcasting(payout_session_factor
|
||||
assert "payout_sent" in events
|
||||
|
||||
|
||||
async def test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_inputs(
|
||||
payout_session_factory,
|
||||
): # B-52
|
||||
"""End-to-end shape of the deadlock this fixes: the pool holds one UTXO per bet,
|
||||
so a round past MAX_TX_INPUTS participants could not be paid at all — the build
|
||||
failed with too_many_inputs, the round stayed "paying_out" retrying every 60s,
|
||||
and no new round could ever open behind it. It must now broadcast normally."""
|
||||
await _seed_paying_out_round(payout_session_factory)
|
||||
|
||||
participants = MAX_TX_INPUTS + 1
|
||||
bet_sats = _POOL_AMOUNT_SATS // participants
|
||||
entries = [
|
||||
{"tx_hash": f"{i:064x}", "tx_pos": 0, "height": 10, "value": bet_sats}
|
||||
for i in range(participants)
|
||||
]
|
||||
# The pool's total must cover the round's recorded pool_amount_sats, exactly as
|
||||
# on-chain: integer division above leaves a remainder, so top the last one up.
|
||||
entries[-1]["value"] += _POOL_AMOUNT_SATS - bet_sats * participants
|
||||
client = FakePayoutClient(entries)
|
||||
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
|
||||
|
||||
await scheduler._trigger_payout(1)
|
||||
|
||||
assert client.broadcasted
|
||||
async with payout_session_factory() as session:
|
||||
pending = (await session.scalars(select(PendingTransaction))).one()
|
||||
assert pending.status == "pending"
|
||||
assert len(Transaction.parse(bytes.fromhex(pending.raw_tx_hex)).vin) == participants
|
||||
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert "payout_sent" in events
|
||||
assert "payout_failed" not in events
|
||||
|
||||
|
||||
async def test_trigger_payout_broadcast_failure_leaves_a_recoverable_row(payout_session_factory):
|
||||
"""Before B-25, a broadcast rejection here left nothing behind — no payout_txid,
|
||||
no PendingTransaction — because everything was persisted only after the
|
||||
@@ -409,6 +446,63 @@ async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_
|
||||
assert events == ["draw_header_corroboration_failed"]
|
||||
|
||||
|
||||
# --- B-63: an unknown tip at closing time must not become the draw's seed --------
|
||||
|
||||
|
||||
class LateTipListener:
|
||||
"""A listener that doesn't know the tip yet and learns it only once asked —
|
||||
the state the old code could observe while `client` already looked alive."""
|
||||
|
||||
def __init__(self, *, learns: tuple[int, str], then_advances_to: tuple[int, str]):
|
||||
self.tip_height = 0
|
||||
self.tip_header_hex = None
|
||||
self._learns = learns
|
||||
self._then_advances_to = then_advances_to
|
||||
self.corroboration_calls: list[int] = []
|
||||
|
||||
def learn_tip(self) -> None:
|
||||
self.tip_height, self.tip_header_hex = self._learns
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
self.corroboration_calls.append(height)
|
||||
return True
|
||||
|
||||
|
||||
async def test_wait_for_next_block_never_seeds_the_draw_from_a_pre_close_block(
|
||||
session_factory, monkeypatch
|
||||
): # B-63
|
||||
"""A tip_at_close of 0 means the tip was *unknown* when the round closed, not
|
||||
that the chain was at height zero. The first header we then learn describes a
|
||||
block that may well predate the close — whose hash was public while bets were
|
||||
still open — so it must become the baseline, never the seed: the draw waits for a
|
||||
block strictly after it."""
|
||||
import app.rounds.scheduler as scheduler_module
|
||||
|
||||
listener = LateTipListener(learns=(500, "aa"), then_advances_to=(501, "bb"))
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
|
||||
async def fake_sleep(_seconds):
|
||||
# First sleep: the tip becomes known (height 500, the pre-close block).
|
||||
# Second: a genuinely new block arrives on top of it.
|
||||
if listener.tip_height == 0:
|
||||
listener.learn_tip()
|
||||
else:
|
||||
listener.tip_height, listener.tip_header_hex = listener._then_advances_to
|
||||
|
||||
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
|
||||
|
||||
height, _block_hash = await scheduler._wait_for_next_block(
|
||||
round_id=1, tip_at_close=0, waiting_since=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
assert height == 501 # the block *after* the one we first learned about
|
||||
assert listener.corroboration_calls == [501] # 500 was never even a candidate
|
||||
|
||||
async with session_factory() as session:
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert events == ["draw_baseline_tip_unknown"] # explainable from /admin
|
||||
|
||||
|
||||
# --- B-36: a stalled draw must be visible, not a silent frozen wait --------------
|
||||
|
||||
|
||||
@@ -457,3 +551,61 @@ async def test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold(s
|
||||
entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].round_id == 1
|
||||
|
||||
|
||||
async def test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check(session_factory): # B-53
|
||||
"""_tick counts in-flight bets in a session of its own, so a "building" row that
|
||||
commits between that count and the participant snapshot used to be invisible to
|
||||
both: the round drew and paid out without the bet, while its sats still landed in
|
||||
the pool. _close_and_draw re-checks in the same session it snapshots from, and
|
||||
must leave the round in "closing" for the next tick rather than draw."""
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address=""))
|
||||
session.add(Round(status="closing", opened_at=datetime.now(timezone.utc)))
|
||||
await session.commit()
|
||||
round_ = (await session.scalars(select(Round))).one()
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=round_.id,
|
||||
user_id=1,
|
||||
bet_amount_sats=1_000_000_000,
|
||||
bet_txid="ab" * 32,
|
||||
status="building", # committed a moment after _tick counted zero
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
round_id = round_.id
|
||||
|
||||
scheduler = RoundScheduler(session_factory, FakeListener())
|
||||
await scheduler._close_and_draw(round_id)
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
assert round_.status == "closing" # not drawn, and not closed as participant-less
|
||||
assert round_.winner_user_id is None
|
||||
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
|
||||
assert "round_closed" not in events
|
||||
assert "winner_drawn" not in events
|
||||
|
||||
|
||||
async def test_tick_ignores_a_config_duration_edited_mid_round(session_factory): # B-61
|
||||
"""Lowering round_duration_seconds from 600 to 30 while a round is 300s in used
|
||||
to close that round on the spot, because the deadline was recomputed live from
|
||||
the config on every tick. The edit applies to the *next* round."""
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address="", round_duration_seconds=30)) # just lowered
|
||||
session.add(
|
||||
Round(
|
||||
status="open",
|
||||
opened_at=datetime.now(timezone.utc) - timedelta(seconds=300),
|
||||
duration_seconds=600, # what this round opened with
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
scheduler = RoundScheduler(session_factory, FakeListener())
|
||||
await scheduler._tick()
|
||||
|
||||
async with session_factory() as session:
|
||||
round_ = (await session.scalars(select(Round))).one()
|
||||
assert round_.status == "open" # still 300s to go, by its own clock
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
|
||||
from app.auth import security
|
||||
|
||||
|
||||
@@ -42,3 +44,59 @@ def test_verify_password_still_rejects_a_wrong_password():
|
||||
stored = hash_password("correct-horse-battery")
|
||||
assert verify_password("correct-horse-battery", stored) is True
|
||||
assert verify_password("wrong", stored) is False
|
||||
|
||||
|
||||
# --- B-55: Argon2 must not run on the event loop --------------------------------
|
||||
|
||||
|
||||
async def _count_loop_ticks_during(coro) -> tuple[object, int]:
|
||||
"""Runs `coro` while a heartbeat task tries to run as often as the event loop
|
||||
lets it. A blocking call starves the heartbeat completely; a threadpooled one
|
||||
leaves the loop free the whole time."""
|
||||
ticks = 0
|
||||
|
||||
async def heartbeat() -> None:
|
||||
nonlocal ticks
|
||||
while True:
|
||||
ticks += 1
|
||||
await asyncio.sleep(0)
|
||||
|
||||
task = asyncio.create_task(heartbeat())
|
||||
await asyncio.sleep(0) # let the heartbeat reach its loop before timing starts
|
||||
try:
|
||||
result = await coro
|
||||
finally:
|
||||
task.cancel()
|
||||
return result, ticks
|
||||
|
||||
|
||||
async def test_hash_password_async_keeps_the_event_loop_free():
|
||||
"""Argon2 costs tens of milliseconds of CPU by design. Run inline from an async
|
||||
handler it froze the whole process for that long — every other request plus all
|
||||
six background tasks (scheduler, confirmation poller, RBF bumper, listener, both
|
||||
reconcilers) — which made a burst of unauthenticated login attempts a cheap way
|
||||
to delay draws and confirmations."""
|
||||
hashed, ticks = await _count_loop_ticks_during(security.hash_password_async("s3cret-passphrase"))
|
||||
|
||||
assert security.verify_password("s3cret-passphrase", hashed)
|
||||
assert ticks > 1 # the loop kept running while the hashing happened
|
||||
|
||||
|
||||
async def test_verify_password_async_keeps_the_event_loop_free():
|
||||
stored = security.hash_password("correct-horse-battery")
|
||||
|
||||
ok, ticks = await _count_loop_ticks_during(
|
||||
security.verify_password_async("correct-horse-battery", stored)
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert ticks > 1
|
||||
|
||||
|
||||
async def test_verify_password_async_rejects_a_wrong_password():
|
||||
"""Same answers as the synchronous function it wraps — including the B-13
|
||||
unparseable-hash case, which must read as "wrong password", not as an error."""
|
||||
stored = security.hash_password("correct-horse-battery")
|
||||
|
||||
assert await security.verify_password_async("wrong", stored) is False
|
||||
assert await security.verify_password_async("whatever", "not-an-argon2-hash") is False
|
||||
|
||||
@@ -5,12 +5,15 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from app.bets.service import place_bet
|
||||
from app.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal
|
||||
from app.db.models import PendingTransaction, RoundConfig, User, UtxoEvent, Withdrawal
|
||||
from app.rounds.events import broadcaster
|
||||
from app.wallet.hd import derive_user_address
|
||||
from app.withdrawals.service import WithdrawalError, request_withdrawal
|
||||
|
||||
|
||||
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
||||
|
||||
|
||||
class FakeElectrumClient:
|
||||
def __init__(self):
|
||||
self.broadcasted: list[str] = []
|
||||
@@ -40,6 +43,13 @@ async def session_factory(tmp_path, monkeypatch):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# B-66: a round only opens on an instance that could actually pay a winner, so
|
||||
# every test that expects one needs a fee address configured — the column has no
|
||||
# default on purpose (an operator must set their own).
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||
session.add(RoundConfig(fee_address=_FEE_ADDRESS))
|
||||
await session.commit()
|
||||
yield async_sessionmaker(engine, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
hd._account_key = None
|
||||
@@ -219,3 +229,129 @@ async def test_failed_broadcast_marks_the_withdrawal_failed_and_frees_the_coins(
|
||||
assert (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().spent_txid is None
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 3_000_000_000
|
||||
|
||||
|
||||
# --- B-62: "withdraw everything" must not build an unbumpable transaction ---------
|
||||
|
||||
|
||||
async def test_full_balance_withdrawal_keeps_a_bumpable_change_output(session_factory):
|
||||
"""The UI's max-amount checkbox sends the whole confirmed balance, so change came
|
||||
out at 0, the change output was dropped, and the tx had a single output —
|
||||
bump_fee then had nothing to shrink and raised RbfError every 30s until the
|
||||
reconciler abandoned the row hours later. Adding inputs is no answer here: the tx
|
||||
already spends every UTXO the user has. So a dust limit stays behind instead."""
|
||||
from embit.transaction import Transaction
|
||||
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
balance = 2_000_000_000
|
||||
user_id = await _make_funded_user(session_factory, 40, balance)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
withdrawal = await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, balance)
|
||||
|
||||
tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
|
||||
assert len(tx.vout) == 2 # recipient + change: bumpable
|
||||
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
|
||||
|
||||
# The user asked for everything and is told what actually went out — the row
|
||||
# already distinguishes the two, since the fee comes out of the amount anyway.
|
||||
assert withdrawal.amount_requested_sats == balance
|
||||
fee = balance - sum(o.value for o in tx.vout)
|
||||
change = min(o.value for o in tx.vout)
|
||||
assert change == DUST_LIMIT_SATS
|
||||
assert withdrawal.amount_sent_sats == balance - DUST_LIMIT_SATS - fee
|
||||
|
||||
|
||||
async def test_a_bet_from_a_balance_equal_to_the_bet_is_refused(session_factory):
|
||||
"""The same shape on the PLAY side, where reducing the amount isn't an option —
|
||||
the bet is a fixed price. "A user's balance must never exactly equal the bet" is
|
||||
a documented invariant of the PLAY phase; this is where it's enforced, with an
|
||||
error that says how much more is needed rather than a bare "insufficient"."""
|
||||
from app.bets.service import BetError
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
user_id = await _make_funded_user(session_factory, 41, BET_AMOUNT_SATS) # exactly the bet
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
with pytest.raises(BetError) as excinfo:
|
||||
await place_bet(session, client, user)
|
||||
|
||||
assert excinfo.value.code == "balance_leaves_no_change"
|
||||
assert excinfo.value.params == {"required_extra_sats": DUST_LIMIT_SATS}
|
||||
assert not client.broadcasted
|
||||
|
||||
async with session_factory() as session:
|
||||
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
|
||||
assert utxo.spent_txid is None # refused before anything moved
|
||||
|
||||
|
||||
async def test_a_bet_with_a_dust_limit_of_headroom_is_accepted(session_factory):
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
user_id = await _make_funded_user(session_factory, 42, BET_AMOUNT_SATS + DUST_LIMIT_SATS)
|
||||
client = FakeElectrumClient()
|
||||
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(session, client, user)
|
||||
|
||||
assert participant.status == "broadcast"
|
||||
|
||||
|
||||
async def test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed(session_factory):
|
||||
"""B-70: the flowchart's WITHDRAW node used to state that a withdrawal cannot
|
||||
happen together with a bet in progress. It can, and should: the hazard is the two
|
||||
picking the *same* UTXO, which is already excluded twice over — the per-user lock
|
||||
(app/tx/locks.py) keeps the two builds from ever being in flight at once, and
|
||||
select_utxos skips anything already marked spent_txid. What is left is untouched,
|
||||
confirmed money, and freezing it for a block just because a bet is in flight would
|
||||
be a restriction with no safety behind it. The diagram was corrected to match."""
|
||||
user_id = await _make_funded_user(session_factory, 43, 2_000_000_000)
|
||||
async with session_factory() as session:
|
||||
# A second confirmed UTXO the bet won't touch (select_utxos is largest-first).
|
||||
session.add(
|
||||
UtxoEvent(user_id=user_id, txid="ab" * 32, vout=1, amount_sats=1_500_000_000, confirmed_height=100)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
bet_client = FakeElectrumClient()
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
participant = await place_bet(session, bet_client, user)
|
||||
assert participant.status == "broadcast" # broadcast, not yet confirmed
|
||||
|
||||
withdraw_client = FakeElectrumClient()
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
withdrawal = await request_withdrawal(session, withdraw_client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS)
|
||||
|
||||
assert withdrawal.status == "broadcast"
|
||||
assert withdraw_client.broadcasted
|
||||
|
||||
async with session_factory() as session:
|
||||
utxos = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).all()
|
||||
spenders = {u.amount_sats: u.spent_txid for u in utxos}
|
||||
# Each transaction took its own input; neither is spending the other's.
|
||||
assert spenders[2_000_000_000] != spenders[1_500_000_000]
|
||||
assert all(txid is not None for txid in spenders.values())
|
||||
pending_kinds = {
|
||||
p.kind for p in (await session.scalars(select(PendingTransaction))).all()
|
||||
}
|
||||
assert pending_kinds == {"bet", "withdrawal"}
|
||||
|
||||
|
||||
def test_the_flowchart_no_longer_claims_bets_and_withdrawals_are_exclusive():
|
||||
from pathlib import Path
|
||||
|
||||
diagram = (
|
||||
Path(__file__).resolve().parents[2] / "flowchart" / "platform-overview.mmd"
|
||||
).read_text(encoding="utf-8")
|
||||
node = [line for line in diagram.splitlines() if line.strip().startswith("E1[")]
|
||||
assert len(node) == 1
|
||||
assert "non puo' avvenire insieme" not in node[0]
|
||||
assert "saldo confermato" in node[0]
|
||||
|
||||
Reference in New Issue
Block a user