14 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 575f1a5354 Readiness, Electrum status, audit browsing and logs (B-92, API-37, API-46, API-47, AUD-34, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29, SEC-48)
- GET /health/ready (Electrum connected, header < 1800s, scheduler heartbeat < 30s) is the
  container healthcheck; the scheduler beats on every tick and draw-wait iteration
- GET /admin/electrum and an Electrum indicator in /admin (server, header age, last rotation)
- GET /admin/audit-log: before_id, event_type, round_id, user_id, from, to; the panel gets filters
- registration writes user_registered in its transaction
- the app's own logs also go to stdout; uvicorn trusts X-Forwarded-For from Caddy's fixed
  address only, so the access log shows the client's IP

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 01:15:22 +02:00
davideandClaude Sonnet 5.5 9520c80dcf Close the smaller state inconsistencies (B-96, BET-02, BET-07, WDR-01, WDR-15, TX-16, TX-18, TX-27, TX-29, DEP-15, DEP-16, AUD-30, AUD-46, DAT-22)
- bet/withdrawal routes read listener.client after taking the user lock
- a reduced full-balance withdrawal is checked against the minimum again
- a bet covered only by pending balance answers balance_pending_confirmation
- a recovered bet/withdrawal/payout writes bet_placed/withdrawal_sent/payout_sent
- an abandoned payout clears winner/fee/reserve amounts with payout_txid
- the reconciler pings SSE only for rows it changed
- closing_stalled every 30 minutes past the deadline; closing_waiting_since on /rounds/current
- one refresh per user at a time; a failed refresh no longer ends the Electrum session

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 01:08:14 +02:00
davideandClaude Sonnet 5.5 24e5fc3d8b Bound credentials, brake password changes, cap bug reports, page the user list, structure every error (B-94, SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12, AUD-42)
- login and password-change fields: at most 256 characters
- wrong current password: per-user brake (5 free, 2s doubling to 300s), 429 even for the
  right password while locked; password_changed audited in the same transaction
- POST /bug-reports: 10 per IP per hour, consumed only by a stored report
- GET /admin/users: limit/after_id paging, the panel walks the pages
- 401/403/404/405/422/503 all answer {code, message, params}; the 422 omits the input
- static files revalidated on every use (Cache-Control: no-cache)
- error.* keys for the new codes in all seven languages

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 00:59:25 +02:00
davideandClaude Sonnet 5.5 c1b192f6d1 Harden the container: non-root, pinned images and dependencies, the key in a file (B-91, SEC-51, SEC-52, SEC-53; closes B-71, B-72)
- app runs as uid 1000; a one-shot data-permissions service chowns ./data to it
  and closes it to others (works on an older root-owned install with no host sudo);
  files are created 0600 (umask 077)
- python:3.12-slim and caddy:2-alpine pinned by digest; dependencies installed from
  requirements.lock with --require-hashes
- the Fernet key is XPRV_ENCRYPTION_KEY_FILE (./secrets/xprv_encryption.key, a compose
  secret), out of every environment variable and of a ./data backup
- setup.md no longer presents a second way to run the server (B-72)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 00:54:23 +02:00
davideandClaude Sonnet 5.5 40ac490826 Safe restore from a backup: START_PAUSED and the derivation-gap check (B-90, REG-15, OPS-27)
START_PAUSED=true pauses the lottery as the operator before the scheduler exists.
On the first Electrum session the 20 derivation indexes past the highest
assigned one are looked up on every server; if any has history, registration
stays closed (503 registration_unavailable) and restore_gap_detected is
written, so a restored database cannot hand a lost user's address to a new one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 00:43:45 +02:00
davideandClaude Sonnet 5.5 55d852fe08 Verify the master key against the database at startup (B-89, SEC-49, SEC-50, SEC-09)
The lifespan decrypts the key and compares a fixed sample (reserve/round pool
addresses, the 10 lowest and highest user addresses, unrevealed round secrets)
with the database, and refuses to serve on any mismatch without printing key
material. The key scripts refuse a new or imported key that would not reproduce
the stored addresses, and write 0600.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 00:38:26 +02:00
davideandClaude Sonnet 5.5 32df50cb75 Select coins until the change output is guaranteed (B-98, BET-24)
select_utxos took the first coin covering the amount, so 10 + 1 PLM and a
10 PLM bet was refused with balance_leaves_no_change and a 10 PLM withdrawal
was reduced. User txs now ask for target + DUST_LIMIT_SATS; the old outcome is
kept only when no allowed combination leaves change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 00:35:17 +02:00
davideandClaude Sonnet 5.5 264441f5d6 Align docs with the code: stale columns, B-92 health threshold, duplicates
- modello-dati: drop 'da implementare' from fields B-73/B-80/B-82/B-85 shipped
  and the stale B-76 note; limiti-noti: remove a duplicated paragraph
- /health/ready threshold 360s -> 1800s in the spec (B-92, not yet built): with
  120s blocks 360s misfires ~5% of the time (e^-3)
- storico-bug/CLAUDE.md/README: register B-99, B-100, test count 698

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 00:32:24 +02:00
davideandClaude Sonnet 5.5 cef05943f9 Keep the fee-rate ceiling below the node's maxfeerate (B-100)
MAX_FEE_RATE_SAT_VB was exactly sendrawtransaction's default maxfeerate
(10 000 sat/vB); with vsize estimated upwards a rate at the ceiling would be
refused. Now 5 000.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 00:32:24 +02:00
davideandClaude Sonnet 5.5 2c0e71a74c Refuse an Electrum server that is not on the PLM chain (B-99, ELC-30)
PLM shares Bitcoin's genesis and powLimit, so a Bitcoin server passed every
header check. connect() now asks server.features and requires PLM's genesis.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 00:32:24 +02:00
davideandClaude Sonnet 5.5 1433813ec7 Install SQLAlchemy's asyncio extra so greenlet ships in the image
A clean `pip install .` in the Docker image left out greenlet, so
`alembic upgrade head` crashed on import of sqlalchemy.ext.asyncio and
the container restart-looped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 16:29:21 +02:00
davideandClaude Sonnet 5.5 8b182ac8b6 Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto
the round when it opened. The cost of a bet and the fee address were read live, so an edit from
/admin in the middle of a round gave the players of that round different stakes for the same
chance and redirected the commission of the round in progress.

- `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from
  round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column,
  existing rounds backfilled with the current configuration, i.e. what they were using; the
  expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the
  round's fee address (an empty one on the row still stops the payout, as before),
  `GET /rounds/current` advertises the round's cost while one is active and the configured one
  otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10).
- `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in
  /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry
  `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included,
  has reached the round's cap, before anything moves; the pool can exceed it by one bet at most.
  A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`.
- PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the
  bet cost the round in progress opened with, which can be lower than the configured one.
- `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the
  seven translations are generic.

Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and
"applies immediately, also to the round in progress" in the round_config table of modello-dati;
the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line).

Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and
the old "fee address missing" payout test now empties the round's address (clearing it in the
configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open,
edits reaching only the next round, every bet of a round paying the same stake whether the cost
is raised or lowered meanwhile, the payout following the round's address (and surviving it being
cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap,
a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route,
the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the
migration backfill. Mutation-checked: charging the live cost, paying the live address, not
copying the cap and `>` instead of `>=` each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:12:00 +02:00
davideandClaude Sonnet 5.5 ccd15807ed Drop a dust-sized change into the fee on an RBF bump and audit every refused bump (B-87, TX-11, TX-24, AUD-40, AUD-09, ADR-19)
`bump_fee` only required `change > fee delta`. A change at the 294 sat minimum (a full-balance
withdrawal) shrank to ~153 sat after the very first minimum bump, relay policy refuses a
replacement with a sub-dust output, and the bumper tried again every 30 seconds for ever, with
nothing but a log line to show for it.

- When what would be left of the reducible output (the user's change, a payout's bump
  reserve) is under DUST_LIMIT_SATS the output is dropped and its whole value goes to the fee:
  that replacement is valid and the last one possible. If the output does not exist, or even
  its whole value is below BIP125's minimum increase (vsize x 1 sat/vB), RbfError. The
  recorded fee rate uses the replacement's own, smaller, size.
- RbfError now carries a `reason` (`change_too_small`, `max_fee_rate`, `replacement_rejected`);
  RbfBumper._record_failure writes one `rbf_bump_failed` row {pending_transaction_id, kind,
  txid, reason, message[:200]} with the row's user_id/round_id, in a transaction of its own,
  once per (current txid, reason) - the audit log is the dedupe clock (AUD-09) - and the pending
  row is untouched. A new txid or a new reason writes a new row.
- bump_fee's docstring no longer cites the "bet without change" case that B-62 removed.

No existing test changed (all bumps they make leave a change well above dust). New
tests/unit/test_rbf_dust.py: the 294 sat case end to end (recipient untouched, whole change to
the fee, last bump impossible), the exact boundary (leaves 294 kept / leaves 293 dropped), a
change under the minimum increase, a payout's reserve dropped with the winner and fee outputs
byte-identical, and the audit rows for each reason, once per pair, new row on a new txid or
reason, round/user attribution. Mutation-checked: the dust test of the output and the
once-per-pair check each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 14:53:10 +02:00
davideandClaude Sonnet 5.5 54dbffb552 Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
A user could bet and then double-spend the 1-conference deposit that funded the bet with a
private chain: the bet left the chain but its participant stayed `confirmed`, and the payout
failed "insufficient pool UTXOs" for ever with everyone else's money stuck. Coinbase outputs
were also credited at 1 confirmation (maturity is 120).

Deposits
- `round_config.deposit_confirmations` (default 6, 1-100, editable from /admin) is the
  threshold for a deposit; an output of a platform transaction (any txid of a
  pending_transactions row, RBF versions included) keeps 1; a coinbase output needs 120,
  recognised from the raw transaction, which must hash to the txid (`is_coinbase`, cached).
  `read_deposit_policy` / `classify_new_deposits` run between the read and the write, so no
  session is open across the lookup.
- The quorum must also place the coin deep enough on our own tip (ELC-17).
- Confirmed outputs short of their threshold are pending deposits (memory only, replaced at
  every refresh): shown in `pending_balance_sats` / `has_pending` (also for DEP-13's withdrawal
  message), never spendable. No scripthash notification announces a maturing deposit, so each
  new block refreshes only the users that have one, one task per user (DEP-09 now says so).

Payout
- `_trigger_payout` verifies the round's confirmed participants' bet outputs with the other
  servers (`verify_pool_outpoints`: strict majority of >= 2 answers per outpoint, a
  mempool-only coin or a tie gives no verdict) and builds from exactly those outpoints
  (RND-22), never from whatever else sits at the address. No quorum: `payout_failed`, `pool
  verification unavailable`, nothing changes. Bets that are gone become `reorged_out`, one
  `participant_reorged_out` row each, `pool_amount_sats` rewritten (only downward) in the same
  transaction, and the 70/30 split is computed on what is left.
- Winner's own bet gone: nothing is built; `rounds.payout_blocked_at` (Alembic f2c64a8d1e93,
  with `deposit_confirmations`, plain add_column) is set, `payout_blocked` and the pause with
  `paused_reason = 'payout_blocked'` are written in one transaction; `POST /admin/resume` clears
  the mark and the next attempt verifies again. /admin/rounds returns it.
- Rounds opened before B-80 (pool = the shared reserve address) keep the old active-server
  `listunspent` choice, since that address holds no single round's bets.

Ambiguities resolved: the winner's row stays `confirmed` while held (AUD-45 says every
participant whose bet is gone becomes `reorged_out`, but OPS-30/DAT-68 need the verification to
repeat after the resume, which only a `confirmed` row allows); `/`'s deposit hint no longer says
"1 confirmation" (7 languages, same keys); guides updated.

Tests changed because the spec changed: listener tests that credited height-100 coins at tip 0
now set a tip past every threshold; the payout fixtures seed confirmed participants (the new
source of the inputs) instead of an active-server `listunspent`, FakeNetwork answers the
verification, and test_round_pool_address asserts the quorum is asked about the round's
address. New: thresholds at exactly 6/5, configured value, coinbase at 119/120 and a mismatching
or missing raw tx, platform outputs at 1, dust, quorum height, waiting-deposit display and
disappearance, block-triggered refresh, resume, verification verdicts, gone bets excluded with
the split recomputed, winner gone held and paused, held payouts untouched until the resume,
re-verification after it, no quorum, a payout with 1 and with 400 participants against
reserve+fee+dust, admin config bounds and migration. Mutation-checked: threshold 1, ignored
coinbase, no height bound, no verification and no winner hold each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 14:25:41 +02:00
96 changed files with 5755 additions and 1113 deletions

No files matched your search

+1
View File
@@ -11,3 +11,4 @@ data/
*.egg-info/
tests/
.claude/
secrets/
+13 -3
View File
@@ -30,9 +30,14 @@ ELECTRUM_CERT_PIN=
# NEVER true in production.
ALLOW_NO_QUORUM=false
# Fernet key protecting the master xprv at rest. Generate with:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
XPRV_ENCRYPTION_KEY=
# File holding the Fernet key that protects the master xprv at rest (B-91): in a file, not in
# an environment variable, so `docker inspect` cannot show it and a backup of ./data does not
# carry both. Inside the container docker-compose.yml mounts it as a secret and overrides
# this path. Create it once:
# mkdir -p secrets && chmod 700 secrets
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" > secrets/xprv_encryption.key
# chmod 600 secrets/xprv_encryption.key
XPRV_ENCRYPTION_KEY_FILE=./secrets/xprv_encryption.key
# Random secret for JWT session signing. Generate with:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
@@ -50,3 +55,8 @@ ADMIN_TOKEN=
# endpoints included — to anyone who requests them. Off by default; set to
# true only for local development, never in production.
ENABLE_API_DOCS=false
# Start with the lottery paused (as the operator's pause, audit-logged), e.g. after
# restoring a backup: nothing opens a round until you resume from /admin. See
# docs/operativita.md (OPS-27).
START_PAUSED=false
+1
View File
@@ -8,3 +8,4 @@ master.xprv.enc
*.egg-info/
logs/
data/
secrets/
+5 -191
View File
@@ -1,77 +1,10 @@
# BUGS.md — open findings
Two audits currently have open entries: 2026-08-03 (B-71, B-72, below) and the
2026-09-28 specification review (B-73 … B-98, at the end of the file).
## Audit of 2026-08-03
Third full-codebase audit, opened after the 2026-07-26 (B-01 … B-24) and
2026-07-27 (B-25 … B-49) lists were emptied. Numbering continues from the last
fixed finding, B-51.
The list opened at B-52 … B-72 and holds only what is still **open**: a finding is
removed from this file once it is fixed, and is not listed here afterwards. Per
CLAUDE.md's convention each entry gets its own commit with its own regression test,
and the `B-nn` marker goes in a comment next to the fix, so
`git log --all --grep 'B-nn'` is the record of how any closed finding was closed.
State of the tree at audit time: 264 unit tests, all passing; `tests/integration/`
still empty; withdrawal and the RBF bump path still never live-broadcast.
Verified as *not* broken while looking for these: i18n key parity (146 identical
keys across all 7 languages), HTML escaping of every user-controlled value in
`admin.js`, the Alembic chain (linear, single head, matching `models.py`),
strictly-integer satoshi arithmetic everywhere, and `.gitignore` coverage of
secrets/DB/logs (nothing sensitive is tracked in git).
Severity is about consequence, not likelihood:
**critical** = money stuck or lost, or the lottery stops;
**high** = a security control that does not hold;
**medium** = wrong behaviour with a bounded blast radius;
**low** = drift between documentation and code.
---
## Critical
### (not new) `drawing` does not resume after a restart
Already tracked as an accepted gap in CLAUDE.md's "Known gaps", not re-numbered
here. Worth restating in context: with `restart: unless-stopped` on the app
container, this is the one state that gets stuck with money in play, and it
remains the last prerequisite for running unattended.
---
## Low — documentation and consistency drift
### B-71 — `.env` points `MASTER_KEY_PATH` at a second copy of the master key
CLAUDE.md's deployment section prescribes pointing `MASTER_KEY_PATH` at the
host-side `./data/keys/master.xprv.enc` so the venv scripts and the container read
one file. `.env` instead sets `./master.xprv.enc`, and both files now exist in the
working tree (both gitignored). They were verified during this audit to decrypt to
the *same* xprv, so nothing has diverged yet — but `scripts/decrypt_master_key.py`
reads a different file from the one the container uses, and a future
`generate_master_key.py --overwrite` would split them silently, with an ops
recovery path that then reports the wrong key.
The same duplication exists for the database (`./plm_lottery.db` next to
`data/db/plm_lottery.db`), which is less dangerous but equally confusing.
Fix: set `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, delete the stray
root copy once confirmed redundant, and state the same for `DATABASE_URL`.
### B-72 — `docs/setup.md` still frames setup as "locally or via Docker"
`docs/setup.md:1-10` lists Python as a prerequisite "for the local/venv workflow"
and describes the master-key step as local *or* Docker, while B-44 made Docker the
only supported way to run the server (`docs/running-the-server.md` and README were
updated, this file was not). The venv genuinely is needed for tests, migrations and
the key scripts — the wording just needs to say that instead of implying a second
way to run the server.
---
Only the 2026-09-28 specification review (B-73 … B-100) still has open entries
(B-93). A finding is removed from this file once it is fixed, and
`git log --all --grep 'B-nn'` is the record of how it was closed. Per CLAUDE.md's
convention each entry gets its own commit with its own regression test, and the `B-nn`
marker goes in a comment next to the fix.
# Audit of 2026-09-28 — specification review (B-73 … B-98)
@@ -100,78 +33,8 @@ decisions, see `docs/limiti-noti.md`):
- **Contain reorgs instead of pretending they don't happen:** detect them, pause
on deep ones, and pay out only what is verifiably on chain.
## Critical
### B-82 — A deposit reorged out after a bet leaves the round unpayable
Deposits are credited at 1 confirmation. A user can bet, then (if they lost)
double-spend the deposit the bet spent with a private chain: the bet disappears but
its participant stays `confirmed` and counted, and the payout fails "insufficient
pool UTXOs" forever with every other player's money stuck. Immature coinbase outputs
are also credited at 1 confirmation (maturity is 120).
Fix: deposits credited at `deposit_confirmations` (round_config, default 6),
coinbase outputs at 120. Before building a payout the round address's UTXOs are
verified with the quorum; participants whose bet outpoint is gone are marked
`reorged_out` and the 70/30 split is computed on what is actually there. If the
winner's own bet is gone the payout is held (`payout_blocked`) and the lottery pauses.
## High
### B-87 — The RBF bump can leave a dust change output
`bump_fee` only requires change > fee delta. A 294-sat change (full-balance
withdrawal) becomes ~153 sat after the minimum bump and every replacement is refused
by relay, every 30s.
Fix: when a bump would leave change below `DUST_LIMIT_SATS`, the change output is
dropped and its value goes to the fee (the replacement is then final); `RbfError`
and every refused bump write a `rbf_bump_failed` audit row.
### B-89 — Key problems surface only at first use
Startup checks only that the key file exists and `XPRV_ENCRYPTION_KEY` is non-empty;
a wrong key or a different xprv is found at the first registration or payout, and a
different xprv silently moves the pool address. The scripts accept any xprv and write
files `0644`.
Fix: at startup decrypt the key and check the pool reserve address and a sample of
stored user addresses; refuse to serve on mismatch. The scripts refuse an xprv whose
derived addresses differ from the DB's and write `0600`.
### B-90 — Restoring a backup is unsafe
A restored DB reopens rounds that were already drawn and paid, and reassigns the
derivation indexes of users registered after the backup to new users — who then
receive the old users' funds.
Fix: `START_PAUSED=true` starts with the lottery paused; at startup the next 20
indexes past the highest assigned are checked for on-chain history and, if any has
some, registration is refused until the operator reconciles (`restore_gap_detected`);
per-round addresses (B-80) and B-82's pre-payout check make a replayed round
unpayable instead of payable twice.
### B-91 — Host and supply-chain hardening
The container runs as root, `.env` and key files are world-readable, dependencies are
unpinned `>=` and base images float, secrets sit in env vars visible to
`docker inspect`.
Fix: non-root `USER`, `0600`/`0700` permissions, a hash-pinned lock file,
digest-pinned base images, the Fernet key mounted as a file rather than an env var.
## Medium
### B-92 — No health, readiness or real audit browsing
`/health` always answers ok; nothing exposes Electrum state; the audit endpoint
returns only the latest 500 rows unfiltered; registration writes no audit row; the
access log records Caddy's IP; app logs go only to the file.
Fix: `GET /health/ready` (Electrum connected, tip age < 360s, scheduler heartbeat <
30s) used as the container healthcheck; Electrum status in `/admin`; audit filters by
event type, round, user and time, with pagination; `user_registered` audit row with
index and address; uvicorn proxy headers from Caddy only; logs also to stdout.
### B-93 — Deploys and rollbacks
@@ -183,52 +46,3 @@ kills after 10s); an older image cannot start on a newer DB; images are untagged
Fix: close SSE streams on shutdown, `stop_grace_period: 30s`, await task
cancellation; tag images per release; `SITE_ADDRESS` in `.env` with `${SITE_ADDRESS:?}`;
documented pre-deploy backup and downgrade procedure.
### B-94 — Account and endpoint abuse limits
`change-password` has no rate limit, no audit and no maximum length; login fields
have no length bounds; `POST /bug-reports` is anonymous and unlimited;
`GET /admin/users` has no pagination; admin, SSE and framework errors are bare
strings; static JS/CSS have no `Cache-Control`.
Fix: throttle and audit (`password_changed`) the password change, cap every
credential field at 256, rate-limit bug reports per IP, paginate the user list,
structured errors everywhere, `Cache-Control: no-cache` on static files.
### B-95 — Round parameters read live instead of snapshotted
Only duration and cooldown are snapshotted at open; bet amount, `fee_address` and the
new `draw_confirmations` are read live, so an edit mid-round changes the round in
progress (e.g. redirects its commission).
Fix: snapshot `bet_amount_sats`, `fee_address` and `draw_confirmations` on the round
at open. Optional per-round cap `max_round_pool_sats` (round_config, 0 = disabled):
once reached, further bets get `round_full`.
### B-96 — Smaller state inconsistencies
A reduced full-balance withdrawal is not re-checked against the minimum; an abandoned
payout leaves `winner_amount_sats`/`fee_amount_sats` set; a promoted payout writes no
`payout_sent` and a promoted bet no `bet_placed`; the reconciler pings SSE for every
row it checks; concurrent refreshes of one user can collide on the unique outpoint
and end the Electrum session; `listener.client` is read outside the per-user lock;
a bet covered only by pending change gets `insufficient_balance` instead of
`balance_pending_confirmation`. A round waiting in `closing` on a bet that never
leaves the mempool gives no signal at all.
Fix: each as described, one regression test per item; for the `closing` wait, a
`closing_stalled` audit row every 1800s past the deadline and `closing_waiting_since`
on `GET /rounds/current` (spec: TX-29).
### B-98 — Coin selection stops at the first covering UTXO, refusing a payable bet
Found while writing the spec on 2026-09-28 (medium). `select_utxos` returns as soon
as the selected total covers the target. When that happens exactly (a 10 PLM coin
for a 10 PLM bet), the change is 0: the bet is refused with
`balance_leaves_no_change` and a withdrawal is reduced by `DUST_LIMIT_SATS`, even
though a second coin would have left a proper change output.
Fix: select until the total covers `target + DUST_LIMIT_SATS` (still largest-first,
still capped at `max_inputs`); only when every allowed coin together cannot reach it
fall back to today's behaviour (refuse the bet, reduce the withdrawal). Spec:
`docs/scommesse-e-prelievi.md` BET-24.
+20 -17
View File
@@ -8,9 +8,9 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 stages of the original build order are code-complete and unit-tested — 637 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
All 10 stages of the original build order are code-complete and unit-tested — 758 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
Two full-codebase audits — 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are **all fixed** as of 2026-07-27, each with its own regression test. A third audit (2026-08-03, B-52 … B-72) and a fourth, the 2026-09-28 specification review (B-73 … B-98: draw manipulation, ambiguous broadcasts, single-txid tracking, single-server abandonment, the 64 KiB Electrum reply limit, unbumpable payouts, 1-conf deposit reorgs, ops hazards), live in the tracked [BUGS.md](BUGS.md), which lists only what is still **open** (B-71 … B-98 at the time of writing). The spec in `docs/` already describes the *target* behaviour for all of them — every affected requirement is marked `*Stato:* da implementare (B-nn)` with an `*Oggi:*` line saying what the code does now, so read both before assuming a requirement is implemented; the design decisions and the implementation order are in [docs/piano-correzioni.md](docs/piano-correzioni.md); the full history of every B-nn, open and closed, is in [docs/storico-bug.md](docs/storico-bug.md). Closed findings leave the file, so the ~276 `B-nn` markers left in comments across the code are pointers into git history (`git log --all --grep 'B-nn'` finds the commit that fixed one, and `git show f1a1145:BUGS.md`-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (`tests/integration/` is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted **by design** instead. A new finding gets the next B-nn, in its own commit with its own regression test.
@@ -44,23 +44,23 @@ pip install -e ".[dev]"
alembic revision --autogenerate -m "message" # after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the master xprv (needs XPRV_ENCRYPTION_KEY in .env)
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the master xprv (needs the key file `XPRV_ENCRYPTION_KEY_FILE` points at)
PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print it (asks for confirmation)
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # all 637 tests
python -m pytest # all 758 tests
python -m pytest tests/unit/test_hd.py # one file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
```
`asyncio_mode = "auto"` (`pyproject.toml`), so async tests need no `@pytest.mark.asyncio`.
`.env` (gitignored) holds the real secrets; `.env.example` documents the required keys and how to generate each. Note it does **not** list `DATABASE_URL` or `MASTER_KEY_PATH`, which the real `.env` does set.
`.env` (gitignored) holds the real secrets (except the Fernet key, which is `secrets/xprv_encryption.key`); `.env.example` documents the required keys and how to generate each. Note it does **not** list `DATABASE_URL` or `MASTER_KEY_PATH`, which the real `.env` does set.
## Deployment (Docker + Caddy)
Same `docker-compose.yml` for dev and prod — only `SITE_ADDRESS` differs. Two containers: `app` (this codebase; its startup command refuses to start if the master key file is missing, then runs `alembic upgrade head` and `uvicorn`) and `caddy` (reverse proxy + automatic TLS). The compose file overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted key, logs — gitignored, survive restarts). Set `MASTER_KEY_PATH` in `.env` to the host-side `./data/keys/master.xprv.enc` so the venv scripts write the exact file the container reads — one source of truth for the key.
Same `docker-compose.yml` for dev and prod — only `SITE_ADDRESS` differs. Three services: a one-shot `data-permissions` (root, chowns `./data` to uid 1000 and closes it to others, B-91), `app` (non-root uid 1000; this codebase; its startup command refuses to start if the master key file is missing, then runs `alembic upgrade head` and `uvicorn`) and `caddy` (reverse proxy + automatic TLS). The compose file overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted key, logs — gitignored, survive restarts). Set `MASTER_KEY_PATH` in `.env` to the host-side `./data/keys/master.xprv.enc` so the venv scripts write the exact file the container reads — one source of truth for the key.
```bash
mkdir -p data/db data/keys data/logs # one-time
@@ -78,7 +78,7 @@ The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Option
- Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite, `embit` for keys/PSBT/tx parsing.
- **PLM access via the Electrum protocol only** (no full node/P2P). Dev bootstrap server: `santantonio.sytes.net:50002` (SSL).
- Auth: Argon2 hashing + JWT (HS256, 24h). Tokens **are** revocable (B-34): the token carries a `tv` claim, `User.token_version` is bumped by a self-service password change and by an admin reset, and `get_current_user`/`get_optional_user` reject any token whose `tv` no longer matches — so changing the password invalidates every session issued before it, instead of leaving them valid for up to `jwt_expire_minutes`. A token predating the claim decodes as `tv = 0`, which is what a migrated user starts at, so the deploy didn't log everyone out. Argon2 costs tens of ms of CPU per call by design, so every async caller goes through `hash_password_async`/`verify_password_async` (`run_in_threadpool`, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts.
- Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or `XPRV_ENCRYPTION_KEY` is empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
- Secrets: master xprv Fernet-encrypted at rest, encryption key in its own file, `./secrets/xprv_encryption.key` (`XPRV_ENCRYPTION_KEY_FILE`; a compose secret in the container, never in the DB, git or an environment variable — B-91). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or the encryption key file is missing or empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway.
## PLM network parameters (mainnet)
@@ -98,16 +98,18 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
| Parameter | Value | Where |
|---|---|---|
| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable |
| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable, **snapshotted onto `Round.bet_amount_sats` when a round opens** (B-95) — an edit applies from the next round |
| Prize split | **70% winner / 30% platform**, rounding remainder to the platform; the platform's share pays the **bump reserve** (`max(294, vsize(inputs, 3 outputs) × 10)` sat, B-79) to the reserve address `1/0` and the rest goes to `fee_address` | **hardcoded** in `rounds/service.py:winner_share` / `wallet/psbt_builder.py:payout_bump_reserve` — a code change, not an admin edit |
| Round duration / cooldown | 600s / 30s | `RoundConfig`, but **snapshotted onto `Round.duration_seconds`/`cooldown_seconds` when a round opens** (B-61) — an edit applies from the next round, never to the one in progress |
| Draw animation | 20s (cosmetic frontend minimum only) | `RoundConfig` |
| Draw confirmations | 3 blocks on the draw block (`draw_confirmations`, 1–20) | `RoundConfig`, admin-editable, **snapshotted onto `Round.draw_confirmations` at open** (B-73) |
| Commission address | `fee_address` | `RoundConfig`, admin-editable, **snapshotted onto `Round.fee_address` at open** (B-95): the payout pays the round's own, never the live one |
| Pool cap | `max_round_pool_sats`, 0 = none (otherwise ≥ the bet cost, ≤ 4·10^15) | `RoundConfig`, **snapshotted onto `Round.max_round_pool_sats` at open** (B-95); `place_bet` refuses with `round_full` (params `max_round_pool_sats`) once the sum of the round's participant rows has *reached* it, in-flight bets included — the pool can end up above the cap by at most one bet |
| Fee rate / RBF timeout | 1 sat/vB / 900s | `RoundConfig` |
| Min withdrawal | = current `bet_amount_sats` (no separate field) | `withdrawals/service.py` |
| Min deposit | none | — |
| Min password length | 8 | `auth/security.py:MIN_PASSWORD_LENGTH` |
| Confirmations, every tx kind | **1** | hardcoded in `tx/confirmation.py` |
| Confirmations | **1** for every platform-built tx (confirmation poller) and for the outputs it creates (bet/withdrawal change, payouts); **`deposit_confirmations` (6)** for a deposit, **120** for a coinbase output (B-82) | tx confirmation hardcoded in `tx/confirmation.py`; deposit threshold in `round_config` (`/admin`), coinbase maturity hardcoded in `deposits/service.py` |
| Max inputs per *user* tx (bet, withdrawal) | 50 (`MAX_TX_INPUTS`, B-48) — over it the build fails with `too_many_inputs`, it never spends more | hardcoded in `wallet/psbt_builder.py` |
| Max inputs per *payout* | 500 (`MAX_PAYOUT_TX_INPUTS`, B-52) — the pool holds one UTXO per bet, so reusing the user cap made any round past ~50 players unpayable | hardcoded in `wallet/psbt_builder.py` |
| Max participants per round | 400 (`MAX_PARTICIPANTS_PER_ROUND`, B-52) — the 401st bet is refused with `round_full` *before* any money moves, so "a round can always be paid out" is an invariant rather than something discovered at payout time | hardcoded in `wallet/psbt_builder.py`, enforced in `bets/service.py` |
@@ -116,9 +118,9 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
**Round cooldown** (`round_cooldown_seconds`, not in the original design): gap after a round closes before the next opens, so players can see the outcome.
**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner, with `paused_reason` (B-78, DAT-69): `operator`, or an automatic reason the system wrote itself (`resurrected_tx` so far, `rounds/pause.py`) — an operator pause never overwrites an automatic one, and resume clears whichever it is.
**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner, with `paused_reason` (B-78, DAT-69): `operator`, or an automatic reason the system wrote itself (`resurrected_tx`, `reorg`, `payout_blocked`, `rounds/pause.py`) — an operator pause never overwrites an automatic one, and resume clears whichever it is.
**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time. Since B-79 (RND-40) that includes the amounts: `payout_config_problem(bet_amount_sats, fee_rate_sat_vb)` checks a round of 1 and of `MAX_PARTICIPANTS_PER_ROUND` participants against the winner's network fee, the bump reserve and dust outputs, and `PUT /admin/config` refuses a bet amount or fee rate that fails it with `422 config_not_payable` (the payout reads `fee_rate_sat_vb` live, so raising it mid-round is exactly what could otherwise strand the round in progress).
**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time. Since B-79 (RND-40) that includes the amounts: `payout_config_problem(bet_amount_sats, fee_rate_sat_vb)` checks a round of 1 and of `MAX_PARTICIPANTS_PER_ROUND` participants against the winner's network fee, the bump reserve and dust outputs, and `PUT /admin/config` refuses a bet amount or fee rate that fails it with `422 config_not_payable` (judged on the resulting values and, for a fee-rate change, on the in-progress round's own snapshotted cost as well; B-95 adds `422 max_round_pool_below_bet_amount` for a cap below the cost of one bet) (the payout reads `fee_rate_sat_vb` live, so raising it mid-round is exactly what could otherwise strand the round in progress).
## Code map
@@ -158,8 +160,9 @@ One connection serves everything — deposit credits, broadcasts, confirmations,
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls][:pin=<sha256>]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed; `pin=` fixes the server's DER certificate hash, `ELECTRUM_CERT_PIN` does the same for the primary, B-84). The server **refuses to start with fewer than 2 fallbacks** (`config.validate_quorum_servers`, B-84; `ALLOW_NO_QUORUM=true` overrides it, development only). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
- **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets` *while holding the per-user lock*, and could stall the confirmation poller permanently.
- **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. A server that keeps answering pings but stopped following the chain is caught too (B-86): at every ping, if no header has been *accepted* for 360s (`_STALL_SECONDS`), `_check_not_stalled` asks the other servers for their tip and ends the session with `ServerStalledError` if any is higher, so `run()` rotates; none higher means a quiet chain and the session goes on. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
- **The chain is checked, not just the headers** (B-99, ELC-30): `ElectrumClient.connect` follows `server.version` with `server.features` and refuses a server whose `genesis_hash` is not PLM's. PLM shares Bitcoin's genesis *and* powLimit, so a Bitcoin Electrum server would pass every header check below; this is what tells them apart, for the quorum's connections too.
- **Headers are validated, not trusted** (`_apply_header`, async since B-74): the tip never regresses, a header must meet the difficulty target it claims *and* that target must be no easier than `draw.POW_LIMIT` (PLM's powLimit; `tests/unit/conftest.py` swaps in a regtest limit so tests can mine headers, and the tests of the limit itself put `MAINNET_POW_LIMIT` back), and every advance must be linked to the tip: a single block by its `prev_hash`, a jump by downloading the headers in between (`blockchain.block.headers`, ≤2016 per request) from the active server and checking each. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. The listener keeps the last 10 headers (`_window`, built from the first header plus its 9 predecessors); a header that does not join the tip is a reorg, walked back to its fork point inside the window and followed (`_follow_reorg`), a fork older than the window ends the session. A followed reorg writes `chain_reorg_detected` *before* the window moves (`_record_reorg`, B-85), and a depth of 2 or more pauses the lottery with `paused_reason = 'reorg'` in the same transaction; a header at the tip's own height with a different hash is a reorg too, not ignored. One more header is refused *without* ending the session (B-64): one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63.
- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — and since B-83 that includes `corroborate_utxo_reinstatement` before a UTXO written off as externally spent is put back (same outpoint, amount, confirmed; `deposits/service.py:find_reinstate_candidates` only reads, `reinstate_reappeared_utxos` writes what the quorum agreed on), and `confirmation_proven` before a tx is marked confirmed: `tx/confirmation.py:poll_once` takes the height the active server reports only as a *claim*, and the row confirms when a strict majority of at least 2 of the *other* servers return a `get_merkle` proof that recomputes to the merkle root of the block's header (`electrum/merkle.py`; the header is the tip we hold, or for an older block the one the quorum agrees on and that meets its own target). With no `network` nothing confirms. The one decision still on the active server's word is the pool-coin listing for a payout (B-82). It needs **at least 2 answers** and a strict majority of those that answered (B-84): fewer → returns **False**, since one voice decides nothing independently and an unreachable network proves nothing. Only `ALLOW_NO_QUORUM=true` keeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep.
- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — and since B-83 that includes `corroborate_utxo_reinstatement` before a UTXO written off as externally spent is put back (same outpoint, amount, confirmed; `deposits/service.py:find_reinstate_candidates` only reads, `reinstate_reappeared_utxos` writes what the quorum agreed on), and `confirmation_proven` before a tx is marked confirmed: `tx/confirmation.py:poll_once` takes the height the active server reports only as a *claim*, and the row confirms when a strict majority of at least 2 of the *other* servers return a `get_merkle` proof that recomputes to the merkle root of the block's header (`electrum/merkle.py`; the header is the tip we hold, or for an older block the one the quorum agrees on and that meets its own target). With no `network` nothing confirms. Since B-82 the pool-coin check before a payout is on the quorum too (`verify_pool_outpoints`, below). A quorum needs **at least 2 answers** and a strict majority of those that answered (B-84): fewer → returns **False**, since one voice decides nothing independently and an unreachable network proves nothing. Only `ALLOW_NO_QUORUM=true` keeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep.
On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep.
@@ -169,7 +172,7 @@ Diagrams: [come-funziona.md](docs/come-funziona.md) (the 5 phases), [ciclo-di-vi
**REG** — on signup the server derives a P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from the encrypted master xprv. Permanent, and doubles as deposit address, winnings address and withdrawal change address.
**DEP** — the listener subscribes to the user's scripthash; balance is credited after **1 confirmation** and only once the other servers corroborate the outpoint and its amount (B-59), with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
**DEP** — the listener subscribes to the user's scripthash; balance is credited once the output reaches its threshold (B-82, DEP-03: **1** if a transaction of the platform created it — any txid of a `pending_transactions` row —, **120** for a coinbase, recognised from the raw tx that must hash to the txid, `ElectrumListener.is_coinbase`, and `round_config.deposit_confirmations`, default 6, for everything else) **and** only once the other servers corroborate the outpoint, its amount and a height that gives it that many confirmations on our tip (B-59, ELC-17). The deposit is the one input a user can double-spend, hence the 6. Outputs short of their threshold are *pending deposits* (`ElectrumListener._pending_deposits`, memory only): shown in `pending_balance_sats` (amber), never spendable, and since no scripthash notification announces a maturing deposit each new block refreshes only the users that have one (`_refresh_waiting_deposits`). A deposit that vanishes later takes the spent-outside path; there is no other rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
**PLAY** — fixed cost, **at most one active bet per user**, and **at most `MAX_PARTICIPANTS_PER_ROUND` (400) players per round** — past that the bet is refused with `round_full` and the player waits for the next round (B-52: the payout must spend one pool UTXO per bet, so a round is only ever allowed to grow to what a single payout tx can drain). PSBT user-address → pool-address, always with a **change output back to the same user address** of at least `DUST_LIMIT_SATS` — a user's balance must never exactly equal the bet, and since B-62 that's enforced (`balance_leaves_no_change`) rather than assumed. Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
@@ -177,7 +180,7 @@ Diagrams: [come-funziona.md](docs/come-funziona.md) (the 5 phases), [ciclo-di-vi
- *Bet cutoff is the round's own deadline* (`round_deadline` = `opened_at + Round.duration_seconds`, the value snapshotted at open time — B-61), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a **compare-and-set on the round row** (`UPDATE rounds ... WHERE status = 'open'`, B-53): the scheduler flips `open` → `closing` in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only `confirmed` participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is `_close_and_draw` re-counting in-flight bets in the same session it snapshots the participants from.
- *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
- *Algorithm* (deliberately simple, meant to be replaced; **commit-reveal since B-73**, spec RND-14 … RND-20, RND-34 … RND-37): at open the round gets a 32-byte `server_seed` — stored only **Fernet-encrypted** with the master-xprv key (`server_seed_enc`; the plaintext column `server_seed` stays NULL until the reveal) — and publishes `server_seed_hash = sha256(seed)`. Entering `drawing` fixes, in one transaction with the status change, the confirmed participants **ordered by bet txid ascending** (public data, no ties — never `broadcast_at`), each one's `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n` lines; `bet_vout` is the output paying the round's pool address), and `draw_height = tip_at_close + 1`, and writes `draw_committed`. The draw waits until the chain has `draw_confirmations` blocks from `draw_height` (snapshotted at open from `round_config`, default 3), then asks **every** server, the active one included, for the header *at that exact height* (`ElectrumListener.agreed_block_hash`: valid work, at least 2 other answers, strict majority of all answers) — no agreement means the same height is asked again, never another block, so whoever mines or relays blocks cannot pick the one that seeds the draw (`draw_header_corroboration_failed`, once per distinct hash the active server reported). `seed = sha256(server_seed ‖ block_hash ‖ participants_hash)` as a big-endian integer, `index = seed mod n`, winner = participant with that `draw_position`; equal probability for everyone, regardless of amount. The reveal (`server_seed` column + `winner_drawn` payload) happens in the same transaction that stores the winner (or when a round closes with no participants), and `GET /rounds/{id}/proof` (public) returns everything needed to recompute it. A tip of 0 at closing means *unknown*: the round stays `closing` (`draw_baseline_tip_unknown`, once per round, B-63) because `draw_height` must come from a height we actually knew. `draw_stalled` fires when the chain has not advanced for 360s while waiting for confirmations, or the quorum keeps failing to agree. `_complete_draw` reads everything it needs from the database (`_close_and_draw` enters `drawing` and then calls it, and since B-81 `_tick` calls it again for any round found in `drawing` — after an exception or a restart — so it is idempotent: a round no longer in `drawing`, or already holding a winner, is left alone); a round opened before B-73 (no `server_seed_enc`) is given a secret as it enters `drawing`.
- *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/<round_id>`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **network fee comes out of the winner's 70%**; the platform's 30% pays the **bump reserve** (B-79, RND-38: always a third output, to the reserve address `1/0`, recorded in `rounds.bump_reserve_sats`) and the remainder goes to `fee_address`. The reserve is what makes the payout bumpable — it normally spends exactly the round's bet outputs and so has no change — and `bump_fee` shrinks it (`_find_reducible_output`: the reserve output for a payout, the user's change for a bet/withdrawal), never the winner's or the fee address's output. Same timeout → RBF → rebroadcast pattern.
- *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/<round_id>`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **network fee comes out of the winner's 70%**; the platform's 30% pays the **bump reserve** (B-79, RND-38: always a third output, to the reserve address `1/0`, recorded in `rounds.bump_reserve_sats`) and the remainder goes to `fee_address`. The reserve is what makes the payout bumpable — it normally spends exactly the round's bet outputs and so has no change — and `bump_fee` shrinks it (`_find_reducible_output`: the reserve output for a payout, the user's change for a bet/withdrawal), never the winner's or the fee address's output. Same timeout → RBF → rebroadcast pattern. **Verified before it is built** (B-82, RND-22, RND-39, `_trigger_payout` → `_verify_round_pool` → `ElectrumListener.verify_pool_outpoints`): a round with its own pool address spends exactly its confirmed participants' bet outputs (`bet_txid:bet_vout`), never whatever else sits at the address, and first asks the *other* servers whether each is still there — a strict majority of at least 2 answers; a mempool-only coin or a tie concludes nothing (`payout_failed`, `pool verification unavailable`, retried). A participant whose bet is gone becomes `reorged_out`, `participant_reorged_out` is audit-logged, `pool_amount_sats` is rewritten (only ever downward) in the same transaction and the 70/30 split is computed on what is left. If the **winner's own** bet is gone nothing is built: `payout_blocked_at` is set, `payout_blocked` is logged and the lottery is paused with `paused_reason = 'payout_blocked'`; the winner's row stays `confirmed` so that after `POST /admin/resume` (which clears the mark) the verification really runs again. A round opened before B-80 (pool = the shared reserve address) keeps the old active-server `listunspent` choice.
- *UI, two independent layers.* A generic phase box ("Pagamento al vincitore in corso…") shows to **every** viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by `status`. **Additively**, a personalized "Hai vinto!/Non hai vinto" box appears only where `user_played` is true (computed via `get_optional_user`, since the endpoint is reachable logged-out) — nobody else has anything to reveal.
- *Reveal timing.* Delayed by at least `draw_animation_seconds`, anchored to the server's `closes_at` so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for `winner_user_id`. Once revealed it's persisted in `localStorage.plm_persisted_result`, surviving the move to `closed` — at which point `get_active_round` stops returning the round and `winner_user_id` disappears from `GET /rounds/current`. `GET /users/me/last-round-result` is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: `refreshRound`/`checkLastRoundResult` in `app/static/app.js`.
@@ -251,19 +254,19 @@ Explicit design choices, not derivable from any single file — respect them:
- Keys are generated and held **server-side**: this is **custodial**. The user controls nothing until they withdraw.
- The deposit address *is* the winnings address — there is no separate "winner address".
- **1 confirmation** for every tx kind. Don't introduce differing thresholds (3, 6, …) without an explicit decision.
- **1 confirmation** for every transaction the platform builds (bets, withdrawals, payouts, and the outputs they create). Deposits (`deposit_confirmations`, 6) and coinbase outputs (120) are the explicit exceptions of B-82 — don't add other thresholds without an explicit decision.
- The draw algorithm is a **replaceable component**, not the final design — don't architect around its current form.
- `GET /admin/users/{id}/privkey` exporting a raw WIF is **intentional**, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes `admin_privkey_accessed` — don't remove that logging.
- **Usernames are case-insensitive** (B-57): one namespace, enforced by a unique index on `lower(username)` (`app/db/models.py`) and matched with `func.lower(...)` on both register and login. The name is still *stored* as typed — that's what `/admin` and the audit log show. The migration refuses to run if two existing accounts differ only by case, rather than guessing which one to rename: both may hold funds.
- Argon2 hashing means **no password recovery, only reset**: `POST /admin/users/{id}/reset-password` sets a new random password, returns it once for the operator to relay, and logs `admin_password_reset`. No self-service reset exists (no email is ever collected); a logged-in user can only *change* their password by supplying the current one.
- RBF bumps are paid by whoever's change the tx pays back to — the user for bets/withdrawals, the pool for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only the sender's own change shrinks (`bump_fee`).
- RBF bumps are paid by whoever's output the tx reserves for it — the user's change for bets/withdrawals, the bump reserve (paid to the reserve address) for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only that one output shrinks, or is dropped into the fee when it would fall under dust (`bump_fee`, B-87).
## Known gaps / TODO
Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here.
- **The `drawing` wait is unbounded by design** (the draw's entropy genuinely depends on a future block). It resumes on its own after an exception or a restart (B-81), and is observable rather than silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36).
- **RBF handles one shape only**: a single reducible output — the sender's change for a bet/withdrawal, the payout's bump reserve — big enough to absorb the increase. No extra-input fallback, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why `build_signed_transaction` now guarantees a change output of at least `DUST_LIMIT_SATS` instead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused with `balance_leaves_no_change`. What's left is a bump whose *delta* exceeds an otherwise-fine change output, which still raises `RbfError`; that tx is eventually abandoned and its UTXOs released.
- **RBF handles one shape only**: a single reducible output — the sender's change for a bet/withdrawal, the payout's bump reserve (B-79). No extra-input fallback and no CPFP, and none would help the case that used to hurt (a transaction that already spends everything the sender has) — which is why `build_signed_transaction` guarantees a change output of at least `DUST_LIMIT_SATS` (B-62) and every payout carries the reserve. Since B-87 a bump that would leave that output under dust drops it and gives its whole value to the fee (the replacement is then the last possible one); a bump with no output, or one whose output cannot even pay BIP125's minimum increase, raises `RbfError`. Every such failure — and `max_fee_rate` and a replacement the network refuses — is written by `RbfBumper._record_failure` as one `rbf_bump_failed` audit row per (current txid, reason). What stays accepted: a transaction that has exhausted its change or reserve and still does not confirm waits, visibly, and is never abandoned while a server knows it.
- **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network).
- **No user-facing history of rounds or transactions.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above) and `GET /bug-reports/mine` one more (the reporter's own reports). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
- **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
+13 -3
View File
@@ -1,15 +1,25 @@
FROM python:3.12-slim
# B-91 (SEC-51, SEC-52): base image pinned by digest, dependencies installed from a
# hash-checked lock file, process running as a non-root user.
# To update: `docker buildx imagetools inspect python:3.12-slim` for the digest, and
# `pip-compile --generate-hashes --strip-extras -o requirements.lock pyproject.toml` for the lock.
FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
WORKDIR /app
COPY requirements.lock ./
RUN pip install --no-cache-dir --require-hashes --no-deps -r requirements.lock
COPY pyproject.toml ./
COPY app ./app
COPY migrations ./migrations
COPY alembic.ini ./
COPY scripts ./scripts
RUN pip install --no-cache-dir .
RUN pip install --no-cache-dir --no-deps . \
&& useradd --uid 1000 --no-create-home --shell /usr/sbin/nologin app
USER 1000:1000
EXPOSE 8123
CMD ["/bin/sh", "-c", "test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123"]
# umask 077: the database, its WAL files and the logs are created readable by the app user only.
CMD ["/bin/sh", "-c", "umask 077; test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123 --proxy-headers --forwarded-allow-ips \"${FORWARDED_ALLOW_IPS:-127.0.0.1}\""]
+1 -1
View File
@@ -62,7 +62,7 @@ python -m pytest # all tests
python -m pytest tests/unit/test_hd.py # one file
```
637 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
758 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
the pending-inclusive balance calculation, and the SSE push channel. No
automated integration tests against a live Electrum connection — mainnet
+127 -22
View File
@@ -1,9 +1,10 @@
import json
import secrets
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from fastapi import APIRouter, Depends, Header, Query, Request, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error
@@ -14,8 +15,8 @@ from app.config import settings
from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.rounds.pause import AUTOMATIC_REASONS, OPERATOR_REASON
from app.rounds.service import payout_config_problem
from app.rounds.pause import pause_by_operator
from app.rounds.service import get_active_round, payout_config_problem
from app.wallet.address import is_valid_plm_address
from app.wallet.hd import derive_user_wif
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB
@@ -28,11 +29,11 @@ async def require_admin(x_admin_token: str = Header(default="")) -> None:
# on two empty strings returns True and would otherwise open the panel to
# anyone on an instance that never configured a token.
if not settings.admin_token:
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
raise http_error(status.HTTP_403_FORBIDDEN, "invalid_admin_token", "invalid admin token")
# compare_digest raises TypeError on a str containing non-ASCII characters
# (B-46) -- comparing the UTF-8 bytes instead accepts any input safely.
if not secrets.compare_digest(x_admin_token.encode(), settings.admin_token.encode()):
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
raise http_error(status.HTTP_403_FORBIDDEN, "invalid_admin_token", "invalid admin token")
# `paused` is deliberately NOT here: it has its own audit-logged endpoints
@@ -47,7 +48,9 @@ _CONFIG_FIELDS = (
"fee_rate_sat_vb",
"rbf_timeout_seconds",
"draw_animation_seconds",
"deposit_confirmations", # B-82 (DAT-62)
"draw_confirmations", # B-73 (DAT-62)
"max_round_pool_sats", # B-95 (DAT-62)
)
@@ -59,7 +62,9 @@ class RoundConfigResponse(BaseModel):
fee_rate_sat_vb: int
rbf_timeout_seconds: int
draw_animation_seconds: int
deposit_confirmations: int # B-82 (DAT-62)
draw_confirmations: int # B-73 (DAT-62)
max_round_pool_sats: int # B-95 (DAT-62)
paused: bool
paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused
@@ -81,7 +86,9 @@ class RoundConfigUpdate(BaseModel):
fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB)
rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600)
draw_animation_seconds: int | None = Field(default=None, ge=0, le=600)
deposit_confirmations: int | None = Field(default=None, ge=1, le=100) # B-82 (DAT-62)
draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62)
max_round_pool_sats: int | None = Field(default=None, ge=0, le=4_000_000_000_000_000) # B-95 (DAT-62)
@field_validator("fee_address")
@classmethod
@@ -134,7 +141,16 @@ async def update_config(
# refused before anything is written — the payout reads fee_rate_sat_vb at build time, so
# a rate raised mid-round could otherwise make the round in progress unpayable.
if "bet_amount_sats" in changes or "fee_rate_sat_vb" in changes:
problem = payout_config_problem(config.bet_amount_sats, config.fee_rate_sat_vb)
amounts = {config.bet_amount_sats}
if "fee_rate_sat_vb" in changes:
# B-95: the round in progress is paid with the bet amount it opened with, which may
# differ from the one configured now — a higher fee rate has to leave *it* payable too.
active = await get_active_round(session)
if active is not None:
amounts.add(active.bet_amount_sats)
problem = next(
(p for p in (payout_config_problem(a, config.fee_rate_sat_vb) for a in sorted(amounts)) if p), None
)
if problem is not None:
await session.rollback()
raise http_error(
@@ -145,6 +161,21 @@ async def update_config(
reason=problem,
)
# B-95 (DAT-62): a cap below the price of one bet would refuse every bet of every round.
if (
("max_round_pool_sats" in changes or "bet_amount_sats" in changes)
and config.max_round_pool_sats
and config.max_round_pool_sats < config.bet_amount_sats
):
offending = {"max_round_pool_sats": config.max_round_pool_sats, "bet_amount_sats": config.bet_amount_sats}
await session.rollback()
raise http_error(
422,
"max_round_pool_below_bet_amount",
"max_round_pool_sats must be 0 (no cap) or at least the bet amount",
**offending,
)
if changes:
await write_audit_log(session, "config_updated", changes)
await session.commit()
@@ -166,14 +197,7 @@ async def pause_lottery(
B-78 (DAT-69, API-32): the reason is "operator", except that an automatic reason
already in force stays — it is the one that needs a look. The audit row always
says `operator` for this call."""
config = await get_round_config(session)
if not (config.paused and config.paused_reason in AUTOMATIC_REASONS):
config.paused_reason = OPERATOR_REASON
config.paused = True
payload = {"reason": OPERATOR_REASON}
if body is not None and body.note:
payload["note"] = body.note
await write_audit_log(session, "lottery_paused", payload)
config = await pause_by_operator(session, body.note if body is not None else None)
await session.commit()
return _config_response(config)
@@ -186,6 +210,9 @@ async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundC
lifted = config.paused_reason if config.paused else None
config.paused = False
config.paused_reason = None
# B-82 (DAT-68, API-32): a payout held because the winner's bet left the chain is released
# for a new attempt, which verifies the coins again, in the same transaction as the resume.
await session.execute(update(Round).where(Round.payout_blocked_at.is_not(None)).values(payout_blocked_at=None))
await write_audit_log(session, "lottery_resumed", {"reason": lifted})
await session.commit()
return _config_response(config)
@@ -200,8 +227,17 @@ class AdminUserResponse(BaseModel):
@router.get("/users", response_model=list[AdminUserResponse], dependencies=[Depends(require_admin)])
async def list_users(session: AsyncSession = Depends(get_session)) -> list[AdminUserResponse]:
users = (await session.scalars(select(User).order_by(User.id))).all()
async def list_users(
limit: int = Query(default=100, ge=1, le=500),
after_id: int | None = Query(default=None, ge=0),
session: AsyncSession = Depends(get_session),
) -> list[AdminUserResponse]:
"""B-94 (API-33): by ascending id, a page at a time; the next page asks for `after_id` equal
to the last id received, and a list shorter than `limit` is the last one."""
query = select(User).order_by(User.id).limit(limit)
if after_id is not None:
query = query.where(User.id > after_id)
users = (await session.scalars(query)).all()
return [
AdminUserResponse(
id=u.id,
@@ -228,7 +264,7 @@ async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session
the most sensitive data the platform holds."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
raise http_error(status.HTTP_404_NOT_FOUND, "user_not_found", "user not found")
wif = derive_user_wif(user.derivation_index)
await write_audit_log(session, "admin_privkey_accessed", {"user_id": user_id}, user_id=user_id)
@@ -256,7 +292,7 @@ async def reset_user_password(
reset; only an admin (via /admin, token-gated) can trigger this."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
raise http_error(status.HTTP_404_NOT_FOUND, "user_not_found", "user not found")
new_password = secrets.token_urlsafe(12)
user.password_hash = await hash_password_async(new_password)
@@ -287,6 +323,7 @@ class AdminRoundResponse(BaseModel):
server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret
draw_height: int | None # B-73
bump_reserve_sats: int | None # B-79 (API-36)
payout_blocked_at: str | None # B-82 (API-36, DAT-68)
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
@@ -318,6 +355,7 @@ async def list_rounds(
server_seed_hash=r.server_seed_hash,
draw_height=r.draw_height,
bump_reserve_sats=r.bump_reserve_sats,
payout_blocked_at=isoformat_utc(r.payout_blocked_at),
)
for r in rounds
]
@@ -332,13 +370,54 @@ class AdminAuditLogResponse(BaseModel):
created_at: str
def _parse_instant(name: str, value: str | None) -> datetime | None:
"""An ISO 8601 instant with an offset, as UTC; anything else is a 422 (API-37)."""
if value is None:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
parsed = None
if parsed is None or parsed.tzinfo is None:
raise http_error(
status.HTTP_422_UNPROCESSABLE_CONTENT,
"invalid_request",
"invalid request",
errors=[{"loc": ["query", name], "msg": "an ISO 8601 instant with an offset is required", "type": "value_error"}],
)
return parsed.astimezone(timezone.utc)
@router.get(
"/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)]
)
async def list_audit_log(
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
session: AsyncSession = Depends(get_session),
limit: int = Query(default=200, ge=1, le=500),
before_id: int | None = Query(default=None),
event_type: list[str] | None = Query(default=None),
round_id: int | None = Query(default=None),
user_id: int | None = Query(default=None),
from_: str | None = Query(default=None, alias="from"),
to: str | None = Query(default=None),
) -> list[AdminAuditLogResponse]:
entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all()
"""B-92 (API-37): newest first, a page at a time, every filter optional and combined with
"and". The next page asks for `before_id` equal to the last id received."""
query = select(AuditLog).order_by(AuditLog.id.desc()).limit(limit)
if before_id is not None:
query = query.where(AuditLog.id < before_id)
if event_type:
query = query.where(AuditLog.event_type.in_(event_type))
if round_id is not None:
query = query.where(AuditLog.round_id == round_id)
if user_id is not None:
query = query.where(AuditLog.user_id == user_id)
start, end = _parse_instant("from", from_), _parse_instant("to", to)
if start is not None:
query = query.where(AuditLog.created_at >= start)
if end is not None:
query = query.where(AuditLog.created_at < end)
entries = (await session.scalars(query)).all()
return [
AdminAuditLogResponse(
id=e.id,
@@ -456,7 +535,7 @@ async def update_bug_report_status(
) -> AdminBugReportResponse:
report = await session.get(BugReport, report_id)
if report is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found")
raise http_error(status.HTTP_404_NOT_FOUND, "bug_report_not_found", "bug report not found")
# B-60: every other admin mutation (config edit, pause/resume, privkey export,
# password reset) leaves a trace; this one silently marked a report `resolved`.
@@ -480,3 +559,29 @@ async def update_bug_report_status(
username = user.username if user is not None else None
return _bug_report_response(report, username)
class AdminElectrumResponse(BaseModel):
connected: bool
endpoint: str | None
tip_height: int
last_header_age_seconds: int | None
fallback_count: int
quorum_enforced: bool
last_rotation: dict | None
@router.get("/electrum", response_model=AdminElectrumResponse, dependencies=[Depends(require_admin)])
async def electrum_status(request: Request) -> AdminElectrumResponse:
"""B-92 (API-47, ELC-29): the connection to the network, for the panel's indicator."""
listener = request.app.state.electrum_listener
connected = listener.client is not None
return AdminElectrumResponse(
connected=connected,
endpoint=str(listener.current_endpoint) if connected and listener.current_endpoint else None,
tip_height=listener.tip_height,
last_header_age_seconds=listener.last_header_age_seconds(),
fallback_count=listener.fallback_count,
quorum_enforced=listener.quorum_enforced,
last_rotation=listener.last_rotation,
)
+10 -7
View File
@@ -26,16 +26,19 @@ async def create_bet(
session: AsyncSession = Depends(get_session),
) -> BetResponse:
listener = request.app.state.electrum_listener
if listener.client is None:
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"network_unavailable",
"not connected to the network, try again shortly",
)
async with request.app.state.user_locks.acquire(user.id):
# B-96 (BET-02): read after the lock is taken — a wait on it across a reconnection
# must not hand place_bet the connection that closed while this request queued.
client = listener.client
if client is None:
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"network_unavailable",
"not connected to the network, try again shortly",
)
try:
participant = await place_bet(session, listener.client, user, network=listener)
participant = await place_bet(session, client, user, network=listener)
except BetError as exc:
# A rejected broadcast isn't the client's fault — it's the network
# refusing our transaction, so it answers 502 rather than 400 (B-07).
+12 -1
View File
@@ -1,10 +1,12 @@
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.client_ip import client_ip
from app.api.timeutil import isoformat_utc
from app.auth.dependencies import get_current_user, get_optional_user
from app.auth.routes import _rate_limited_error, _rate_limiters
from app.db.models import BugReport, User
from app.db.session import get_session
@@ -39,9 +41,17 @@ class BugReportResponse(BaseModel):
@router.post("", response_model=BugReportResponse, status_code=status.HTTP_201_CREATED)
async def create_bug_report(
body: BugReportCreate,
request: Request,
user: User | None = Depends(get_optional_user),
session: AsyncSession = Depends(get_session),
) -> BugReportResponse:
# B-94 (SEC-55): a quota per IP, checked before the insert and consumed only by a report
# that was stored (validation failures never get here).
quota = _rate_limiters(request).bug_reports
ip_key = f"ip:{client_ip(request)}"
retry_after = quota.retry_after(ip_key)
if retry_after > 0:
raise _rate_limited_error(retry_after)
report = BugReport(
description=body.description,
contact=body.contact,
@@ -49,6 +59,7 @@ async def create_bug_report(
)
session.add(report)
await session.commit()
quota.record(ip_key)
return BugReportResponse(id=report.id)
+6 -3
View File
@@ -3,7 +3,7 @@ import json
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -57,7 +57,7 @@ async def round_stream(request: Request) -> Response:
try:
queue = broadcaster.subscribe(client_ip(request))
except RoundEventCapacityError:
return JSONResponse(status_code=503, content={"detail": "too many concurrent update streams"})
raise http_error(503, "too_many_streams", "too many concurrent update streams")
async def event_generator():
ticks_since_keepalive = 0
@@ -119,6 +119,8 @@ class CurrentRoundResponse(BaseModel):
# waiting for a block" rather than a countdown implying a bounded wait — this
# phase has no timeout, only draw_animation_seconds' cosmetic minimum.
draw_waiting_since: str | None = None
# B-96 (TX-29): since when the round has waited in "closing" for in-flight bets; only then.
closing_waiting_since: str | None = None
# B-73 (RND-34, RND-14): the commitment published from the opening, and what is fixed
# on entering "drawing". The secret itself is never in this response (API-45 has it, once
# revealed).
@@ -222,7 +224,7 @@ async def current_round(
opened_at=opened_at.isoformat(),
closes_at=closes_at.isoformat(),
participant_count=participant_count,
bet_amount_sats=config.bet_amount_sats,
bet_amount_sats=round_.bet_amount_sats, # B-95 (API-21): the round's own, fixed at open
jackpot_sats=jackpot_sats,
pending_participant_count=all_count,
pending_jackpot_sats=winner_share(all_pool_sats),
@@ -233,6 +235,7 @@ async def current_round(
draw_block_height=round_.draw_block_height,
draw_block_hash=round_.draw_block_hash,
draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None,
closing_waiting_since=isoformat_utc(round_.closed_at) if round_.status == "closing" and round_.closed_at else None,
server_seed_hash=round_.server_seed_hash,
participants_hash=round_.participants_hash,
draw_height=round_.draw_height,
+23 -5
View File
@@ -1,11 +1,13 @@
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error
from app.audit.log import write_audit_log
from app.api.timeutil import isoformat_utc
from app.auth.dependencies import get_current_user
from app.auth.routes import _rate_limited_error, _rate_limiters
from app.auth.security import (
MIN_PASSWORD_LENGTH,
create_access_token,
@@ -31,10 +33,15 @@ class MeResponse(BaseModel):
@router.get("/me", response_model=MeResponse)
async def me(
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> MeResponse:
pending_balance_sats, has_pending = await compute_pending_balance(session, user)
# B-82 (DEP-12 (b)): deposits confirmed but short of their threshold, kept in memory by
# the listener (absent in an app without one, e.g. a test of this route alone).
listener = getattr(request.app.state, "electrum_listener", None)
waiting_sats = listener.pending_deposit_sats(user.id) if listener is not None else 0
pending_balance_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
return MeResponse(
id=user.id,
username=user.username,
@@ -47,8 +54,8 @@ async def me(
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
current_password: str = Field(max_length=256) # B-94 (REG-12)
new_password: str = Field(max_length=256)
class ChangePasswordResponse(BaseModel):
@@ -58,13 +65,22 @@ class ChangePasswordResponse(BaseModel):
@router.post("/me/change-password", response_model=ChangePasswordResponse)
async def change_password(
body: ChangePasswordRequest,
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> ChangePasswordResponse:
"""Self-service password change — requires the current password, unlike the
admin-only /admin/users/{id}/reset-password (which is for a user who's
actually locked out and can't provide it)."""
# B-94 (SEC-54): a stolen token must not be able to guess the current password, change it
# and lock the owner out. Checked before the Argon2 hash and even for the right password.
limiter = _rate_limiters(request).change_password
key = f"user:{user.id}"
retry_after = limiter.retry_after(key)
if retry_after > 0:
raise _rate_limited_error(retry_after)
if not await verify_password_async(body.current_password, user.password_hash):
limiter.record_failure(key)
raise http_error(
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
)
@@ -84,7 +100,9 @@ async def change_password(
# every other open session (this user's other devices, or an attacker's)
# gets "session_expired" on its next request.
user.token_version += 1
await write_audit_log(session, "password_changed", {"user_id": user.id}, user_id=user.id)
await session.commit()
limiter.record_success(key)
return ChangePasswordResponse(access_token=create_access_token(user.id, user.token_version))
+9 -7
View File
@@ -32,17 +32,19 @@ async def create_withdrawal(
session: AsyncSession = Depends(get_session),
) -> WithdrawalResponse:
listener = request.app.state.electrum_listener
if listener.client is None:
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"network_unavailable",
"not connected to the network, try again shortly",
)
async with request.app.state.user_locks.acquire(user.id):
# B-96 (WDR-01): read after the lock is taken, as in create_bet.
client = listener.client
if client is None:
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"network_unavailable",
"not connected to the network, try again shortly",
)
try:
withdrawal = await request_withdrawal(
session, listener.client, user, body.external_address, body.amount_sats, network=listener
session, client, user, body.external_address, body.amount_sats, network=listener
)
except WithdrawalError as exc:
code = (
+7
View File
@@ -186,6 +186,8 @@ class RollingQuota:
_REGISTRATIONS_PER_IP = 5
_REGISTRATION_WINDOW_SECONDS = 3600.0
_BUG_REPORTS_PER_IP = 10
_BUG_REPORT_WINDOW_SECONDS = 3600.0
class AuthRateLimiters:
@@ -208,3 +210,8 @@ class AuthRateLimiters:
self.register_ip = RollingQuota(
limit=_REGISTRATIONS_PER_IP, window_seconds=_REGISTRATION_WINDOW_SECONDS
)
# B-94 (SEC-54): wrong `current_password` on a password change, per user — a stolen
# token must not be able to guess the password and lock the owner out.
self.change_password = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0)
# B-94 (SEC-55): bug reports are anonymous; a quota per IP keeps them from filling the DB.
self.bug_reports = RollingQuota(limit=_BUG_REPORTS_PER_IP, window_seconds=_BUG_REPORT_WINDOW_SECONDS)
+22 -2
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.client_ip import client_ip as _client_ip
from app.api.errors import http_error
from app.audit.log import write_audit_log
from app.auth.rate_limit import AuthRateLimiters
from app.auth.security import (
MIN_PASSWORD_LENGTH,
@@ -70,6 +71,14 @@ class TokenResponse(BaseModel):
async def register(
body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session)
) -> TokenResponse:
# B-90 (REG-15): after a restore a new user could be handed the derivation index — the
# address, with the funds — of someone who registered after the backup.
if not getattr(request.app.state.electrum_listener, "registration_open", True):
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"registration_unavailable",
"registration is temporarily unavailable",
)
limiters = _rate_limiters(request)
ip_key = f"ip:{_client_ip(request)}"
# B-58: checked before the Argon2 hash below, so an IP that's out of quota
@@ -113,6 +122,15 @@ async def register(
)
session.add(user)
try:
# B-92 (AUD-41, REG-16): in the same transaction as the user, so after a restore the log
# says which derivation indexes were already assigned.
await session.flush()
await write_audit_log(
session,
"user_registered",
{"derivation_index": next_index, "address": address},
user_id=user.id,
)
await session.commit()
except IntegrityError as exc:
await session.rollback()
@@ -140,8 +158,10 @@ async def register(
class LoginRequest(BaseModel):
username: str
password: str
# B-94 (API-19): bounded before anything is hashed or counted; a megabyte password would
# otherwise be an Argon2 input and a rate-limit key.
username: str = Field(max_length=256)
password: str = Field(max_length=256)
@router.post("/login", response_model=TokenResponse)
+35 -3
View File
@@ -16,7 +16,7 @@ from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
from app.tx.versions import delete_versions, record_version
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.wallet.balance import recompute_balance
from app.wallet.balance import compute_pending_balance, recompute_balance
from app.wallet.hd import derive_user_key
from app.wallet.psbt_builder import (
MAX_PARTICIPANTS_PER_ROUND,
@@ -93,19 +93,51 @@ async def place_bet(
max_participants=MAX_PARTICIPANTS_PER_ROUND,
)
# B-95 (BET-20, DAT-27): the round's own cap on the pool, when it has one. The sum counts
# every participant row (a bet still in flight may yet confirm — the conservative side), and
# a bet is refused once the sum has *reached* the cap, since its own net amount is not known
# before it is built: the pool can therefore end up above the cap by at most one bet.
if round_.max_round_pool_sats:
pool_so_far = await session.scalar(
select(func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0)).where(
RoundParticipant.round_id == round_.id
)
)
if pool_so_far >= round_.max_round_pool_sats:
raise BetError(
"round_full",
"this round has reached its limit, wait for the next one",
max_participants=MAX_PARTICIPANTS_PER_ROUND,
max_round_pool_sats=round_.max_round_pool_sats,
)
if not round_.pool_address:
# B-80: open_new_round_if_needed always writes it; a row without one cannot
# take money that would then have nowhere verifiable to go.
raise RuntimeError(f"round {round_.id} has no pool address")
bet_amount = config.bet_amount_sats
# B-95 (BET-21): the stake of the round — what every player of it pays — not the value in
# round_config now, which an operator may have edited since the round opened.
bet_amount = round_.bet_amount_sats
unspent = (
await session.scalars(
select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None))
)
).all()
if sum(u.amount_sats for u in unspent) < bet_amount:
confirmed_sats = sum(u.amount_sats for u in unspent)
if confirmed_sats < bet_amount:
# B-96 (BET-07, DEP-13): the balance on screen includes unconfirmed change and deposits
# short of their threshold; a bet that balance would cover gets "wait for the
# confirmation", as a withdrawal does, instead of a flat "insufficient".
waiting_sats = network.pending_deposit_sats(user.id) if hasattr(network, "pending_deposit_sats") else 0
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
if has_pending and pending_inclusive_sats >= bet_amount:
raise BetError(
"balance_pending_confirmation",
"the bet cost is covered by your pending balance, which has not confirmed yet",
pending_sats=pending_inclusive_sats - confirmed_sats,
)
raise BetError("insufficient_balance", "insufficient balance", required_sats=bet_amount)
user_key = derive_user_key(user.derivation_index)
+21 -1
View File
@@ -19,6 +19,14 @@ logger = logging.getLogger(__name__)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
def model_post_init(self, __context) -> None:
if self.xprv_encryption_key_file and not self.xprv_encryption_key:
try:
with open(self.xprv_encryption_key_file) as f:
self.xprv_encryption_key = f.read().strip()
except OSError:
pass # validate_runtime_secrets reports it, naming the file
database_url: str = "sqlite+aiosqlite:///./plm_lottery.db"
electrum_host: str = "santantonio.sytes.net"
@@ -41,7 +49,14 @@ class Settings(BaseSettings):
# trust the quorum exists to remove.
allow_no_quorum: bool = False
# B-90 (OPS-27): start with the lottery paused, as after restoring a backup.
start_paused: bool = False
xprv_encryption_key: str = ""
# B-91 (SEC-53): the key as a file (a compose secret in the container), so it is in no
# environment variable and `docker inspect` cannot show it. When set it is read into
# `xprv_encryption_key` at load, which is what the rest of the code uses.
xprv_encryption_key_file: str = ""
master_key_path: str = "./master.xprv.enc"
jwt_secret: str = ""
jwt_algorithm: str = "HS256"
@@ -92,8 +107,13 @@ def validate_runtime_secrets(config: Settings | None = None) -> None:
'(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")'
)
if not config.xprv_encryption_key.strip():
where = (
f"XPRV_ENCRYPTION_KEY_FILE ({config.xprv_encryption_key_file}) is missing or empty"
if config.xprv_encryption_key_file
else "XPRV_ENCRYPTION_KEY_FILE must point to a file holding the key"
)
problems.append(
"XPRV_ENCRYPTION_KEY must be set (generate: python -c "
where + " (generate: python -c "
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
)
if problems:
+17
View File
@@ -114,6 +114,9 @@ class Round(Base):
# the reserve address and is what an RBF bump shrinks, so this is never rewritten by a bump).
bump_reserve_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
# B-82 (DAT-68): set when the verification before the payout finds the *winner's* bet gone
# from the chain; while it is set no payout is built. Cleared by POST /admin/resume.
payout_blocked_at: Mapped[datetime | None] = mapped_column(default=None)
# B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's
# 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with
# `server_seed_hash` (sha256 of the secret, published from then on); the plaintext
@@ -125,6 +128,14 @@ class Round(Base):
# blocks the chain must have from `draw_height` before its hash is read, copied from the
# config at open.
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
# B-95 (DAT-61, RND-05): the rest of what governs this round, copied from round_config in the
# transaction that opens it and never changed — the cost of a bet, where the commission goes,
# and the cap on the pool (0 = none). Read live, a mid-round edit gave players of the same
# round different stakes and redirected a round's commission. The fee *rate* stays live: it
# belongs to a transaction, not to the rules of the round.
bet_amount_sats: Mapped[int] = mapped_column(BigInteger, default=1_000_000_000, server_default="1000000000")
fee_address: Mapped[str] = mapped_column(String(128), default="", server_default="")
max_round_pool_sats: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0")
server_seed_enc: Mapped[str | None] = mapped_column(Text, default=None)
server_seed: Mapped[str | None] = mapped_column(String(64), default=None)
server_seed_hash: Mapped[str | None] = mapped_column(String(64), default=None)
@@ -184,6 +195,12 @@ class RoundConfig(Base):
# of that block is read; copied onto each round when it opens (DAT-61), so an edit
# applies from the next round.
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
# B-82 (DAT-62, DEP-03): confirmations after which an output paying a user is credited
# as a deposit (platform transactions keep 1, coinbase outputs 120).
deposit_confirmations: Mapped[int] = mapped_column(default=6, server_default="6")
# B-95 (DAT-27, DAT-62): cap on the sum of a round's bets, 0 = no cap; copied onto each round
# when it opens. Past it a bet is refused with `round_full`.
max_round_pool_sats: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0")
fee_rate_sat_vb: Mapped[int] = mapped_column(default=1)
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
# Maintenance switch: when true, the round currently in progress still runs to
+85 -1
View File
@@ -1,17 +1,33 @@
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import UtxoEvent
from app.db.models import PendingTransaction, PendingTransactionTxid, RoundConfig, UtxoEvent
from app.rounds.events import broadcaster
from app.wallet.balance import recompute_balance
from app.wallet.psbt_builder import DUST_LIMIT_SATS
logger = logging.getLogger(__name__)
# B-82 (DEP-03): confirmations a coinbase output needs before it can be spent, PLM's
# COINBASE_MATURITY (palladiumcore/src/consensus/consensus.h). A coinbase paying a user's
# address is credited no earlier than this, whatever deposit_confirmations says.
COINBASE_MATURITY = 120
# Confirmations an output created by a transaction of the platform itself — the change of
# a bet or a withdrawal, a payout — needs: the platform built it, nobody else could have
# double-spent its inputs (DEP-03).
PLATFORM_TRANSACTION_CONFIRMATIONS = 1
# Default of round_config.deposit_confirmations, used only when the config row does not
# exist yet (get_round_config creates it with the same value).
DEFAULT_DEPOSIT_CONFIRMATIONS = 6
async def find_new_credit_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
"""The subset of `entries` that would actually credit something: confirmed
@@ -41,6 +57,74 @@ async def find_new_credit_candidates(session: AsyncSession, user_id: int, entrie
]
@dataclass
class DepositPolicy:
"""What decides how many confirmations an incoming output needs (DEP-03), read from the
database before any network call: the configured deposit threshold and which of the
transactions involved are the platform's own."""
deposit_confirmations: int = DEFAULT_DEPOSIT_CONFIRMATIONS
platform_txids: set[str] = field(default_factory=set)
async def read_deposit_policy(session: AsyncSession, txids: set[str]) -> DepositPolicy:
"""The deposit threshold and, among `txids`, the ones created by a platform transaction:
any txid of a `pending_transactions` row, replaced RBF versions included (B-77)."""
configured = await session.scalar(select(RoundConfig.deposit_confirmations))
platform: set[str] = set()
if txids:
platform |= set(
(await session.scalars(select(PendingTransactionTxid.txid).where(PendingTransactionTxid.txid.in_(txids))))
)
platform |= set(
(await session.scalars(select(PendingTransaction.current_txid).where(PendingTransaction.current_txid.in_(txids))))
)
return DepositPolicy(
deposit_confirmations=configured if configured else DEFAULT_DEPOSIT_CONFIRMATIONS, platform_txids=platform
)
async def classify_new_deposits(
new_entries: list[dict],
tip_height: int,
policy: DepositPolicy,
is_coinbase: Callable[[str], Awaitable[bool | None]],
) -> tuple[list[tuple[dict, int]], list[dict]]:
"""Split the new, confirmed, non-dust entries of `find_new_credit_candidates` into the
ones that may now be credited — as `(entry, required_confirmations)`, the number the
quorum is then asked to confirm (ELC-17) — and the ones still **waiting** for
confirmations (B-82, DEP-03, DEP-14).
Confirmations are `tip_height - height + 1`. The threshold is 1 for an output of a
platform transaction, 120 (`COINBASE_MATURITY`) for a coinbase output, and
`deposit_confirmations` for anything else; a coinbase is only looked for once the plain
threshold is met, since below it the output waits anyway. `is_coinbase` answers from the
raw transaction (hash-checked by the caller) and may return None when it cannot tell, in
which case the output waits and is asked again at the next refresh. No database session
is involved: this runs between the read and the write phases, where the network is used.
"""
candidates: list[tuple[dict, int]] = []
waiting: list[dict] = []
for entry in new_entries:
confirmations = tip_height - entry["height"] + 1
if entry["tx_hash"] in policy.platform_txids:
required = PLATFORM_TRANSACTION_CONFIRMATIONS
else:
required = policy.deposit_confirmations
if required <= confirmations < COINBASE_MATURITY:
# Plain threshold met, maturity not: only now is it worth knowing whether
# this is a coinbase output (which would still be immature).
coinbase = await is_coinbase(entry["tx_hash"])
if coinbase is None or coinbase:
waiting.append(entry)
continue
if confirmations < required:
waiting.append(entry)
continue
candidates.append((entry, required))
return candidates, waiting
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""Insert utxo_events for newly-confirmed entries from an Electrum
`listunspent` response (idempotent on txid+vout), refresh the user's cached
+16
View File
@@ -7,6 +7,8 @@ import re
import ssl
from typing import NamedTuple
from app.wallet.plm_network import GENESIS_HASH
logger = logging.getLogger(__name__)
@@ -192,6 +194,20 @@ class ElectrumClient:
raise
self._read_task = asyncio.create_task(self._read_loop())
await self.request("server.version", ["plm-lottery", "1.4"])
try:
await self._check_genesis()
except ElectrumError:
self._read_task.cancel()
self._writer.close()
raise
async def _check_genesis(self) -> None:
"""The server must be on PLM's chain, not just any chain with valid headers: PLM shares
Bitcoin's genesis and powLimit, so only `server.features` tells them apart (B-99, ELC-30)."""
features = await self.request("server.features")
genesis = features.get("genesis_hash") if isinstance(features, dict) else None
if genesis != GENESIS_HASH:
raise ElectrumError(f"{self.host}:{self.port} is not on the PLM chain (genesis {genesis!r})")
def _check_pin(self) -> None:
"""The server's DER certificate must hash to the pinned SHA-256 (B-84, ELC-03)."""
+265 -10
View File
@@ -1,22 +1,25 @@
import asyncio
import logging
import time
from datetime import datetime, timezone
from collections import Counter
from collections.abc import Callable
from embit.transaction import Transaction
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import User
from app.deposits.service import (
classify_new_deposits,
credit_confirmed_utxos,
find_new_credit_candidates,
find_reinstate_candidates,
find_utxos_missing_from,
mark_utxos_spent_externally,
read_deposit_policy,
reinstate_reappeared_utxos,
)
from app.config import MIN_QUORUM_RESPONSES
@@ -32,6 +35,7 @@ from app.rounds.draw import (
)
from app.rounds.events import broadcaster
from app.rounds.pause import pause_automatically
from app.wallet.hd import derive_user_address
logger = logging.getLogger(__name__)
@@ -68,10 +72,22 @@ _monotonic = time.monotonic
# ordinary on any chain, two or more is worth a look before a new round opens.
_REORG_PAUSE_DEPTH = 2
# B-90 (REG-15): how many derivation indexes past the highest assigned one are looked up for history
# before registration opens.
_RESTORE_GAP_WINDOW = 20
# `blockchain.block.headers` serves at most this many headers per request (ELC-12).
_MAX_HEADERS_PER_REQUEST = 2016
def _rotation_reason(exc: BaseException) -> str:
if isinstance(exc, ServerStalledError):
return "stalled_tip"
if isinstance(exc, HeaderValidationError):
return "invalid_header"
return "error"
class ServerStalledError(Exception):
"""The active server stopped following the chain while others report a higher tip
(B-86, ELC-21). Ends the session like a dropped connection, so run() rotates."""
@@ -93,6 +109,11 @@ class ElectrumListener:
servers down) still backs off instead of spinning.
"""
# B-90 (REG-15): False until the check of the derivation indexes past the highest assigned
# one has completed and found no address with history. A class attribute so a test that
# is not about a restore can open registration once for every listener.
registration_open = False
def __init__(
self,
client_factory: Callable[[ElectrumEndpoint], ElectrumClient],
@@ -106,6 +127,11 @@ class ElectrumListener:
# B-84 (ELC-19): ALLOW_NO_QUORUM, development only — see _corroborate_majority.
self._allow_no_quorum = allow_no_quorum
self._endpoint_index = 0
self._restore_checked = False
# B-92 (API-47): why and when the listener last moved on to another server.
self.last_rotation: dict | None = None
self._end_reason: str | None = None
self._refresh_locks: dict[int, asyncio.Lock] = {}
self._scripthash_to_user: dict[str, int] = {}
# Retains address_for_new_user's fire-and-forget subscribe task so it
# can't be garbage-collected mid-flight, and so its exception (if any) is
@@ -120,6 +146,27 @@ class ElectrumListener:
# B-86: when the last header was accepted (monotonic seconds), None until one is.
self._last_header_at: float | None = None
self.client: ElectrumClient | None = None
# B-82 (DEP-14): per user, the deposits seen at the last refresh that are confirmed
# but still short of their threshold — what the displayed balance shows as pending.
# Memory only: it moves no money and is rebuilt by the first refresh after a restart.
self._pending_deposits: dict[int, list[dict]] = {}
# txid -> is it a coinbase (DEP-03). A transaction never changes, so this is safe to
# keep; bounded so a long-lived process does not grow it without limit.
self._coinbase_cache: dict[str, bool] = {}
# Users with a refresh already running because of a new block (see _consume_headers).
self._block_refreshes: set[int] = set()
def last_header_age_seconds(self) -> int | None:
"""B-92 (ELC-29): seconds since a header was last accepted, None before the first."""
return None if self._last_header_at is None else int(_monotonic() - self._last_header_at)
@property
def fallback_count(self) -> int:
return max(len(self._endpoints) - 1, 0)
@property
def quorum_enforced(self) -> bool:
return not self._allow_no_quorum
@property
def current_endpoint(self) -> ElectrumEndpoint | None:
@@ -167,11 +214,13 @@ class ElectrumListener:
logger.error("no Electrum endpoints configured; listener idle")
return
connected = False
self._end_reason = None
try:
connected = await self._run_once(endpoint)
except asyncio.CancelledError:
raise
except Exception:
except Exception as exc:
self._end_reason = _rotation_reason(exc)
logger.exception("Electrum session on %s failed", endpoint)
finally:
self.client = None
@@ -180,6 +229,11 @@ class ElectrumListener:
# server that just dropped us has no claim on being tried first again.
if len(self._endpoints) > 1:
self._endpoint_index = (self._endpoint_index + 1) % len(self._endpoints)
self.last_rotation = {
"reason": self._end_reason or ("connection_closed" if connected else "unreachable"),
"from": str(endpoint),
"at": datetime.now(timezone.utc).isoformat(),
}
if connected:
# We did reach a server, so the network is up — don't let an earlier
@@ -256,21 +310,78 @@ class ElectrumListener:
# the session actually does end.
subscribe_task = asyncio.create_task(self._subscribe_all_users())
subscribe_task.add_done_callback(self._log_subscribe_all_users_failure)
restore_task = None
if not self._restore_checked:
restore_task = asyncio.create_task(self._check_restore_gap())
restore_task.add_done_callback(self._log_restore_check_failure)
try:
done, still_running = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally:
subscribe_task.cancel()
if restore_task is not None:
restore_task.cancel()
for task in tasks:
task.cancel()
for task in done:
exc = task.exception()
if exc is not None:
self._end_reason = _rotation_reason(exc)
logger.warning("Electrum session on %s ending: %r", endpoint, exc)
finally:
self.client = None
await client.close()
return True
def _log_restore_check_failure(self, task: asyncio.Task) -> None:
if not task.cancelled() and task.exception() is not None:
logger.warning("restore-gap check not completed, registration stays closed: %r", task.exception())
async def _check_restore_gap(self) -> None:
"""B-90 (REG-15): a database restored from a backup does not know the users who
registered after it, and would hand their derivation indexes — their addresses,
with whatever they received — to new users. So the `_RESTORE_GAP_WINDOW` indexes
past the highest assigned one are looked up with `get_history` on the active
server and on every other one; a single server reporting a transaction makes the
address used. Registration opens only when none is; otherwise `restore_gap_detected`
is written and it stays closed until a later start finds none (the operator
occupies those indexes, OPS-28). If the active server cannot answer the check is
simply tried again on the next session."""
async with self._session_factory() as session:
highest = (await session.execute(select(func.max(User.derivation_index)))).scalar_one_or_none()
first = 0 if highest is None else highest + 1
addresses = [(index, derive_user_address(index)) for index in range(first, first + _RESTORE_GAP_WINDOW)]
async def ask(client: ElectrumClient) -> list[bool]:
histories = await asyncio.gather(
*(client.request("blockchain.scripthash.get_history", [address_to_scripthash(a)]) for _, a in addresses)
)
return [bool(history) for history in histories]
active = await self._ask_active_server(ask)
if active is None:
raise RuntimeError("the active server did not answer the history lookups")
others = await self._ask_other_servers(ask)
answers = [active] + [answer for answer in others if answer is not None]
used = [
{"derivation_index": index, "address": address}
for position, (index, address) in enumerate(addresses)
if any(answer[position] for answer in answers)
]
self._restore_checked = True
if not used:
self.registration_open = True
return
logger.error(
"restore gap: %s derivation index(es) past the highest assigned one already have history "
"on chain — registration stays closed (OPS-28)",
len(used),
)
async with self._session_factory() as session:
await write_audit_log(
session, "restore_gap_detected", {"highest_assigned_index": highest, "used": used}
)
await session.commit()
def _log_subscribe_all_users_failure(self, task: asyncio.Task) -> None:
if task.cancelled():
return
@@ -819,7 +930,9 @@ class ElectrumListener:
return await self._corroborate_majority(_ask, lambda agrees: agrees, f"outpoint {txid}:{vout}")
async def corroborate_utxo_credit(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
async def corroborate_utxo_credit(
self, scripthash: str, txid: str, vout: int, value: int, required_confirmations: int | None = None
) -> bool:
"""B-59: the mirror of corroborate_utxo_spent, for money going the other
way. A candidate external spend could not reduce a balance without a
quorum, but `value` and `height` for a *credit* were taken from the single
@@ -841,8 +954,11 @@ class ElectrumListener:
only credited once the quorum agrees.
"""
# B-82 (ELC-17): the other server must also place the coin deep enough — at a height
# that gives it `required_confirmations` counted on *our* tip, not on its own.
max_height = None if required_confirmations is None else self.tip_height - required_confirmations + 1
return await self._corroborate_unspent_confirmed(
scripthash, txid, vout, value, f"credit of {value} sats at {txid}:{vout}"
scripthash, txid, vout, value, f"credit of {value} sats at {txid}:{vout}", max_height=max_height
)
async def corroborate_utxo_reinstatement(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
@@ -856,10 +972,11 @@ class ElectrumListener:
)
async def _corroborate_unspent_confirmed(
self, scripthash: str, txid: str, vout: int, value: int, description: str
self, scripthash: str, txid: str, vout: int, value: int, description: str, max_height: int | None = None
) -> bool:
"""Do the other servers list this outpoint as unspent, for the same amount, and
confirmed (height > 0)? Shared by credits (B-59) and reinstatements (B-83)."""
confirmed (height > 0, and no higher than `max_height` when one is given)? Shared by
credits (B-59, B-82) and reinstatements (B-83)."""
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
client = self._client_factory(endpoint)
@@ -873,6 +990,7 @@ class ElectrumListener:
and e.get("tx_pos") == vout
and e.get("value") == value
and (e.get("height") or 0) > 0
and (max_height is None or (e.get("height") or 0) <= max_height)
for e in entries
)
except Exception:
@@ -882,6 +1000,59 @@ class ElectrumListener:
return await self._corroborate_majority(_ask, lambda agrees: agrees, description)
async def verify_pool_outpoints(
self, scripthash: str, outpoints: list[tuple[str, int, int]]
) -> dict[tuple[str, int], bool] | None:
"""Which of a round's bet outputs (`(txid, vout, amount)`) are really there, at the
round's pool address, according to the *other* servers (B-82, ELC-27, RND-39).
Every other server lists the address' unspent coins; each answer says, per outpoint,
*present* (listed, same amount, confirmed), *absent* (not listed, or listed with
another amount) or neither (listed but unconfirmed — a block that left the chain
may well come back, which is no reason to throw a bet out). An outpoint is present
or absent when a strict majority of the answers say so. The result maps every
outpoint to True (present) or False (absent); it is None — nothing concluded — when
fewer than the quorum minimum (ELC-14) of servers answered or an outpoint has no
majority either way, so a participant is only ever excluded on the quorum's word and
the caller simply tries again later. With ALLOW_NO_QUORUM=true and no other server
configured the active server answers instead (development only)."""
async def _ask(client: ElectrumClient) -> dict[tuple[str, int], tuple[int, int]] | None:
entries = await client.listunspent(scripthash)
return {(e["tx_hash"], e["tx_pos"]): (e["value"], e.get("height") or 0) for e in entries}
answers = [a for a in await self._ask_other_servers(_ask) if a is not None]
if not self._other_endpoints() and self._allow_no_quorum:
answers = [a for a in [await self._ask_active_server(_ask)] if a is not None]
elif len(answers) < max(self._minimum_other_answers(), 1):
logger.warning(
"could not verify the pool coins at %s: %s answer(s) from the other servers, %s needed",
scripthash,
len(answers),
max(self._minimum_other_answers(), 1),
)
return None
if not answers:
return None
result: dict[tuple[str, int], bool] = {}
for txid, vout, amount in outpoints:
present = absent = 0
for answer in answers:
listed = answer.get((txid, vout))
if listed is None or listed[0] != amount:
absent += 1
elif listed[1] > 0:
present += 1
if present * 2 > len(answers):
result[(txid, vout)] = True
elif absent * 2 > len(answers):
result[(txid, vout)] = False
else:
logger.warning("the servers do not agree whether %s:%s is still on the chain", txid, vout)
return None
return result
# --- Confirmations proven by merkle branch (B-83, TX-22, ELC-26) ---------------------
async def _header_at(self, height: int) -> str | None:
@@ -942,15 +1113,89 @@ class ElectrumListener:
# (rounds/scheduler.py:_wait_for_next_block) — nudge dashboards to
# refetch instead of waiting for their next poll.
broadcaster.publish()
self._refresh_waiting_deposits()
def _refresh_waiting_deposits(self) -> None:
"""A deposit that is waiting for confirmations (DEP-14) does not change its address'
status when the next block arrives — the history is the same, only the tip moved — so
no scripthash notification will ever announce that it has matured. Refresh the users
that have one at each new block, one task per user at a time, instead of leaving it to
the 300s sweep (B-82, DEP-09)."""
if self.client is None:
return
scripthash_of = {user_id: scripthash for scripthash, user_id in self._scripthash_to_user.items()}
for user_id, waiting in list(self._pending_deposits.items()):
scripthash = scripthash_of.get(user_id)
if not waiting or scripthash is None or user_id in self._block_refreshes:
continue
self._block_refreshes.add(user_id)
task = asyncio.create_task(self._refresh_after_block(user_id, scripthash))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
async def _refresh_after_block(self, user_id: int, scripthash: str) -> None:
try:
await self.refresh_user(user_id, scripthash)
except Exception:
logger.exception("refreshing user_id=%s after a new block failed", user_id)
finally:
self._block_refreshes.discard(user_id)
def pending_deposit_sats(self, user_id: int) -> int:
"""Total of the user's deposits that are confirmed but short of their threshold
(DEP-12 (b), DEP-14); 0 when there are none or nothing has been refreshed yet."""
return sum(entry["value"] for entry in self._pending_deposits.get(user_id, []))
async def is_coinbase(self, txid: str) -> bool | None:
"""Is `txid` a coinbase transaction (DEP-03)? Read from the raw transaction, which is
accepted only if it hashes to `txid` — so the server cannot misdescribe it — and
whose single input has the null outpoint. None when it could not be fetched or did
not match; the caller then simply asks again at the next refresh."""
cached = self._coinbase_cache.get(txid)
if cached is not None:
return cached
if self.client is None:
return None
try:
raw = await self.client.get_transaction(txid, verbose=False)
tx = Transaction.parse(bytes.fromhex(raw))
except Exception:
logger.warning("could not read transaction %s to tell whether it is a coinbase", txid)
return None
if tx.txid().hex() != txid:
logger.warning("transaction %s: the server returned one that hashes to %s", txid, tx.txid().hex())
return None
result = len(tx.vin) == 1 and tx.vin[0].txid == bytes(32) and tx.vin[0].vout == 0xFFFFFFFF
if len(self._coinbase_cache) >= 10_000:
self._coinbase_cache.clear()
self._coinbase_cache[txid] = result
return result
async def _consume_scripthash(self, queue: asyncio.Queue) -> None:
while True:
scripthash, _status = await queue.get()
user_id = self._scripthash_to_user.get(scripthash)
if user_id is not None:
await self.refresh_user(user_id, scripthash)
try:
await self.refresh_user(user_id, scripthash)
except asyncio.CancelledError:
raise
except Exception:
# B-96 (DEP-16): one user's failed refresh (a coin already recorded, a
# corroboration error) ends neither the session nor the other users'
# notifications; a dropped connection ends it through wait_closed(), and
# this user is picked up again by the next refresh or the periodic sweep.
logger.warning("refresh of user %s failed after a notification", user_id, exc_info=True)
async def refresh_user(self, user_id: int, scripthash: str) -> None:
"""One refresh of a user at a time (B-96, DEP-15): a notification, the re-subscription
and the periodic sweep can ask together, and two of them reading the same new coin would
both try to credit it. The lock is the refresh's own, not the bet/withdrawal one, so a
corroboration in progress never delays a bet. Other users refresh in parallel."""
async with self._refresh_locks.setdefault(user_id, asyncio.Lock()):
await self._refresh_user_locked(user_id, scripthash)
async def _refresh_user_locked(self, user_id: int, scripthash: str) -> None:
"""Three phases, so no DB session is held across a network call (B-18),
same shape as _trigger_payout: read what's needed, corroborate every
balance-moving candidate against other servers — new credits (B-59) as
@@ -960,7 +1205,8 @@ class ElectrumListener:
entries = await self.client.listunspent(scripthash)
async with self._session_factory() as session:
credit_candidates = await find_new_credit_candidates(session, user_id, entries)
new_entries = await find_new_credit_candidates(session, user_id, entries)
policy = await read_deposit_policy(session, {entry["tx_hash"] for entry in new_entries})
# B-83 (DEP-02): candidates only — nothing is written before the quorum agrees.
reinstate_candidates = await find_reinstate_candidates(session, user_id, entries)
candidates = [
@@ -968,11 +1214,20 @@ class ElectrumListener:
for row in await find_utxos_missing_from(session, user_id, entries)
]
# B-82 (DEP-03, DEP-14): which of the new outputs have reached their threshold; the
# others are remembered as pending deposits (memory only) and shown as such.
credit_candidates, waiting_deposits = await classify_new_deposits(
new_entries, self.tip_height, policy, self.is_coinbase
)
self._pending_deposits[user_id] = waiting_deposits
if not waiting_deposits:
self._pending_deposits.pop(user_id, None)
corroborated_credits = [
entry
for entry in credit_candidates
for entry, required in credit_candidates
if await self.corroborate_utxo_credit(
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"]
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"], required_confirmations=required
)
]
+8
View File
@@ -1,4 +1,5 @@
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
@@ -16,9 +17,16 @@ def setup_logging() -> None:
handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
handler.setFormatter(logging.Formatter(_FORMAT))
# B-92 (ARC-09): also on stdout, so `docker compose logs app` shows the app's own messages
# (scheduler, listener, reconcilers) next to uvicorn's and the startup output. Only on the
# root logger: uvicorn's loggers already print to the console by themselves.
console = logging.StreamHandler(sys.stdout)
console.setFormatter(logging.Formatter(_FORMAT))
root = logging.getLogger()
root.setLevel(logging.INFO)
root.addHandler(handler)
root.addHandler(console)
# "uvicorn.error"/"uvicorn.access" have propagate=False in uvicorn's default
# logging config, so they never reach the root handler above and need their
+73 -1
View File
@@ -3,8 +3,10 @@ import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.logging_config import setup_logging
@@ -28,10 +30,12 @@ from app.deposits.reconcile import DepositReconciler
from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints
from app.electrum.listener import ElectrumListener
from app.rounds.scheduler import RoundScheduler
from app.rounds.pause import pause_by_operator
from app.tx.broadcast import RbfBumper
from app.tx.confirmation import ConfirmationPoller
from app.tx.locks import UserLocks
from app.tx.reconcile import PendingTransactionReconciler
from app.wallet.verify import verify_master_key
logger = logging.getLogger(__name__)
@@ -53,6 +57,12 @@ async def lifespan(app: FastAPI):
settings.electrum_cert_pin,
)
validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum
verify_master_key() # B-89: refuses to serve with a key that did not produce the stored addresses
if settings.start_paused: # B-90 (OPS-27): before the scheduler exists, so no round can open first
async with AsyncSessionLocal() as session:
await pause_by_operator(session, "START_PAUSED")
await session.commit()
logger.warning("START_PAUSED=true: the lottery starts paused (resume it from /admin)")
logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints))
listener = ElectrumListener(
@@ -62,6 +72,7 @@ async def lifespan(app: FastAPI):
app.state.user_locks = UserLocks()
scheduler = RoundScheduler(AsyncSessionLocal, listener)
app.state.scheduler = scheduler # B-92: /health/ready reads its heartbeat
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client, network=listener)
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client, network=listener)
# Resolves in-flight transactions against the chain — the piece that lets the
@@ -109,6 +120,28 @@ app.include_router(qr_router)
app.include_router(rounds_router)
# B-94 (API-15, API-16): no error answers with a bare-string or list `detail`; the framework's
# own 401/404/405 and request-validation failures take the structured shape too.
_FRAMEWORK_CODES = {401: "not_authenticated", 404: "not_found", 405: "method_not_allowed"}
@app.exception_handler(StarletteHTTPException)
async def structured_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse:
if isinstance(exc.detail, dict) and "code" in exc.detail:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers)
code = _FRAMEWORK_CODES.get(exc.status_code, f"http_{exc.status_code}")
detail = ApiError(code, str(exc.detail)).as_detail()
return JSONResponse(status_code=exc.status_code, content={"detail": detail}, headers=exc.headers)
@app.exception_handler(RequestValidationError)
async def structured_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
"""`input` is left out on purpose: an over-long password must not come back in the response."""
errors = [{"loc": list(e["loc"]), "msg": e["msg"], "type": e["type"]} for e in exc.errors()]
detail = ApiError("invalid_request", "invalid request", errors=errors).as_detail()
return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": detail})
@app.exception_handler(Exception)
async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
"""Answers with the same structured `detail` shape as every deliberate failure
@@ -126,6 +159,35 @@ async def health() -> dict[str, str]:
return {"status": "ok"}
# B-92 (API-46, ELC-29, ARC-61): the header age is generous on purpose — with 120 s blocks, 360 s
# of silence happens by chance about 5% of the time (e^-3), and a healthcheck must not flap on that.
READY_MAX_HEADER_AGE_SECONDS = 1800
READY_MAX_SCHEDULER_HEARTBEAT_SECONDS = 30
@app.get("/health/ready", include_in_schema=False)
async def health_ready() -> JSONResponse:
listener = getattr(app.state, "electrum_listener", None)
scheduler = getattr(app.state, "scheduler", None)
header_age = listener.last_header_age_seconds() if listener is not None else None
heartbeat_age = scheduler.heartbeat_age_seconds() if scheduler is not None else None
checks = {
"electrum_connected": listener is not None and listener.client is not None,
"last_header_age_seconds": header_age,
"scheduler_heartbeat_age_seconds": heartbeat_age,
}
ready = (
checks["electrum_connected"]
and header_age is not None
and header_age < READY_MAX_HEADER_AGE_SECONDS
and heartbeat_age is not None
and heartbeat_age < READY_MAX_SCHEDULER_HEARTBEAT_SECONDS
)
return JSONResponse(
status_code=200 if ready else 503, content={"status": "ready" if ready else "not_ready", "checks": checks}
)
_NO_STORE_HEADERS = {"Cache-Control": "no-store"}
@@ -149,4 +211,14 @@ async def report_bug_page() -> FileResponse:
return FileResponse("app/static/report-bug.html", headers=_NO_STORE_HEADERS)
app.mount("/", StaticFiles(directory="app/static", html=True), name="static")
class NoCacheStaticFiles(StaticFiles):
"""B-94 (API-09): the browser may keep a copy but revalidates it on every use, so after an
update it does not run an old app.js against the new API."""
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
response.headers["Cache-Control"] = "no-cache"
return response
app.mount("/", NoCacheStaticFiles(directory="app/static", html=True), name="static")
+15
View File
@@ -28,3 +28,18 @@ async def pause_automatically(session: AsyncSession, reason: str) -> None:
config.paused = True
config.paused_reason = reason
await write_audit_log(session, "lottery_paused", {"reason": reason})
async def pause_by_operator(session: AsyncSession, note: str | None = None):
"""The operator's pause (`POST /admin/pause`, B-90's `START_PAUSED`): never overwrites
an automatic reason already in force — that is the one that needs a look — and the
audit row always says `operator`. Returns the config row; the caller commits."""
config = await get_round_config(session)
if not (config.paused and config.paused_reason in AUTOMATIC_REASONS):
config.paused_reason = OPERATOR_REASON
config.paused = True
payload = {"reason": OPERATOR_REASON}
if note:
payload["note"] = note
await write_audit_log(session, "lottery_paused", payload)
return config
+194 -9
View File
@@ -1,4 +1,5 @@
import asyncio
import time
import logging
from datetime import datetime, timedelta, timezone
@@ -22,6 +23,7 @@ from app.rounds.draw import (
server_seed_commitment,
)
from app.rounds.events import broadcaster
from app.rounds.pause import pause_automatically
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
from app.tx.versions import record_version
@@ -46,6 +48,9 @@ _PAYOUT_RETRY_INTERVAL_SECONDS = 60
# corroboration failure. These only make an already-long wait *observable*, they
# never cut it short.
_DRAW_PROGRESS_LOG_INTERVAL_SECONDS = 60
# B-96 (TX-29, AUD-46): how long past its deadline a round may sit in `closing` on an in-flight bet before
# `closing_stalled` is written, and how often it repeats. The wait itself has no limit (ADR-51).
CLOSING_STALL_THRESHOLD_SECONDS = 1800
_DRAW_STALL_THRESHOLD_SECONDS = 360 # a few multiples of PLM's 120s block time
@@ -58,9 +63,16 @@ class RoundScheduler:
def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener):
self._session_factory = session_factory
self._listener = listener
# B-92 (ARC-61): monotonic time of the last tick or draw-wait iteration, read by
# /health/ready. The draw wait runs inside a tick and lasts several blocks, so it beats too.
self.last_heartbeat: float | None = None
def heartbeat_age_seconds(self) -> int | None:
return None if self.last_heartbeat is None else int(time.monotonic() - self.last_heartbeat)
async def run(self) -> None:
while True:
self.last_heartbeat = time.monotonic()
try:
await self._tick()
except asyncio.CancelledError:
@@ -142,10 +154,50 @@ class RoundScheduler:
)
)
if pending_count:
await self._note_closing_stall(round_id, deadline)
return # wait for in-flight bets to confirm before closing; stays "closing"
await self._close_and_draw(round_id)
async def _note_closing_stall(self, round_id: int, deadline: datetime) -> None:
"""B-96 (TX-29, AUD-46): the wait in `closing` is unbounded by design, so it is made
visible — one `closing_stalled` row per CLOSING_STALL_THRESHOLD_SECONDS once the round has
been past its deadline for that long. The latest row is the clock of the next one (read
from the log, not from memory, so a restart neither repeats nor anticipates it)."""
now = datetime.now(timezone.utc)
seconds_past = (now - deadline).total_seconds()
if seconds_past < CLOSING_STALL_THRESHOLD_SECONDS:
return
async with self._session_factory() as session:
last = await session.scalar(
select(AuditLog.created_at)
.where(AuditLog.event_type == "closing_stalled", AuditLog.round_id == round_id)
.order_by(AuditLog.id.desc())
.limit(1)
)
if last is not None and now < last.replace(tzinfo=timezone.utc) + timedelta(
seconds=CLOSING_STALL_THRESHOLD_SECONDS
):
return
txids = list(
await session.scalars(
select(RoundParticipant.bet_txid)
.where(
RoundParticipant.round_id == round_id,
RoundParticipant.status.in_(("building", "broadcast")),
)
.order_by(RoundParticipant.id)
)
)
await write_audit_log(
session,
"closing_stalled",
{"in_flight": len(txids), "txids": txids, "seconds_past_deadline": int(seconds_past)},
round_id=round_id,
)
await session.commit()
logger.warning("round %s has waited %ss past its deadline for %s in-flight bet(s)", round_id, int(seconds_past), len(txids))
async def _close_and_draw(self, round_id: int) -> None:
"""Close a `closing` round once nothing is in flight: either close it (no confirmed
participant) or commit to the draw — fix the participant list, its fingerprint and the
@@ -365,6 +417,7 @@ class RoundScheduler:
reported_hashes: set[str | None] = set()
while True:
self.last_heartbeat = time.monotonic()
now = datetime.now(timezone.utc)
tip = self._listener.tip_height
if tip >= required_tip:
@@ -437,6 +490,8 @@ class RoundScheduler:
either way it's due immediately.
"""
async with self._session_factory() as session:
if await session.scalar(select(Round.payout_blocked_at).where(Round.id == round_id)) is not None:
return # B-82 (DAT-68): held until an operator resumes the lottery
last_failure_at = await session.scalar(
select(AuditLog.created_at)
.where(AuditLog.event_type == "payout_failed", AuditLog.round_id == round_id)
@@ -477,6 +532,11 @@ class RoundScheduler:
logger.warning("round %s payout deferred: database busy", round_id)
return
round_ = await session.get(Round, round_id)
if round_.payout_blocked_at is not None:
# B-82 (DAT-68): held since the winner's own bet left the chain. Nothing is built
# until an operator resumes the lottery, which clears the mark (and the next
# attempt verifies the coins again).
return
already_in_flight = await session.scalar(
select(PendingTransaction).where(
PendingTransaction.round_id == round_id,
@@ -497,13 +557,25 @@ class RoundScheduler:
return
reserved_outpoints = await _reserved_payout_outpoints(session)
config = await get_round_config(session)
fee_address = config.fee_address
fee_address = round_.fee_address # B-95 (DAT-61): the round's own, fixed when it opened
fee_rate = config.fee_rate_sat_vb
pool_amount_sats = round_.pool_amount_sats
round_pool_address = round_.pool_address # B-80 ("" fails in derive_round_pool_key below)
winner_user_id = round_.winner_user_id
winner = await session.get(User, winner_user_id)
winner_address = winner.address if winner is not None else None
# B-82 (RND-22, RND-39): the bets the payout may spend and must first verify — the
# round's confirmed participants, each with the output that pays the pool.
bet_rows = [
(p.id, p.user_id, p.bet_txid, p.bet_vout, p.bet_amount_sats)
for p in (
await session.scalars(
select(RoundParticipant)
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed")
.order_by(RoundParticipant.draw_position)
)
).all()
]
await session.commit() # get_round_config may have created the row
if not fee_address:
@@ -517,8 +589,10 @@ class RoundScheduler:
await self._log_payout_failure(round_id, winner_user_id, "winner user not found")
return
winner_sats = winner_share(pool_amount_sats)
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
# A round opened before the per-round pool address (B-80) pays from the shared reserve
# address, whose coins are not the bets of one round: it keeps the old way of choosing
# them. Every other round is verified and paid from its own bets (RND-22, RND-39).
legacy_pool = round_pool_address == derive_pool_address()
# --- Phase 2: build (network read only, no DB write yet) -----------------
try:
@@ -528,12 +602,33 @@ class RoundScheduler:
pool_key = derive_round_pool_key(round_id, round_pool_address)
pool_script_obj = script.p2wpkh(pool_key.to_public())
pool_address = pool_script_obj.address(network=PLM_MAINNET)
entries = await client.listunspent(address_to_scripthash(pool_address))
utxos = [
Utxo(e["tx_hash"], e["tx_pos"], e["value"])
for e in entries
if e["height"] > 0 and (e["tx_hash"], e["tx_pos"]) not in reserved_outpoints
]
if not legacy_pool:
# B-82 (RND-39): with the quorum, before anything is built — bets that left the
# chain drop out and the split is computed on what is really there.
verified = await self._verify_round_pool(
round_id, pool_address, winner_user_id, bet_rows, pool_amount_sats
)
if verified is None:
return # held, unverifiable or database busy — already recorded
bet_rows, pool_amount_sats = verified
winner_sats = winner_share(pool_amount_sats)
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
if legacy_pool:
entries = await client.listunspent(address_to_scripthash(pool_address))
utxos = [
Utxo(e["tx_hash"], e["tx_pos"], e["value"])
for e in entries
if e["height"] > 0 and (e["tx_hash"], e["tx_pos"]) not in reserved_outpoints
]
else:
# B-82 (RND-22): exactly the verified bet outputs of this round, nothing else
# that happens to sit at the address, and none already claimed by an unresolved
# payout.
utxos = [
Utxo(txid, vout, amount)
for _id, _user_id, txid, vout, amount in bet_rows
if (txid, vout) not in reserved_outpoints
]
built = build_payout_transaction(
signing_key=pool_key,
@@ -634,6 +729,96 @@ class RoundScheduler:
logger.info("round %s payout broadcast: txid=%s", round_id, built.txid)
async def _verify_round_pool(
self,
round_id: int,
pool_address: str,
winner_user_id: int | None,
bet_rows: list[tuple[int, int, str, int, int]],
pool_amount_sats: int | None,
) -> tuple[list[tuple[int, int, str, int, int]], int] | None:
"""B-82 (RND-39, DAT-67, DAT-68, ELC-27): before a payout is built, check with the quorum
that each confirmed participant's bet output is still at the round's pool address.
Returns the participants that are really there with the pool amount recomputed on them
(the same pair when nothing was missing), or None when this attempt must stop: the
quorum could not be reached (`payout_failed`, `pool verification unavailable`), the
database was busy, or the **winner's own bet is gone** — then the payout is held
(`payout_blocked`, the lottery pauses) and only an operator's resume tries again.
Participants whose bet is gone become `reorged_out` and leave the pool, one
`participant_reorged_out` row each, in one transaction with the rewritten
`pool_amount_sats` (which can only go down). The winner's own row is left `confirmed`
in the held case, precisely so that the verification repeats after the resume."""
verdicts = await self._listener.verify_pool_outpoints(
address_to_scripthash(pool_address), [(txid, vout, amount) for _i, _u, txid, vout, amount in bet_rows]
)
if verdicts is None:
logger.error("round %s payout deferred: the pool coins could not be verified with the quorum", round_id)
await self._log_payout_failure(round_id, winner_user_id, "pool verification unavailable")
return None
gone = [row for row in bet_rows if not verdicts[(row[2], row[3])]]
if not gone:
return bet_rows, pool_amount_sats
winner_gone = next((row for row in gone if row[1] == winner_user_id), None)
async with self._session_factory() as session:
try:
await begin_immediate(session) # B-88
except OperationalError:
logger.warning("round %s payout deferred: database busy while excluding reorged-out bets", round_id)
return None
round_ = await session.get(Round, round_id)
excluded = [row for row in gone if row is not winner_gone]
for participant_id, user_id, txid, vout, amount in excluded:
participant = await session.get(RoundParticipant, participant_id)
if participant is None or participant.status != "confirmed":
continue
participant.status = "reorged_out"
await session.flush()
remaining = (
await session.scalars(
select(RoundParticipant).where(
RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed"
)
)
).all()
new_pool = sum(p.bet_amount_sats for p in remaining)
if pool_amount_sats is not None:
new_pool = min(new_pool, pool_amount_sats) # DAT-67: only ever reduced
round_.pool_amount_sats = new_pool
for _participant_id, user_id, txid, vout, amount in excluded:
await write_audit_log(
session,
"participant_reorged_out",
{"txid": txid, "vout": vout, "amount_sats": amount, "pool_amount_sats": new_pool},
user_id=user_id,
round_id=round_id,
)
if winner_gone is not None:
_pid, user_id, txid, vout, amount = winner_gone
round_.payout_blocked_at = datetime.now(timezone.utc)
await write_audit_log(
session,
"payout_blocked",
{"txid": txid, "vout": vout, "amount_sats": amount},
user_id=user_id,
round_id=round_id,
)
logger.error(
"round %s payout held: the winner's bet %s:%s is no longer on the chain; lottery paused",
round_id,
txid,
vout,
)
await pause_automatically(session, "payout_blocked") # same transaction (AUD-44)
await session.commit()
broadcaster.publish()
if winner_gone is not None:
return None
gone_ids = {row[0] for row in gone}
return [row for row in bet_rows if row[0] not in gone_ids], new_pool
async def _log_payout_failure(self, round_id: int, winner_user_id: int | None, reason: str) -> None:
"""Leaves an operator-visible trace in the audit log for a round stuck in
"paying_out" — the logs alone don't show up in /admin. Called from every
+5
View File
@@ -174,6 +174,11 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
duration_seconds=config.round_duration_seconds,
cooldown_seconds=config.round_cooldown_seconds,
draw_confirmations=config.draw_confirmations, # B-73 (DAT-61)
# B-95 (DAT-61, RND-05): everything else that governs the round, fixed now — a later
# edit from /admin applies to the next round, never to this one.
bet_amount_sats=config.bet_amount_sats,
fee_address=config.fee_address.strip(),
max_round_pool_sats=config.max_round_pool_sats,
# B-73 (RND-34, DAT-64): the commitment, then the secret itself encrypted — the
# plaintext column stays NULL until the reveal.
**_new_round_secret(),
+17 -1
View File
@@ -37,6 +37,7 @@
<span id="chain-status-label">Connessione…</span>
</span>
<span class="chain-block mono" id="chain-block">Blocco —</span>
<span class="chain-block mono" id="electrum-status">Electrum —</span>
<button class="secondary" style="margin:8px 0 8px 14px" onclick="adminLogout()">Esci</button>
</nav>
@@ -85,6 +86,10 @@
<input id="admin-draw-animation" inputmode="numeric" placeholder="es. 20">
<label for="admin-draw-confirmations">Conferme sul blocco di estrazione (1–20, dal round successivo)</label>
<input id="admin-draw-confirmations" inputmode="numeric" placeholder="es. 3">
<label for="admin-deposit-confirmations">Conferme per accreditare un deposito (1–100; le uscite coinbase ne richiedono comunque 120)</label>
<input id="admin-deposit-confirmations" inputmode="numeric" placeholder="es. 6">
<label for="admin-max-pool">Tetto al montepremi di un round (PLM, 0 = nessun tetto, dal round successivo)</label>
<input id="admin-max-pool" inputmode="decimal" placeholder="es. 0">
<label for="admin-fee-rate">Fee rate di rete (sat/vB)</label>
<input id="admin-fee-rate" inputmode="numeric" placeholder="es. 1">
<label for="admin-rbf-timeout">Timeout prima del fee-bump RBF (secondi)</label>
@@ -149,7 +154,17 @@
<div class="view" id="view-audit">
<h2 class="section-title">Audit log</h2>
<p class="hint">Ultimi eventi registrati dal sistema (config, bet, payout, accessi a chiavi private, ecc.).</p>
<p class="hint">Eventi registrati dal sistema (config, bet, payout, accessi a chiavi private, ecc.), dal più recente. I filtri si combinano; "Carica altri" prosegue dall'ultimo evento mostrato.</p>
<div class="card">
<div class="row-between" style="flex-wrap:wrap;gap:8px">
<input id="audit-f-type" placeholder="Tipo (es. payout_failed)" style="width:200px" />
<input id="audit-f-round" placeholder="Round" inputmode="numeric" style="width:90px" />
<input id="audit-f-user" placeholder="Utente" inputmode="numeric" style="width:90px" />
<input id="audit-f-from" type="datetime-local" title="Da" />
<input id="audit-f-to" type="datetime-local" title="A" />
<button class="secondary" style="width:auto;margin-top:0" onclick="loadAuditLog()">Filtra</button>
</div>
</div>
<div class="card">
<div class="table-wrap">
@@ -160,6 +175,7 @@
<tbody id="audit-tbody"></tbody>
</table>
</div>
<button id="audit-more" class="secondary hidden" style="width:auto" onclick="loadAuditLog(true)">Carica altri</button>
</div>
</div>
+59 -8
View File
@@ -34,8 +34,8 @@ async function callAdmin(method, path, body) {
const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken };
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
const data = await res.json().catch(() => ({}));
// detail is a bare string on the admin endpoints, but the shared dependencies
// (auth) answer with the structured {code, message} form of app/api/errors.py.
// Every error answers with the structured {code, message, params} detail (API-16);
// the bare-string fallback only covers a proxy's own error page.
if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText);
return data;
}
@@ -76,6 +76,26 @@ async function refreshChainStatus() {
} catch (e) {
// leave the last-known status on screen rather than blanking it out
}
refreshElectrumStatus();
}
// B-92 (API-47): which server the platform is talking to and how fresh its last block is.
async function refreshElectrumStatus() {
const el = document.getElementById('electrum-status');
if (!adminToken) return;
try {
const s = await callAdmin('GET', '/admin/electrum');
const age = s.last_header_age_seconds;
el.textContent = s.connected
? `Electrum ${s.endpoint}${age != null ? ' · ultimo blocco ' + age + 's fa' : ''}`
: 'Electrum non connesso';
el.title = s.last_rotation
? `Ultimo cambio server: ${s.last_rotation.from} (${s.last_rotation.reason}) alle ${fmtDate(s.last_rotation.at)}`
: '';
if (!s.quorum_enforced) el.title += ' · quorum non imposto (ALLOW_NO_QUORUM)';
} catch (e) {
// keep the last text
}
}
function startChainStatusPolling() {
@@ -90,7 +110,7 @@ function stopChainStatusPolling() {
}
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit', 'bugreports'];
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports };
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: () => loadAuditLog(), bugreports: loadBugReports };
let currentAdminView = 'parametri';
function switchView(name) {
@@ -151,6 +171,8 @@ async function adminLoadConfig() {
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
document.getElementById('admin-draw-animation').value = data.draw_animation_seconds;
document.getElementById('admin-draw-confirmations').value = data.draw_confirmations;
document.getElementById('admin-deposit-confirmations').value = data.deposit_confirmations;
document.getElementById('admin-max-pool').value = data.max_round_pool_sats / SATS_PER_PLM;
document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb;
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
renderMaintenanceState(data.paused);
@@ -211,6 +233,8 @@ async function adminSave() {
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10),
draw_confirmations: parseInt(document.getElementById('admin-draw-confirmations').value, 10),
deposit_confirmations: parseInt(document.getElementById('admin-deposit-confirmations').value, 10),
max_round_pool_sats: Math.round(parseFloat(document.getElementById('admin-max-pool').value || '0') * SATS_PER_PLM),
fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10),
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
};
@@ -226,7 +250,14 @@ async function adminSave() {
async function loadUsers() {
try {
const users = await callAdmin('GET', '/admin/users');
// B-94 (API-33): the list comes in pages of up to 500, by ascending id.
const users = [];
for (;;) {
const afterId = users.length ? users[users.length - 1].id : 0;
const page = await callAdmin('GET', `/admin/users?limit=500&after_id=${afterId}`);
users.push(...page);
if (page.length < 500) break;
}
const tbody = document.getElementById('users-tbody');
tbody.innerHTML = users.map((u) => `
<tr>
@@ -334,11 +365,29 @@ async function loadPending() {
}
}
async function loadAuditLog() {
let auditLastId = null;
const AUDIT_PAGE = 200;
async function loadAuditLog(more = false) {
try {
const entries = await callAdmin('GET', '/admin/audit-log');
// B-92 (API-37): filters combine; "more" continues from the last id shown.
const params = new URLSearchParams({ limit: String(AUDIT_PAGE) });
const type = document.getElementById('audit-f-type').value.trim();
if (type) params.append('event_type', type);
for (const [id, key] of [['audit-f-round', 'round_id'], ['audit-f-user', 'user_id']]) {
const v = document.getElementById(id).value.trim();
if (v) params.append(key, v);
}
for (const [id, key] of [['audit-f-from', 'from'], ['audit-f-to', 'to']]) {
const v = document.getElementById(id).value;
if (v) params.append(key, new Date(v).toISOString());
}
if (more === true && auditLastId !== null) params.append('before_id', String(auditLastId));
const entries = await callAdmin('GET', '/admin/audit-log?' + params);
auditLastId = entries.length ? entries[entries.length - 1].id : auditLastId;
document.getElementById('audit-more').classList.toggle('hidden', entries.length < AUDIT_PAGE);
const tbody = document.getElementById('audit-tbody');
tbody.innerHTML = entries.map((e) => `
const rows = entries.map((e) => `
<tr>
<td>${e.id}</td>
<td>${escapeHtml(e.event_type)}</td>
@@ -347,7 +396,9 @@ async function loadAuditLog() {
<td>${e.round_id ?? '—'}</td>
<td>${fmtDate(e.created_at)}</td>
</tr>
`).join('') || '<tr><td colspan="6" class="hint">Nessun evento registrato.</td></tr>';
`).join('');
if (more === true) tbody.insertAdjacentHTML('beforeend', rows);
else tbody.innerHTML = rows || '<tr><td colspan="6" class="hint">Nessun evento registrato.</td></tr>';
} catch (e) {
toast('Errore nel caricamento audit log: ' + e.message, 'error');
}
+1 -1
View File
@@ -87,7 +87,7 @@ async function call(method, path, body) {
// A token the server no longer accepts can't be recovered from by retrying:
// without this every poll keeps failing against a dashboard that still looks
// logged in, toasting "session expired" forever. Drop back to the login form.
if (res.status === 401 && data.detail?.code === 'session_expired' && token) logout();
if (res.status === 401 && ['session_expired', 'not_authenticated'].includes(data.detail?.code) && token) logout();
throw new Error(apiErrorMessage(data.detail) || res.statusText);
}
return data;
+70 -14
View File
@@ -87,7 +87,7 @@ const TRANSLATIONS = {
'result.lose': 'Not a win this time.',
'deposit.balanceTitle': 'Internal balance',
'deposit.balanceHint': 'Updated after 1 network confirmation',
'deposit.balanceHint': 'Updated once the network has confirmed your deposit (a few blocks)',
'deposit.refreshBtn': 'Refresh',
'deposit.refreshAria': 'Refresh balance',
'deposit.addressTitle': 'Deposit address',
@@ -142,7 +142,7 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Not connected to the network, please try again shortly.',
'error.no_round_open': 'No round is open right now, please try again shortly.',
'error.round_closing': 'The current round is closing, please try again shortly.',
'error.round_full': 'This round has reached its maximum of {max_participants} players — wait for the next one, it opens shortly.',
'error.round_full': 'This round is full (player or prize-pool limit reached) — wait for the next one, it opens shortly.',
'error.already_betting': 'You already have an active bet in the current round.',
'error.insufficient_balance': 'Insufficient balance.',
'error.balance_leaves_no_change': 'Your balance is too close to the bet amount: {required_extra_plm} PLM more are needed so the transaction keeps a change output and can be fee-bumped if the network is slow.',
@@ -161,6 +161,14 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Enter an amount greater than zero.',
'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.',
'error.service_busy': 'The service is busy right now and nothing was sent. Please try again in a moment.',
'error.registration_unavailable': 'Registration is temporarily unavailable. Please try again later.',
'error.not_authenticated': 'You are not signed in. Please sign in again.',
'error.not_found': 'Not found.',
'error.method_not_allowed': 'This action is not allowed.',
'error.too_many_streams': 'Too many live-update connections right now; the page will keep refreshing on its own.',
'error.invalid_admin_token': 'The admin token is missing or wrong.',
'error.user_not_found': 'User not found.',
'error.bug_report_not_found': 'Bug report not found.',
'error.amount_below_dust_limit': 'The amount is too small to be sent.',
'error.too_many_inputs': 'Your balance is split across too many small deposits to be spent in a single transaction (max {max_inputs}). Please contact support to consolidate it.',
'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.',
@@ -258,7 +266,7 @@ const TRANSLATIONS = {
'result.lose': 'Non hai vinto questa volta.',
'deposit.balanceTitle': 'Saldo interno',
'deposit.balanceHint': 'Aggiornato dopo 1 conferma sulla rete',
'deposit.balanceHint': 'Aggiornato quando la rete ha confermato il deposito (alcuni blocchi)',
'deposit.refreshBtn': 'Aggiorna',
'deposit.refreshAria': 'Aggiorna saldo',
'deposit.addressTitle': 'Indirizzo di deposito',
@@ -310,7 +318,7 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Nessuna connessione alla rete, riprova tra poco.',
'error.no_round_open': 'Nessun round aperto in questo momento, riprova tra poco.',
'error.round_closing': 'Il round corrente si sta chiudendo, riprova tra poco.',
'error.round_full': 'Questo round ha raggiunto il massimo di {max_participants} giocatori — aspetta il prossimo, si apre tra poco.',
'error.round_full': 'Questo round è al completo (limite di giocatori o di montepremi raggiunto) — aspetta il prossimo, si apre tra poco.',
'error.already_betting': 'Hai già una bet attiva nel round corrente.',
'error.insufficient_balance': 'Saldo insufficiente.',
'error.balance_leaves_no_change': 'Il tuo saldo è troppo vicino all\'importo della giocata: servono {required_extra_plm} PLM in più perché la transazione mantenga un resto e possa essere rilanciata con fee più alta se la rete è lenta.',
@@ -329,6 +337,14 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Inserisci un importo maggiore di zero.',
'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.',
'error.service_busy': 'Il servizio è occupato in questo momento e non è stato inviato nulla. Riprova tra poco.',
'error.registration_unavailable': 'La registrazione è temporaneamente non disponibile. Riprova più tardi.',
'error.not_authenticated': 'Non hai effettuato l\'accesso. Accedi di nuovo.',
'error.not_found': 'Non trovato.',
'error.method_not_allowed': 'Questa azione non è consentita.',
'error.too_many_streams': 'Troppe connessioni di aggiornamento in tempo reale; la pagina continuerà ad aggiornarsi da sola.',
'error.invalid_admin_token': 'Il token admin manca o è errato.',
'error.user_not_found': 'Utente non trovato.',
'error.bug_report_not_found': 'Segnalazione non trovata.',
'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.",
'error.too_many_inputs': 'Il tuo saldo è suddiviso in troppi piccoli depositi per essere speso in una sola transazione (max {max_inputs}). Contatta l\'assistenza per consolidarlo.',
'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.',
@@ -426,7 +442,7 @@ const TRANSLATIONS = {
'result.lose': 'Esta vez no has ganado.',
'deposit.balanceTitle': 'Saldo interno',
'deposit.balanceHint': 'Actualizado tras 1 confirmación en la red',
'deposit.balanceHint': 'Se actualiza cuando la red confirma tu depósito (unos bloques)',
'deposit.refreshBtn': 'Actualizar',
'deposit.refreshAria': 'Actualizar saldo',
'deposit.addressTitle': 'Dirección de depósito',
@@ -478,7 +494,7 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Sin conexión con la red, inténtalo de nuevo en un momento.',
'error.no_round_open': 'No hay ninguna ronda abierta ahora mismo, inténtalo de nuevo en un momento.',
'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.',
'error.round_full': 'Esta ronda ha alcanzado su máximo de {max_participants} jugadores: espera la siguiente, se abre en breve.',
'error.round_full': 'Esta ronda está completa (límite de jugadores o de bote alcanzado): espera la siguiente, se abre en breve.',
'error.already_betting': 'Ya tienes una apuesta activa en la ronda actual.',
'error.insufficient_balance': 'Saldo insuficiente.',
'error.balance_leaves_no_change': 'Tu saldo está demasiado cerca del importe de la apuesta: hacen falta {required_extra_plm} PLM más para que la transacción conserve un cambio y pueda relanzarse con más comisión si la red va lenta.',
@@ -497,6 +513,14 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Introduce un importe mayor que cero.',
'error.broadcast_failed': 'La red rechazó la transacción. Inténtalo de nuevo en un momento.',
'error.service_busy': 'El servicio está ocupado en este momento y no se ha enviado nada. Inténtalo de nuevo en un momento.',
'error.registration_unavailable': 'El registro no está disponible temporalmente. Inténtalo de nuevo más tarde.',
'error.not_authenticated': 'No has iniciado sesión. Inicia sesión de nuevo.',
'error.not_found': 'No encontrado.',
'error.method_not_allowed': 'Esta acción no está permitida.',
'error.too_many_streams': 'Demasiadas conexiones de actualización en tiempo real; la página seguirá actualizándose sola.',
'error.invalid_admin_token': 'Falta el token de administrador o es incorrecto.',
'error.user_not_found': 'Usuario no encontrado.',
'error.bug_report_not_found': 'Informe de error no encontrado.',
'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.',
'error.too_many_inputs': 'Tu saldo está repartido en demasiados depósitos pequeños para gastarse en una sola transacción (máx. {max_inputs}). Contacta con soporte para consolidarlo.',
'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.',
@@ -594,7 +618,7 @@ const TRANSLATIONS = {
'result.lose': "Pas de gain cette fois-ci.",
'deposit.balanceTitle': 'Solde interne',
'deposit.balanceHint': 'Mis à jour après 1 confirmation sur le réseau',
'deposit.balanceHint': 'Mis à jour une fois le dépôt confirmé par le réseau (quelques blocs)',
'deposit.refreshBtn': 'Actualiser',
'deposit.refreshAria': 'Actualiser le solde',
'deposit.addressTitle': 'Adresse de dépôt',
@@ -646,7 +670,7 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Pas de connexion au réseau, réessayez dans un instant.',
'error.no_round_open': "Aucun round n'est ouvert pour le moment, réessayez dans un instant.",
'error.round_closing': 'Le round en cours est en train de se fermer, réessayez dans un instant.',
'error.round_full': 'Ce tour a atteint son maximum de {max_participants} joueurs — attendez le prochain, il ouvre dans un instant.',
'error.round_full': 'Ce tour est complet (limite de joueurs ou de cagnotte atteinte) — attendez le prochain, il ouvre dans un instant.',
'error.already_betting': 'Vous avez déjà une mise active dans le round en cours.',
'error.insufficient_balance': 'Solde insuffisant.',
'error.balance_leaves_no_change': 'Votre solde est trop proche du montant de la mise : il faut {required_extra_plm} PLM de plus pour que la transaction garde une monnaie de rendu et puisse être relancée avec des frais plus élevés si le réseau est lent.',
@@ -665,6 +689,14 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Saisissez un montant supérieur à zéro.',
'error.broadcast_failed': 'Le réseau a refusé la transaction. Veuillez réessayer dans un instant.',
'error.service_busy': 'Le service est occupé pour le moment et rien n\'a été envoyé. Veuillez réessayer dans un instant.',
'error.registration_unavailable': 'L\'inscription est temporairement indisponible. Veuillez réessayer plus tard.',
'error.not_authenticated': 'Vous n\'êtes pas connecté. Veuillez vous reconnecter.',
'error.not_found': 'Introuvable.',
'error.method_not_allowed': 'Cette action n\'est pas autorisée.',
'error.too_many_streams': 'Trop de connexions de mise à jour en direct ; la page continuera de s\'actualiser seule.',
'error.invalid_admin_token': 'Le jeton d\'administration est absent ou incorrect.',
'error.user_not_found': 'Utilisateur introuvable.',
'error.bug_report_not_found': 'Signalement introuvable.',
'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.",
'error.too_many_inputs': 'Votre solde est réparti sur trop de petits dépôts pour être dépensé en une seule transaction (max {max_inputs}). Contactez le support pour le consolider.',
'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.",
@@ -762,7 +794,7 @@ const TRANSLATIONS = {
'result.lose': 'Diesmal kein Gewinn.',
'deposit.balanceTitle': 'Internes Guthaben',
'deposit.balanceHint': 'Aktualisiert nach 1 Netzwerkbestätigung',
'deposit.balanceHint': 'Aktualisiert, sobald das Netzwerk die Einzahlung bestätigt hat (einige Blöcke)',
'deposit.refreshBtn': 'Aktualisieren',
'deposit.refreshAria': 'Guthaben aktualisieren',
'deposit.addressTitle': 'Einzahlungsadresse',
@@ -814,7 +846,7 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Keine Verbindung zum Netzwerk, bitte versuche es gleich erneut.',
'error.no_round_open': 'Derzeit ist keine Runde offen, bitte versuche es gleich erneut.',
'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.',
'error.round_full': 'Diese Runde hat ihr Maximum von {max_participants} Spielern erreicht — warten Sie auf die nächste, sie beginnt in Kürze.',
'error.round_full': 'Diese Runde ist voll (Spieler- oder Preispool-Limit erreicht) — warten Sie auf die nächste, sie beginnt in Kürze.',
'error.already_betting': 'Du hast bereits eine aktive Wette in der laufenden Runde.',
'error.insufficient_balance': 'Nicht genügend Guthaben.',
'error.balance_leaves_no_change': 'Ihr Guthaben liegt zu nah am Einsatzbetrag: Es werden {required_extra_plm} PLM mehr benötigt, damit die Transaktion einen Wechselgeld-Ausgang behält und bei langsamem Netz mit höherer Gebühr neu gesendet werden kann.',
@@ -833,6 +865,14 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Gib einen Betrag größer als null ein.',
'error.broadcast_failed': 'Das Netzwerk hat die Transaktion abgelehnt. Bitte versuche es in Kürze erneut.',
'error.service_busy': 'Der Dienst ist gerade ausgelastet und es wurde nichts gesendet. Bitte versuche es gleich noch einmal.',
'error.registration_unavailable': 'Die Registrierung ist vorübergehend nicht verfügbar. Bitte versuche es später noch einmal.',
'error.not_authenticated': 'Du bist nicht angemeldet. Bitte melde dich erneut an.',
'error.not_found': 'Nicht gefunden.',
'error.method_not_allowed': 'Diese Aktion ist nicht erlaubt.',
'error.too_many_streams': 'Zu viele Live-Update-Verbindungen; die Seite aktualisiert sich weiterhin selbst.',
'error.invalid_admin_token': 'Das Admin-Token fehlt oder ist falsch.',
'error.user_not_found': 'Benutzer nicht gefunden.',
'error.bug_report_not_found': 'Fehlermeldung nicht gefunden.',
'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.',
'error.too_many_inputs': 'Ihr Guthaben ist auf zu viele kleine Einzahlungen verteilt, um in einer einzigen Transaktion ausgegeben zu werden (max. {max_inputs}). Bitte wenden Sie sich an den Support, um es zusammenzufassen.',
'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.',
@@ -930,7 +970,7 @@ const TRANSLATIONS = {
'result.lose': 'На этот раз без выигрыша.',
'deposit.balanceTitle': 'Внутренний баланс',
'deposit.balanceHint': 'Обновляется после 1 подтверждения в сети',
'deposit.balanceHint': 'Обновляется, когда сеть подтвердит депозит (несколько блоков)',
'deposit.refreshBtn': 'Обновить',
'deposit.refreshAria': 'Обновить баланс',
'deposit.addressTitle': 'Адрес для депозита',
@@ -982,7 +1022,7 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Нет соединения с сетью, повторите попытку чуть позже.',
'error.no_round_open': 'Сейчас нет открытого раунда, повторите попытку чуть позже.',
'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.',
'error.round_full': 'В этом раунде достигнут максимум участников ({max_participants}) — дождитесь следующего, он начнётся совсем скоро.',
'error.round_full': 'Этот раунд заполнен (достигнут лимит игроков или призового фонда) — дождитесь следующего, он начнётся совсем скоро.',
'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.',
'error.insufficient_balance': 'Недостаточно средств.',
'error.balance_leaves_no_change': 'Ваш баланс слишком близок к сумме ставки: нужно ещё {required_extra_plm} PLM, чтобы в транзакции остался выход сдачи и её можно было переотправить с более высокой комиссией, если сеть работает медленно.',
@@ -1001,6 +1041,14 @@ const TRANSLATIONS = {
'error.invalid_amount': 'Введите сумму больше нуля.',
'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.',
'error.service_busy': 'Сервис сейчас занят, ничего не было отправлено. Попробуйте ещё раз через минуту.',
'error.registration_unavailable': 'Регистрация временно недоступна. Попробуйте позже.',
'error.not_authenticated': 'Вы не вошли в систему. Войдите снова.',
'error.not_found': 'Не найдено.',
'error.method_not_allowed': 'Это действие не разрешено.',
'error.too_many_streams': 'Слишком много соединений для живых обновлений; страница продолжит обновляться сама.',
'error.invalid_admin_token': 'Токен администратора отсутствует или неверен.',
'error.user_not_found': 'Пользователь не найден.',
'error.bug_report_not_found': 'Сообщение об ошибке не найдено.',
'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.',
'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.',
'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.',
@@ -1098,7 +1146,7 @@ const TRANSLATIONS = {
'result.lose': '这次没有中奖。',
'deposit.balanceTitle': '内部余额',
'deposit.balanceHint': '在网络确认 1 次后更新',
'deposit.balanceHint': '网络确认存款后更新(需要数个区块)',
'deposit.refreshBtn': '刷新',
'deposit.refreshAria': '刷新余额',
'deposit.addressTitle': '存款地址',
@@ -1150,7 +1198,7 @@ const TRANSLATIONS = {
'error.network_unavailable': '未连接到网络,请稍后重试。',
'error.no_round_open': '当前没有开放的回合,请稍后重试。',
'error.round_closing': '当前回合正在结束,请稍后重试。',
'error.round_full': '本轮已达到 {max_participants} 名玩家的上限 —— 请等待下一轮,很快就会开始。',
'error.round_full': '本轮已满(达到玩家人数或奖池上限)—— 请等待下一轮,很快就会开始。',
'error.already_betting': '你在当前回合已有一笔有效下注。',
'error.insufficient_balance': '余额不足。',
'error.balance_leaves_no_change': '您的余额与投注金额过于接近:还需要 {required_extra_plm} PLM,交易才能保留找零输出,并在网络拥堵时提高手续费重新广播。',
@@ -1169,6 +1217,14 @@ const TRANSLATIONS = {
'error.invalid_amount': '请输入大于零的金额。',
'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。',
'error.service_busy': '服务当前繁忙,没有发送任何内容,请稍后重试。',
'error.registration_unavailable': '注册暂时不可用,请稍后再试。',
'error.not_authenticated': '您尚未登录,请重新登录。',
'error.not_found': '未找到。',
'error.method_not_allowed': '不允许此操作。',
'error.too_many_streams': '实时更新连接过多;页面将自行继续刷新。',
'error.invalid_admin_token': '管理员令牌缺失或错误。',
'error.user_not_found': '未找到用户。',
'error.bug_report_not_found': '未找到错误报告。',
'error.amount_below_dust_limit': '金额过小,无法发送。',
'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。',
'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。',
+1 -1
View File
@@ -176,7 +176,7 @@
<div class="dash-panel active" id="panel-deposit">
<div class="card">
<h2 data-i18n="deposit.balanceTitle">Saldo interno</h2>
<p class="hint" data-i18n="deposit.balanceHint">Aggiornato dopo 1 conferma sulla rete</p>
<p class="hint" data-i18n="deposit.balanceHint">Aggiornato quando la rete ha confermato il deposito (alcuni blocchi)</p>
<div class="row-between">
<div><span class="balance-value mono" id="dash-balance">—</span> <span class="balance-unit">PLM</span></div>
<button class="secondary" onclick="refreshMe()" id="refresh-btn" data-i18n-aria-label="deposit.refreshAria" aria-label="Aggiorna saldo">
+93 -17
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import logging
from datetime import datetime, timedelta, timezone
@@ -9,8 +10,10 @@ from embit.finalizer import finalize_psbt
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import (
AuditLog,
PendingTransaction,
PendingTransactionTxid,
Round,
@@ -25,7 +28,7 @@ from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_
from app.tx.versions import record_version
from app.wallet.hd import derive_pool_address, derive_round_pool_key, derive_user_key
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
from app.wallet.psbt_builder import DUST_LIMIT_SATS, MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
logger = logging.getLogger(__name__)
@@ -39,8 +42,17 @@ _FEE_RATE_INCREMENT = 1 # how much pending.fee_rate_sat_vb's *target* rises by
_INCREMENTAL_RELAY_FEE_RATE_SAT_VB = 1
# B-87 (TX-24, AUD-40): why a bump could not be made — the `reason` of the `rbf_bump_failed`
# audit row the bumper writes.
REASON_CHANGE_TOO_SMALL = "change_too_small"
REASON_MAX_FEE_RATE = "max_fee_rate"
REASON_REPLACEMENT_REJECTED = "replacement_rejected"
class RbfError(Exception):
pass
def __init__(self, message: str, reason: str = REASON_CHANGE_TOO_SMALL):
super().__init__(message)
self.reason = reason
def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool:
@@ -145,13 +157,16 @@ async def bump_fee(
matched is a conflict: the row is left alone, the candidate stays recorded, and
the reconciler resolves it.
Only handles the common case: exactly one change output paying back to the
tx's own sender address, large enough to absorb the increase. If there's no
such output (e.g. an exact-amount bet with no change), this raises RbfError —
bumping such a tx would require selecting additional inputs, which isn't
implemented for the MVP; it needs manual operator intervention. Also raises
RbfError, rather than bumping, once the row is already at MAX_FEE_RATE_SAT_VB
(B-32), and when the network explicitly refuses the replacement (TX-24).
Only handles one shape: exactly one output to take the increase from — the sender's
own change, or a payout's bump reserve (B-79) — and when what would be left of it is
below the dust limit the output is dropped and its whole value goes to the fee, which
makes that replacement the last possible one (B-87, TX-11). With no such output, or
when even its whole value is below BIP125's minimum increase, this raises RbfError —
adding inputs would not help the case that used to hurt (a transaction that already
spends everything the sender has). Also raises RbfError, rather than bumping, once the
row is already at MAX_FEE_RATE_SAT_VB (B-32), and when the network explicitly refuses
the replacement (TX-24). Each carries a `reason` for the `rbf_bump_failed` audit row
(RbfBumper._record_failure).
"""
# --- Phase 1: read what's needed, close the session before any network call ---
async with session_factory() as session:
@@ -162,7 +177,8 @@ async def bump_fee(
if pending.fee_rate_sat_vb >= MAX_FEE_RATE_SAT_VB:
raise RbfError(
f"pending_transaction {pending_id}: already at the maximum fee rate "
f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further"
f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further",
REASON_MAX_FEE_RATE,
)
kind = pending.kind
@@ -193,12 +209,28 @@ async def bump_fee(
fee_delta = max(target_fee - old_fee, min_valid_delta)
change_index = _find_reducible_output(old_tx, kind, own_address)
if change_index is None or old_tx.vout[change_index].value <= fee_delta:
raise RbfError(f"pending_transaction {pending_id}: no change output large enough to absorb a fee bump")
if change_index is None:
raise RbfError(f"pending_transaction {pending_id}: there is no output to take a fee bump from")
reducible_value = old_tx.vout[change_index].value
if reducible_value < min_valid_delta:
# Not even giving the whole output to the fee reaches BIP125's minimum increase.
raise RbfError(
f"pending_transaction {pending_id}: its change ({reducible_value} sat) cannot even cover the "
f"minimum relay increase ({min_valid_delta} sat)"
)
new_vout = list(old_tx.vout)
bumped_change = new_vout[change_index].value - fee_delta
new_vout[change_index] = TransactionOutput(bumped_change, new_vout[change_index].script_pubkey)
bumped_change = reducible_value - fee_delta
if bumped_change >= DUST_LIMIT_SATS:
new_vout[change_index] = TransactionOutput(bumped_change, new_vout[change_index].script_pubkey)
else:
# B-87 (TX-11): what would be left is below the dust limit, an output no node relays — a
# 294 sat change shrinks to ~153 after the very first minimum bump, and every replacement
# kept by it was refused, every 30 seconds, for ever. Drop the output instead and give its
# whole value to the fee: the replacement is valid, and it is the last one this
# transaction can have, since nothing is left to reduce.
del new_vout[change_index]
fee_delta = reducible_value
new_vin = [TransactionInput(v.txid, v.vout, sequence=RBF_SEQUENCE) for v in old_tx.vin]
new_tx = Transaction(vin=new_vin, vout=new_vout)
@@ -232,7 +264,10 @@ async def bump_fee(
# The network explicitly refused the replacement and no server knows it: the row
# is unchanged and keeps the version that is on the network (TX-24). The
# candidate stays recorded — it was never relayed, so nobody will confirm it.
raise RbfError(f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}")
raise RbfError(
f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}",
REASON_REPLACEMENT_REJECTED,
)
# ACCEPTED, or UNCERTAIN: the replacement may well be on the network, so the row
# follows it (TX-12); the old version stays recorded and is still recognised should
# it be the one that confirms.
@@ -259,7 +294,7 @@ async def bump_fee(
replaced_by_txid=old_txid, # points backwards: what current_txid replaced
current_txid=new_txid,
raw_tx_hex=raw_hex,
fee_rate_sat_vb=(old_fee + fee_delta) // vsize,
fee_rate_sat_vb=(old_fee + fee_delta) // estimate_vsize(len(new_vin), len(new_vout)),
attempt_count=PendingTransaction.attempt_count + 1,
last_broadcast_at=datetime.now(timezone.utc),
)
@@ -367,6 +402,46 @@ class RbfBumper:
logger.exception("RBF bump tick failed")
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
async def _record_failure(self, pending_id: int, exc: RbfError) -> None:
"""B-87 (TX-24, AUD-40, AUD-09): a bump that cannot be made is visible from /admin as one
`rbf_bump_failed` row — in a transaction of its own, the row stays `pending` — and only
**once per (current txid, reason)**: the same failure on the same transaction every 30
seconds goes to the log alone, while a new txid (after a successful bump) or another
reason writes a new row."""
try:
async with self._session_factory() as session:
pending = await session.get(PendingTransaction, pending_id)
if pending is None:
return
already = (
await session.scalars(
select(AuditLog.payload_json).where(
AuditLog.event_type == "rbf_bump_failed",
AuditLog.payload_json.contains(f'"txid": "{pending.current_txid}"'),
)
)
).all()
for payload_json in already:
payload = json.loads(payload_json)
if payload.get("txid") == pending.current_txid and payload.get("reason") == exc.reason:
return
await write_audit_log(
session,
"rbf_bump_failed",
{
"pending_transaction_id": pending.id,
"kind": pending.kind,
"txid": pending.current_txid,
"reason": exc.reason,
"message": str(exc)[:200],
},
user_id=pending.user_id,
round_id=pending.round_id,
)
await session.commit()
except Exception:
logger.exception("could not record the failed bump of pending_transaction %s", pending_id)
async def _tick(self, client: ElectrumClient) -> None:
now = datetime.now(timezone.utc)
async with self._session_factory() as session:
@@ -379,7 +454,8 @@ class RbfBumper:
for pending_id in due_ids:
try:
await bump_fee(self._session_factory, client, pending_id, network=self._network)
except RbfError:
except RbfError as exc:
logger.exception("could not bump pending_transaction %s", pending_id)
await self._record_failure(pending_id, exc)
except Exception:
logger.exception("unexpected error bumping pending_transaction %s", pending_id)
+40 -2
View File
@@ -214,8 +214,9 @@ async def reconcile_once(
await _abandon(session, row, payload)
changed = True
await session.commit()
resolved += changed
broadcaster.publish()
if changed: # B-96 (TX-27): a row that was only examined moved nothing to refetch
resolved += 1
broadcaster.publish()
return resolved
@@ -360,6 +361,9 @@ async def _promote(session: AsyncSession, row: PendingTransaction) -> None:
"""The tx did make it onto the chain before we died — finish what phase 2 of
place_bet/request_withdrawal would have done."""
row.status = "pending"
# B-96 (TX-16, AUD-30): besides pending_tx_recovered, the event the successful send would have
# written — a recovered bet, withdrawal or payout is otherwise absent from the audit trail.
sent_event: tuple[str, dict, int | None] | None = None
if row.kind == "bet":
participant = await session.scalar(
select(RoundParticipant).where(
@@ -368,10 +372,39 @@ async def _promote(session: AsyncSession, row: PendingTransaction) -> None:
)
if participant is not None and participant.status == "building":
participant.status = "broadcast"
sent_event = (
"bet_placed",
{"txid": row.current_txid, "amount_sats": participant.bet_amount_sats},
row.user_id,
)
elif row.kind == "withdrawal" and row.withdrawal_id is not None:
withdrawal = await session.get(Withdrawal, row.withdrawal_id)
if withdrawal is not None and withdrawal.status == "building":
withdrawal.status = "broadcast"
sent_event = (
"withdrawal_sent",
{
"txid": row.current_txid,
"amount_sent_sats": withdrawal.amount_sent_sats,
"external_address": withdrawal.external_address,
},
row.user_id,
)
elif row.kind == "payout" and row.round_id is not None:
round_ = await session.get(Round, row.round_id)
if round_ is not None:
sent_event = (
"payout_sent",
{
"txid": row.current_txid,
"winner_sats": round_.winner_amount_sats,
"commission_sats": round_.fee_amount_sats,
},
round_.winner_user_id,
)
if sent_event is not None:
event, payload, event_user_id = sent_event
await write_audit_log(session, event, payload, user_id=event_user_id, round_id=row.round_id)
await write_audit_log(
session,
"pending_tx_recovered",
@@ -418,6 +451,11 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str)
round_ = await session.get(Round, row.round_id) if row.round_id else None
if round_ is not None and round_.status == "paying_out":
round_.payout_txid = None
# B-96 (TX-18, DAT-22): the amounts belong to the attempt that no longer exists; the
# next attempt writes its own.
round_.winner_amount_sats = None
round_.fee_amount_sats = None
round_.bump_reserve_sats = None
logger.error(
"round %s payout tx %s vanished — round needs operator attention", round_.id, row.current_txid
)
+9 -2
View File
@@ -18,7 +18,9 @@ async def recompute_balance(session: AsyncSession, user_id: int) -> int:
return user.cached_balance_sats
async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[int, bool]:
async def compute_pending_balance(
session: AsyncSession, user: User, pending_deposit_sats: int = 0
) -> tuple[int, bool]:
"""Balance including the user's own change still in flight.
Placing a bet or a withdrawal spends whatever UTXOs cover the amount — often
@@ -47,6 +49,11 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
the same sats are already inside cached_balance_sats — double-counting the
change unless excluded below.
B-82 (DEP-12 (b), DEP-14): `pending_deposit_sats` is the total of the user's deposits
that are confirmed but still short of their threshold (the listener keeps them, in
memory); they are added to what is shown, and make `has_pending` true, exactly like
change in flight — without ever being spendable.
Returns (pending_inclusive_balance_sats, has_pending) — has_pending tells the
caller whether this differs from the confirmed-only balance at all.
"""
@@ -79,4 +86,4 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
pending_change_sats += out.value
return user.cached_balance_sats + pending_change_sats, bool(pending)
return user.cached_balance_sats + pending_change_sats + pending_deposit_sats, bool(pending) or pending_deposit_sats > 0
+2 -3
View File
@@ -5,7 +5,7 @@ from embit.bip32 import HDKey
from embit.ec import PrivateKey
from app.config import settings
from app.wallet.keystore import decrypt_xprv, encrypt_xprv
from app.wallet.keystore import decrypt_xprv, encrypt_xprv, write_secret_file
from app.wallet.plm_network import ACCOUNT_PATH, PLM_MAINNET
_account_key: HDKey | None = None
@@ -17,8 +17,7 @@ def generate_master_key(overwrite: bool = False) -> None:
if os.path.exists(settings.master_key_path) and not overwrite:
raise FileExistsError(f"{settings.master_key_path} already exists")
root = HDKey.from_seed(os.urandom(32), version=PLM_MAINNET["xprv"])
with open(settings.master_key_path, "wb") as f:
f.write(encrypt_xprv(root.to_base58(version=PLM_MAINNET["xprv"])))
write_secret_file(settings.master_key_path, encrypt_xprv(root.to_base58(version=PLM_MAINNET["xprv"])))
def _load_account_key() -> HDKey:
+11
View File
@@ -1,3 +1,5 @@
import os
from cryptography.fernet import Fernet
from app.config import settings
@@ -20,3 +22,12 @@ def encrypt_round_seed(server_seed_hex: str) -> str:
def decrypt_round_seed(token: str) -> str:
return Fernet(settings.xprv_encryption_key).decrypt(token.encode()).decode()
def write_secret_file(path: str, data: bytes) -> None:
"""B-89 (SEC-50): created `0600` from the start (never through a wider mode) and
rewritten with the same mode, so a key file is not readable by other users."""
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
os.fchmod(f.fileno(), 0o600)
f.write(data)
+4
View File
@@ -26,3 +26,7 @@ PLM_MAINNET = {
BIP44_COIN_TYPE = 746
ACCOUNT_PATH = f"m/84h/{BIP44_COIN_TYPE}h/0h"
# PLM shares Bitcoin's genesis block (palladiumcore chainparams.cpp) and its powLimit, so a
# Bitcoin Electrum server would pass every header check; the handshake asks for this instead.
GENESIS_HASH = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
+23 -13
View File
@@ -30,8 +30,10 @@ DUST_LIMIT_SATS = 294
# reason MIN_PASSWORD_LENGTH is shared in auth/security.py) and tx/broadcast.py's RBF
# bump escalation, which refuses to bump a pending_transaction past this rate (B-32) —
# without a ceiling, a stuck transaction's fee climbed by 1 sat/vB every bump forever,
# eating further and further into the sender's change with no limit.
MAX_FEE_RATE_SAT_VB = 10_000
# eating further and further into the sender's change with no limit. B-100: half of the node's own
# `sendrawtransaction` maxfeerate (0.1 PLM/kvB = 10 000 sat/vB, palladiumcore rpc/rawtransaction.cpp):
# vsize is estimated upwards, so a rate sitting exactly on the node's limit would be refused.
MAX_FEE_RATE_SAT_VB = 5_000
# Ceiling on how many UTXOs one *user* transaction (bet, withdrawal) may spend (B-48).
# Every extra input costs ~68 vbytes of fee, and that fee comes out of the amount being
@@ -125,7 +127,7 @@ def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
def select_utxos(
utxos: list[Utxo], target_sats: int, max_inputs: int = MAX_TX_INPUTS
utxos: list[Utxo], target_sats: int, max_inputs: int = MAX_TX_INPUTS, min_change_sats: int = 0
) -> tuple[list[Utxo], int]:
"""Greedily select UTXOs (largest first, to minimize input count) covering
target_sats — the amount deducted from the sender's balance. The fee is paid
@@ -140,7 +142,13 @@ def select_utxos(
want different ones (B-52): MAX_TX_INPUTS protects a user from a fee that would
eat into their own bet/withdrawal, while the payout drains a pool holding one
UTXO per bet and needs MAX_PAYOUT_TX_INPUTS to be able to pay a full round at
all."""
all.
B-98 (BET-24): with `min_change_sats` selection goes on until the total covers
`target + min_change_sats`, so a second coin is taken when the first covers the
target exactly. Only when every allowed coin together cannot leave that change is
the shorter selection returned (any total >= target), and the caller then refuses
the bet or reduces the withdrawal as before."""
# B-75: a UTXO below the dust limit is never offered, even if one is already on
# record: it costs more to spend than it is worth.
ordered = sorted(
@@ -149,16 +157,18 @@ def select_utxos(
selected: list[Utxo] = []
total = 0
for utxo in ordered:
if len(selected) == max_inputs:
raise InsufficientFundsError(
f"balance too fragmented: more than {max_inputs} inputs would be needed",
code="too_many_inputs",
max_inputs=max_inputs,
)
if total >= target_sats + min_change_sats or len(selected) == max_inputs:
break
selected.append(utxo)
total += utxo.amount_sats
if total >= target_sats:
return selected, total
if total >= target_sats:
return selected, total
if len(selected) == max_inputs and len(ordered) > max_inputs:
raise InsufficientFundsError(
f"balance too fragmented: more than {max_inputs} inputs would be needed",
code="too_many_inputs",
max_inputs=max_inputs,
)
raise InsufficientFundsError("not enough confirmed balance to cover amount")
@@ -202,7 +212,7 @@ def build_signed_transaction(
never exactly equal the bet" is a documented invariant of the PLAY phase. The
player needs a little more than the bet amount, which is what the error says.
"""
selected, total_in = select_utxos(utxos, amount_sats)
selected, total_in = select_utxos(utxos, amount_sats, min_change_sats=DUST_LIMIT_SATS)
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
change = total_in - amount_sats
+125
View File
@@ -0,0 +1,125 @@
"""B-89 (SEC-49, SEC-50): is the master key the one that produced what the database holds?
A different xprv, or a wrong encryption key, used to surface only at the first
registration or payout — and a different xprv never did: it quietly moved the pool
address. The check compares a fixed sample of stored addresses with what the key derives,
reading the database with plain `sqlite3` (read-only), so the startup check and the
key scripts share it without an event loop."""
import hashlib
import logging
import sqlite3
from typing import NamedTuple
from cryptography.fernet import Fernet
from embit import script
from embit.bip32 import HDKey
from app.config import ConfigError, settings
from app.wallet.plm_network import ACCOUNT_PATH, PLM_MAINNET
logger = logging.getLogger(__name__)
_SAMPLE_SIZE = 10
class Sample(NamedTuple):
users: list[tuple[int, str]] # (derivation_index, address)
rounds: list[tuple[int, str]] # (id, pool_address) of the last rounds with one
seeds: list[tuple[int, str, str]] # (id, server_seed_enc, server_seed_hash), unrevealed
def database_file(url: str | None = None) -> str | None:
"""The SQLite file `DATABASE_URL` points at, or None for anything else."""
url = url or settings.database_url
prefix = "sqlite+aiosqlite:///"
return url[len(prefix):] if url.startswith(prefix) else None
def load_sample(path: str | None = None) -> Sample:
"""The lowest and highest `_SAMPLE_SIZE` users, the last rounds that have a pool
address and every round whose secret is still unrevealed. A missing file or missing
tables (a fresh instance) is an empty sample: there is nothing to compare."""
path = path or database_file()
empty = Sample([], [], [])
if path is None:
return empty
try:
db = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
except sqlite3.Error:
return empty
try:
users = {
index: address
for order in ("ASC", "DESC")
for index, address in db.execute(
f"SELECT derivation_index, address FROM users ORDER BY derivation_index {order} LIMIT ?",
(_SAMPLE_SIZE,),
)
}
rounds = db.execute(
"SELECT id, pool_address FROM rounds WHERE pool_address != '' ORDER BY id DESC LIMIT ?",
(_SAMPLE_SIZE,),
).fetchall()
seeds = db.execute(
"SELECT id, server_seed_enc, server_seed_hash FROM rounds "
"WHERE server_seed_enc IS NOT NULL AND server_seed IS NULL"
).fetchall()
return Sample(sorted(users.items()), rounds, seeds)
except sqlite3.OperationalError: # no such table
return empty
finally:
db.close()
def account_key(xprv_base58: str) -> HDKey:
return HDKey.from_base58(xprv_base58).derive(ACCOUNT_PATH)
def _address(account: HDKey, path: str) -> str:
return script.p2wpkh(account.derive(path).to_public()).address(network=PLM_MAINNET)
def sample_problems(account: HDKey, sample: Sample, encryption_key: str | None = None) -> list[str]:
"""One line per check that failed; never any key material. With `encryption_key`
the unrevealed round secrets are checked as well (they depend on it, not on the xprv)."""
problems = []
for index, stored in sample.users:
if _address(account, f"0/{index}") != stored:
problems.append(f"user address at derivation index {index} differs from the one derived")
if sample.rounds:
reserve = _address(account, "1/0")
for round_id, stored in sample.rounds:
if stored not in (reserve, _address(account, f"2/{round_id}")):
problems.append(f"pool address of round {round_id} is neither its own nor the reserve address")
if encryption_key is not None:
fernet = Fernet(encryption_key)
for round_id, token, committed in sample.seeds:
try:
secret = fernet.decrypt(token.encode()).decode()
except Exception:
problems.append(f"secret of round {round_id} does not decrypt")
continue
if hashlib.sha256(bytes.fromhex(secret)).hexdigest() != committed:
problems.append(f"secret of round {round_id} does not match its published hash")
return problems
def verify_master_key() -> None:
"""Called from the lifespan, before any task starts: decrypt the key and compare the
sample. Raises `ConfigError` (the process exits) on the first sign of a mismatch."""
from app.wallet import hd
try:
account = hd._load_account_key()
except Exception as exc:
raise ConfigError(
f"the master key at {settings.master_key_path} cannot be loaded ({type(exc).__name__}): "
"check MASTER_KEY_PATH and XPRV_ENCRYPTION_KEY_FILE"
) from exc
problems = sample_problems(account, load_sample(), settings.xprv_encryption_key)
if problems:
raise ConfigError(
"the master key does not match the database — refusing to serve: " + "; ".join(problems)
)
logger.info("Master key verified against the database sample")
+14 -1
View File
@@ -108,7 +108,10 @@ async def request_withdrawal(
# "insufficient". Distinguish "you don't have the money" from "your money
# hasn't confirmed yet" so the error doesn't contradict what the user is
# looking at on screen.
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user)
# B-82 (DEP-13): the displayed balance includes deposits still short of their
# threshold, so a withdrawal they would cover gets "wait for the confirmation".
waiting_sats = network.pending_deposit_sats(user.id) if hasattr(network, "pending_deposit_sats") else 0
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
if has_pending and pending_inclusive_sats >= amount_sats:
raise WithdrawalError(
"balance_pending_confirmation",
@@ -141,6 +144,16 @@ async def request_withdrawal(
except InsufficientFundsError as exc:
raise WithdrawalError(exc.code, str(exc), **exc.params) from exc
# B-96 (WDR-15): the reduction above can take a full-balance withdrawal a dust limit under
# the minimum that was checked on the requested amount.
moved_sats = built.recipient_sats + built.fee_sats
if moved_sats < config.bet_amount_sats:
raise WithdrawalError(
"amount_below_minimum",
f"amount below the minimum of {config.bet_amount_sats} sats",
minimum_sats=config.bet_amount_sats,
)
# Persist the intent before broadcasting, and only promote the rows once the
# network has accepted the tx — same two-phase shape as place_bet (B-08).
spent_by_key = {(u.txid, u.vout): u for u in unspent}
+51 -1
View File
@@ -1,20 +1,54 @@
services:
# B-91 (SEC-51): one-shot, as root, so the bind-mounted ./data belongs to the non-root
# user the app runs as (uid 1000) and is closed to everyone else — also the first time
# after an older root-owned install, with no sudo on the host.
data-permissions:
build: .
image: plm-lottery-app:local
user: "0:0"
entrypoint: ["/bin/sh", "-c", "chown -R 1000:1000 /data && chmod -R go-rwx /data"]
volumes:
- ./data:/data
restart: "no"
app:
build: .
image: plm-lottery-app:local
restart: unless-stopped
env_file: .env
environment:
- DATABASE_URL=sqlite+aiosqlite:////app/db_data/plm_lottery.db
- MASTER_KEY_PATH=/app/key_data/master.xprv.enc
- XPRV_ENCRYPTION_KEY_FILE=/run/secrets/xprv_encryption_key
# B-92 (ARC-02): uvicorn trusts X-Forwarded-For from Caddy's fixed address only, so the access
# log records the client's IP instead of Caddy's.
- FORWARDED_ALLOW_IPS=172.28.0.10
secrets:
- xprv_encryption_key
volumes:
- ./data/db:/app/db_data
- ./data/keys:/app/key_data
- ./data/logs:/app/logs
expose:
- "8123"
# B-92 (ARC-61): ready = Electrum connected, a recent header, a live scheduler. Docker does not
# restart an unhealthy container (the process exiting does); the state is for monitoring.
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request as u; u.urlopen('http://127.0.0.1:8123/health/ready', timeout=4)"
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
depends_on:
data-permissions:
condition: service_completed_successfully
caddy:
image: caddy:2-alpine
image: caddy:2-alpine@sha256:d8542f48d34a9cf4e4c11a478865229840e87e4c96ea3f439101f31a5d35f75f
restart: unless-stopped
ports:
- "80:80"
@@ -25,9 +59,25 @@ services:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
default:
ipv4_address: 172.28.0.10
depends_on:
- app
# A fixed subnet and a fixed address for Caddy, so uvicorn can trust exactly that one proxy (ARC-02).
networks:
default:
ipam:
config:
- subnet: 172.28.0.0/24
# The Fernet key that protects the master key lives in its own file, outside ./data (a backup of
# the data does not carry both) and in no environment variable (SEC-53).
secrets:
xprv_encryption_key:
file: ./secrets/xprv_encryption.key
volumes:
caddy_data:
caddy_config:
@@ -1,6 +1,6 @@
# ADR-01 — La piattaforma è custodiale: tutte le chiavi sono generate e tenute dal server
- **Stato:** Accettata — implementazione da completare (B-91)
- **Stato:** Accettata
- **Area:** Custodia, chiavi e indirizzi
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -23,5 +23,3 @@ git. L'utente non controlla nulla finché non preleva.
*Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv` · `app/wallet/hd.py:generate_master_key` ·
*Test:* `tests/unit/test_hd.py`
*Stato:* da implementare (B-91) · *Oggi:* la chiave di cifratura è la
variabile d'ambiente `XPRV_ENCRYPTION_KEY`, visibile con `docker inspect`.
@@ -1,6 +1,6 @@
# ADR-06 — I depositi richiedono `deposit_confirmations` conferme (6 per default, configurabile in `round_config`), le uscite coinbase 120; ogni altra transazione ne richiede 1
- **Stato:** Accettata — implementazione da completare (B-82)
- **Stato:** Accettata
- **Area:** Conferme e fiducia nella rete
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -34,7 +34,5 @@ decisione esplicita.
è contenuto da [ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md) e accettato in [LIM-24]. Regole in
[registrazione-e-depositi.md](../registrazione-e-depositi.md#deposito) (B-82).
*Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates` ·
*Test:* `tests/unit/test_confirmation.py::test_unconfirmed_tx_is_left_pending`, `tests/unit/test_deposits.py::test_unconfirmed_entry_is_ignored`
*Stato:* da implementare (B-82) · *Oggi:* ogni moneta, deposito o coinbase
compresi, si accredita a 1 conferma.
*Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates` · `app/deposits/service.py:classify_new_deposits` ·
*Test:* `tests/unit/test_confirmation.py::test_unconfirmed_tx_is_left_pending`, `tests/unit/test_deposits.py::test_unconfirmed_entry_is_ignored`, `tests/unit/test_deposit_confirmations.py::test_a_deposit_is_credited_exactly_at_six_confirmations`, `tests/unit/test_deposit_confirmations.py::test_an_immature_coinbase_is_not_credited_until_it_has_120_confirmations`, `tests/unit/test_deposit_confirmations.py::test_an_output_of_a_platform_transaction_is_credited_at_one_confirmation`
@@ -1,6 +1,6 @@
# ADR-10 — I parametri del round sono fotografati all'apertura
- **Stato:** Accettata — implementazione da completare (B-95)
- **Stato:** Accettata
- **Area:** Configurazione e ambiente
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -8,7 +8,7 @@
**I parametri del round sono fotografati all'apertura.**
Durata, pausa successiva, costo della scommessa (`bet_amount_sats`),
`fee_address` e `draw_confirmations` vengono copiati sulla riga del round
`fee_address`, `draw_confirmations` e tetto al montepremi (`max_round_pool_sats`) vengono copiati sulla riga del round
quando si apre; una modifica vale dal round successivo, mai per quello in
corso. La pausa fra due round è quella annunciata dal round appena chiuso.
Il fee rate resta letto dal vivo: riguarda la singola transazione, non le
@@ -22,6 +22,4 @@ regole del round.
[ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#0--apertura) (B-95).
*Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:round_deadline` ·
*Test:* `tests/unit/test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing`, `tests/unit/test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`, `tests/unit/test_rounds_service.py::test_cooldown_comes_from_the_round_that_closed`
*Stato:* da implementare (B-95) · *Oggi:* sono fotografati solo durata e
pausa; costo, `fee_address` e fee rate sono letti dal vivo ([LIM-16]).
*Test:* `tests/unit/test_round_snapshot.py::test_a_round_copies_cost_fee_address_and_cap_when_it_opens`, `tests/unit/test_round_snapshot.py::test_an_edit_from_admin_never_reaches_the_round_in_progress_but_the_next_one`, `tests/unit/test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing`, `tests/unit/test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`, `tests/unit/test_rounds_service.py::test_cooldown_comes_from_the_round_that_closed`
@@ -1,6 +1,6 @@
# ADR-11 — Il server rifiuta di partire con segreti inadeguati
- **Stato:** Accettata — implementazione da completare (B-91)
- **Stato:** Accettata
- **Area:** Configurazione e ambiente
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -24,8 +24,6 @@ chiuso a tutti. Il controllo è nel lifespan, non in un validatore di
*Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_config.py`
*Stato:* da implementare (B-91) per la parte sulla chiave · *Oggi:* la
chiave Fernet arriva da `XPRV_ENCRYPTION_KEY` (variabile d'ambiente); dopo
B-91 arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
La chiave Fernet arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
[sicurezza.md](../sicurezza.md#sec-53) SEC-53) e il rifiuto riguarda un file
mancante o vuoto.
@@ -11,8 +11,7 @@ probabilità uguale per tutti** indipendentemente dall'importo effettivo.
Nessun deposito minimo.
- *Conseguenze:* l'estrazione sceglie fra partecipanti, non pesa gli
importi. Il costo è fotografato all'apertura del round ([ADR-10](ADR-10-parametri-round-fotografati-all-apertura.md)), così
tutti i partecipanti di un round pagano la stessa posta (B-95; oggi è letto
dal vivo, [LIM-16]).
tutti i partecipanti di un round pagano la stessa posta (B-95).
*Codice:* `app/bets/service.py:place_bet` · `app/rounds/draw.py:draw_winner` ·
*Test:* `tests/unit/test_bets.py::test_place_bet_rejects_second_bet_same_round`
@@ -1,6 +1,6 @@
# ADR-19 — Ogni transazione utente conserva un'uscita di resto verso l'utente, e un'accelerazione che porterebbe quel resto sotto `DUST_LIMIT_SATS` (294 sat) lo elimina
- **Stato:** Accettata — implementazione da completare (B-87)
- **Stato:** Accettata
- **Area:** Economia del gioco
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -31,7 +31,4 @@ e di [TX-11](../transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-
utente: la sua uscita riducibile è la riserva di [ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md).
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused`
*Stato:* da implementare (B-87) · *Oggi:* `bump_fee` richiede solo che il
resto superi l'aumento, e può lasciare un resto sotto soglia che nessun nodo
inoltra.
*Test:* `tests/unit/test_rbf_dust.py::test_a_minimum_change_is_dropped_into_the_fee_by_the_first_bump`, `tests/unit/test_rbf_dust.py::test_the_dust_boundary_of_what_a_bump_leaves`, `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused`
@@ -1,6 +1,6 @@
# ADR-41 — Limitazione dei tentativi solo su login e registrazione
- **Stato:** Accettata — implementazione da completare (B-94)
- **Stato:** Accettata
- **Area:** Account, amministrazione e sicurezza
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -17,12 +17,10 @@ account creati davvero. I contatori sono limitati in numero.
limita la produzione.
- *Conseguenze:* nessun limite su scommesse, prelievi, admin ([LIM-07]).
Il cambio password (limitato e registrato come `password_changed`) e le
segnalazioni di bug (limitate per IP) si aggiungono con B-94; ogni campo
segnalazioni di bug (limitate per IP) sono limitati (B-94); ogni campo
delle credenziali ha una lunghezza massima di 256 caratteri
([sicurezza.md](../sicurezza.md#limiti-di-frequenza), B-94).
*Codice:* `app/auth/rate_limit.py:AuthRateLimiters` · `app/auth/rate_limit.py:RateLimiter` · `app/auth/rate_limit.py:RollingQuota` · `app/auth/routes.py:login` · `app/api/routes/users.py:change_password` · `app/api/routes/bug_reports.py:create_bug_report` ·
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
*Stato:* da implementare (B-94) · *Oggi:* cambio password e segnalazioni di
bug non hanno limiti né lunghezze massime, e il cambio password non scrive
audit.
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_abuse_limits.py`
@@ -1,6 +1,6 @@
# ADR-50 — Frontend statici, senza framework né build
- **Stato:** Accettata — implementazione da completare (B-94)
- **Stato:** Accettata
- **Area:** Interfacce, API e lingue
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -15,6 +15,3 @@ aggiornamento non lascia una pagina nuova con uno script vecchio;
[architettura.md](../architettura.md#allimport), B-94).
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` · `app/static/app.js` · `app/static/admin.js`
*Stato:* da implementare (B-94) · *Oggi:* solo le quattro pagine HTML hanno
`Cache-Control` (`no-store`); JS e CSS, serviti da `StaticFiles`, non ne
hanno.
@@ -1,6 +1,6 @@
# ADR-52 — Le riorganizzazioni della catena si contengono invece di ignorarle
- **Stato:** Accettata — implementazione da completare (B-82)
- **Stato:** Accettata
- **Area:** Conferme e fiducia nella rete
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -43,6 +43,4 @@ e [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento)
([LIM-24]).
*Codice:* `app/electrum/listener.py:ElectrumListener._record_reorg` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/rounds/service.py:open_new_round_if_needed` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-82) · *Oggi:* il pagamento non verifica le monete
del round: le prime due misure (rilevare, fermare) sono attive.
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_pool_verification.py`
+35 -74
View File
@@ -50,9 +50,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `GET` | `/admin/pending-transactions` | admin | [API-38](#get-adminpending-transactions) |
| `GET` | `/admin/bug-reports` | admin | [API-39](#get-adminbug-reports) |
| `POST` | `/admin/bug-reports/{report_id}/status` | admin | [API-40](#post-adminbug-reportsreport_idstatus) |
| `GET` | `/admin/electrum` | admin | [API-47](#get-adminelectrum) — *da implementare (B-86, B-92)* |
| `GET` | `/admin/electrum` | admin | [API-47](#get-adminelectrum) |
| `GET` | `/health` | nessuna | [API-41](#get-health) |
| `GET` | `/health/ready` | nessuna | [API-46](#get-healthready) — *da implementare (B-92)* |
| `GET` | `/health/ready` | nessuna | [API-46](#get-healthready) |
| `GET` | `/`, `/admin`, `/guida`, `/report-bug` | nessuna | [API-42](#pagine-e-file-statici) |
| `GET` | qualunque altro percorso (file statici) | nessuna | [API-43](#pagine-e-file-statici) |
@@ -117,9 +117,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
modo: torna alla schermata di login.
*Codice:* `app/auth/dependencies.py:get_current_user` ·
*Test:* `tests/unit/test_users.py`, `tests/unit/test_bug_reports.py`
*Stato:* da implementare (B-94), per l'header assente · *Oggi:* l'assenza
dell'header è intercettata da FastAPI prima del codice applicativo e
risponde con il corpo non strutturato `{"detail": "Not authenticated"}`.
- **[API-07]** Autenticazione **facoltativa**: senza header, con un header che
non inizia con `Bearer `, con un token invalido o superato, la richiesta è
@@ -137,8 +134,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
(vedi [sicurezza.md](sicurezza.md)).
*Codice:* `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-94) · *Oggi:* `403` con il corpo non
strutturato `{"detail": "invalid admin token"}`.
- **[API-09]** Intestazioni di cache:
- le quattro pagine (`/`, `/admin`, `/guida`, `/report-bug`) rispondono con
@@ -156,7 +151,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`app/main.py:user_guide`, `app/main.py:report_bug_page`,
`app/api/routes/rounds.py:round_stream`, `app/api/routes/qr.py:address_qr`,
`app/main.py` (`app.mount`)
*Stato:* da implementare (B-94), per i file statici · *Oggi:* i file
serviti dal mount non hanno `Cache-Control`, solo `ETag`/`Last-Modified`,
e il browser può riusarli senza rivalidarli.
@@ -169,8 +163,8 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*Test:* `tests/unit/test_docs_disabled.py`
- **[API-11]** Dove un controllo dipende dall'indirizzo IP del chiamante (limiti
di login e registrazione, tetto per IP del flusso SSE e, da implementare con
B-94, quota per IP di `POST /bug-reports`), l'IP è l'**ultimo**
di login e registrazione, tetto per IP del flusso SSE e quota per IP di
`POST /bug-reports`), l'IP è l'**ultimo**
elemento di `X-Forwarded-For` (quello scritto dal proxy fidato), o in sua
assenza l'indirizzo della connessione, o `"unknown"`. Il motivo e il ruolo di
Caddy sono in [sicurezza.md](sicurezza.md).
@@ -238,11 +232,10 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
con `type: "json_invalid"`). Il valore ricevuto (`input`) **non** è
ripetuto nella risposta, così una password troppo lunga non torna indietro
in chiaro.
*Codice:* `app/main.py` (gestore di `RequestValidationError`, da scrivere),
*Codice:* `app/main.py` (gestore di `RequestValidationError`),
`app/static/app.js:apiErrorMessage` ·
*Test:* `tests/unit/test_users.py`, `tests/unit/test_admin.py`,
`tests/unit/test_bug_reports.py`
*Stato:* da implementare (B-94) · *Oggi:* `422` nel formato **nativo** di
FastAPI: `detail` è una **lista** di oggetti `{type, loc, msg, input, ctx?}`,
senza `code`; il frontend riconosce la lista e mostra il messaggio generico
tradotto `error.invalid_request`.
@@ -269,9 +262,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`app/api/routes/admin.py:update_bug_report_status`,
`app/api/routes/rounds.py:round_stream`, `app/main.py` ·
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py`
*Stato:* da implementare (B-94) · *Oggi:* tutte le risposte della tabella
hanno `detail` come stringa inglese (colonna "Oggi"), e il frontend la
mostra così com'è.
- **[API-17]** Il client traduce `code` con la chiave `error.<code>` di
`app/static/i18n.js`, passando `params` all'interpolazione e aggiungendo per
@@ -305,12 +295,12 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
usato, **senza distinzione tra maiuscole e minuscole**, anche se
l'altro account si è registrato nello stesso istante);
`409 derivation_index_conflict` (dopo 5 tentativi non è stato possibile
assegnare un indice di derivazione libero); `503 registration_suspended`
assegnare un indice di derivazione libero); `503 registration_unavailable`
(all'avvio sono stati trovati movimenti sulla catena per indici di
derivazione successivi all'ultimo assegnato, cioè il database potrebbe
essere un backup più vecchio della realtà; la registrazione resta
sospesa finché l'operatore non riconcilia,
[operativita.md](operativita.md) B-90 — *da implementare*).
[operativita.md](operativita.md) B-90).
- **Limite di frequenza:** al massimo 5 account creati per IP in una finestra
mobile di un'ora; la quota è verificata **prima** dell'hash Argon2 e
consumata solo da una registrazione riuscita (un nome già preso o un
@@ -324,8 +314,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
[registrazione-e-depositi.md](registrazione-e-depositi.md).
*Codice:* `app/auth/routes.py:register`, `app/auth/routes.py:RegisterRequest` ·
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_users.py`
*Stato:* da implementare (B-92, B-90) · *Oggi:* non scrive audit, e non
esiste la sospensione `registration_suspended`.
#### POST /auth/login
@@ -335,7 +323,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
massimo 256 caratteri (oltre: `422 invalid_request`, prima di qualunque
hash Argon2 e senza consumare i contatori di fallimento). Lo username è
confrontato senza distinzione tra maiuscole e minuscole.
*Stato:* da implementare (B-94) · *Oggi:* nessun vincolo di lunghezza.
- **Risposta `200`:** `{"access_token": <JWT>, "address": <indirizzo>}`.
- **Errori:** `422`; `429 rate_limited` (`params.retry_after_seconds`,
arrotondato per eccesso al secondo); `401 invalid_credentials` —
@@ -400,9 +387,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*Codice:* `app/api/routes/users.py:change_password`,
`app/auth/rate_limit.py:RateLimiter` ·
*Test:* `tests/unit/test_users.py`
*Stato:* da implementare (B-94) · *Oggi:* nessuna lunghezza massima, nessun
limite di frequenza (la password attuale si può provare senza limiti) e
nessuna voce di audit.
#### GET /users/me/last-round-result
@@ -467,10 +451,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
casi (successo, rifiuto, esito ambiguo) e all'apertura del round.
*Codice:* `app/api/routes/bets.py:create_bet`,
`app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`
*Stato:* da implementare (B-95, B-96) · *Oggi:* un costo coperto solo da resto non confermato
risponde `insufficient_balance`; non c'è tetto al montepremi; il costo è
quello configurato al momento della scommessa, non quello del round.
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py`
#### POST /withdrawals
@@ -516,8 +497,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`,
`app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
*Stato:* da implementare (B-96) · *Oggi:* l'importo ridotto di un prelievo
dell'intero saldo non è riconfrontato con il minimo.
### Round
@@ -537,7 +516,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `opened_at` | stringa ISO o `null` | apertura del round |
| `closes_at` | stringa ISO o `null` | scadenza per le scommesse: `opened_at` + la durata **fissata all'apertura del round** (non quella attuale di configurazione) |
| `participant_count` | intero | partecipanti **confermati**, cioè quelli tra cui si estrae |
| `bet_amount_sats` | intero | costo che pagherebbe una nuova scommessa: con un round attivo il costo **fissato all'apertura del round** (`rounds.bet_amount_sats`), senza round attivo quello configurato — *da implementare (B-95); oggi sempre quello configurato* |
| `bet_amount_sats` | intero | costo che pagherebbe una nuova scommessa: con un round attivo il costo **fissato all'apertura del round** (`rounds.bet_amount_sats`), senza round attivo quello configurato |
| `jackpot_sats` | intero | 70% (arrotondato per difetto) della somma degli importi effettivi delle scommesse confermate; limite superiore, perché la commissione del pagamento è ancora da sottrarre |
| `pending_participant_count` | intero | tutti i partecipanti, confermati **e** in volo (totale, non differenza) |
| `pending_jackpot_sats` | intero | 70% della somma di tutte le scommesse, confermate e in volo |
@@ -548,6 +527,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `draw_block_height` | intero o `null` | altezza del blocco usato per l'estrazione |
| `draw_block_hash` | stringa o `null` | hash di quel blocco; è **uno** degli ingressi del seme, non il seme (vedi [API-45](#get-roundsround_idproof)) |
| `draw_waiting_since` | stringa ISO o `null` | inizio dell'attesa del blocco; valorizzato **solo** mentre `status` è `drawing` |
| `closing_waiting_since` | stringa ISO o `null` | da quando il round attende in `closing` le scommesse in volo ([TX-29](transazioni-e-riconciliazione.md#riconciliazione)); valorizzato **solo** mentre `status` è `closing` |
| `server_seed_hash` | stringa o `null` | impegno del server sul segreto del round, pubblicato dall'apertura; `null` per un round aperto prima di B-73 |
| `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` |
| `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` |
@@ -577,8 +557,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`app/rounds/service.py:round_deadline`, `app/rounds/service.py:winner_share`,
`app/rounds/service.py:rounds_can_open` ·
*Test:* `tests/unit/test_rounds_route.py`
*Stato:* da implementare (B-82, B-95), per i campi marcati · *Oggi:*
`bet_amount_sats` è sempre il costo configurato.
#### GET /rounds/stream
@@ -613,7 +591,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`app/rounds/events.py:RoundEventBroadcaster.subscribe`,
`app/rounds/events.py:RoundEventBroadcaster.publish` ·
*Test:* `tests/unit/test_round_events.py`
*Stato:* da implementare (B-94, B-93) · *Oggi:* il `503` ha il corpo non
*Stato:* da implementare (B-93), per la chiusura dei flussi all'arresto
strutturato `{"detail": "too many concurrent update streams"}`, e un flusso
aperto non viene chiuso all'arresto: uvicorn lo attende finché Docker non
termina il processo.
@@ -724,8 +702,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`app/api/routes/bug_reports.py:BugReportCreate`,
`app/auth/rate_limit.py:RollingQuota` ·
*Test:* `tests/unit/test_bug_reports.py`
*Stato:* da implementare (B-94) · *Oggi:* nessun limite di frequenza: un
anonimo può inserire segnalazioni senza limite.
#### GET /bug-reports/mine
@@ -760,8 +736,6 @@ dei parametri operativi in
"draw_confirmations", "max_round_pool_sats", "paused", "paused_reason"}`.
*Codice:* `app/api/routes/admin.py:read_config` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82, B-95) · *Oggi:* mancano
`deposit_confirmations` e `max_round_pool_sats`.
#### PUT /admin/config
@@ -775,12 +749,12 @@ dei parametri operativi in
| `bet_amount_sats` | > 0 e ≤ 10 000 000 000 000 (100 000 PLM) |
| `round_duration_seconds` | 30 – 604 800 (7 giorni) |
| `round_cooldown_seconds` | 0 – 86 400 |
| `fee_rate_sat_vb` | 1 – 10 000 (`MAX_FEE_RATE_SAT_VB`) |
| `fee_rate_sat_vb` | 1 – 5 000 (`MAX_FEE_RATE_SAT_VB`) |
| `rbf_timeout_seconds` | 60 – 604 800 |
| `draw_animation_seconds` | 0 – 600 |
| `deposit_confirmations` | 1 – 100 — *da implementare (B-82)* |
| `deposit_confirmations` | 1 – 100 |
| `draw_confirmations` | 1 – 20 |
| `max_round_pool_sats` | `0` (disattivato), oppure da `bet_amount_sats` a 4 000 000 000 000 000 — *da implementare (B-95)* |
| `max_round_pool_sats` | `0` (disattivato), oppure da `bet_amount_sats` a 4 000 000 000 000 000 |
I limiti sono quelli di [modello-dati.md](modello-dati.md) (tabella
`round_config`), che ne è il proprietario. `paused` e `paused_reason`
@@ -794,18 +768,18 @@ dei parametri operativi in
dà `422` con `detail = {"code": "config_not_payable", "message", "params":
{"reason"}}`, dove `reason` è `winner_share_below_network_fee` o
`commission_below_dust_limit`; il controllo usa i valori risultanti dopo
la modifica.
la modifica e, se cambia `fee_rate_sat_vb`, anche il costo con cui si è
aperto il round in corso. Un tetto `max_round_pool_sats` diverso da 0 e
minore del costo della scommessa (dopo la modifica) dà `422` con
`detail.code = "max_round_pool_below_bet_amount"`.
- **Effetti:** scrive `config_updated` con i valori prima/dopo dei soli
campi davvero cambiati; nessuna scrittura se nulla cambia. Durata, pausa
tra round, costo della scommessa, `fee_address` e `draw_confirmations` si
applicano dal round successivo (per costo e `fee_address`: *da
implementare*, B-95; oggi subito). Nessuna notifica SSE.
applicano dal round successivo, come il tetto `max_round_pool_sats`. Nessuna
notifica SSE.
*Codice:* `app/api/routes/admin.py:update_config`,
`app/api/routes/admin.py:RoundConfigUpdate` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82, B-95), per i due campi nuovi e la copia
all'apertura di costo ed `fee_address` · *Oggi:* sono accettati i primi
otto campi.
#### POST /admin/pause e POST /admin/resume
@@ -834,9 +808,6 @@ dei parametri operativi in
*Codice:* `app/api/routes/admin.py:pause_lottery`,
`app/api/routes/admin.py:resume_lottery` ·
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_pause_reason.py`
*Stato:* da implementare (B-82), per l'azzeramento di `payout_blocked_at` ·
*Oggi:* `rounds.payout_blocked_at` non esiste ancora; le pause automatiche
esistenti sono solo `resurrected_tx`.
#### GET /admin/users
@@ -850,8 +821,6 @@ dei parametri operativi in
"balance_sats", "created_at"}`; `balance_sats` è il saldo confermato.
*Codice:* `app/api/routes/admin.py:list_users` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-94) · *Oggi:* restituisce **tutti** gli utenti
in una sola risposta, senza paginazione né limite.
#### GET /admin/users/{user_id}/privkey
@@ -893,8 +862,7 @@ dei parametri operativi in
completa di un round è in [API-45](#get-roundsround_idproof).
*Codice:* `app/api/routes/admin.py:list_rounds` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82), per `payout_blocked_at` · *Oggi:*
la lista si ferma a `bump_reserve_sats`.
#### GET /admin/audit-log
@@ -917,10 +885,7 @@ dei parametri operativi in
- **Risposta `200`:** lista di `{"id", "event_type", "payload" (oggetto),
"user_id", "round_id", "created_at"}`, per `id` decrescente.
*Codice:* `app/api/routes/admin.py:list_audit_log` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-92), per filtri e paginazione · *Oggi:* accetta
solo `limit`, quindi oltre le ultime 500 voci il registro non è
consultabile dall'API.
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_observability.py`
#### GET /admin/pending-transactions
@@ -949,9 +914,7 @@ dei parametri operativi in
`last_rotation` (motivo e ora dell'ultimo cambio di server, ad esempio punta
ferma, [connessione-electrum.md](connessione-electrum.md) ELC-21).
*Codice:* `app/api/routes/admin.py`, `app/electrum/listener.py:ElectrumListener.current_endpoint` ·
*Test:* —
*Stato:* da implementare (B-86, B-92) · *Oggi:* l'endpoint non esiste;
`/admin` mostra solo l'altezza della catena letta da `GET /rounds/current`.
*Test:* `tests/unit/test_observability.py`
#### GET /admin/bug-reports
@@ -995,7 +958,7 @@ dei parametri operativi in
**tutte** queste condizioni sono vere, altrimenti `503` con lo stesso corpo e
`"status": "not_ready"`:
- la connessione Electrum è attiva (`listener.client` non nullo);
- l'ultimo header è stato accettato da meno di 360 secondi
- l'ultimo header è stato accettato da meno di 1800 secondi
([connessione-electrum.md](connessione-electrum.md) ELC-29);
- lo scheduler dei round ha battuto da meno di 30 secondi (il battito si
aggiorna a ogni giro e a ogni iterazione dell'attesa del blocco di
@@ -1006,9 +969,7 @@ dei parametri operativi in
`scheduler_heartbeat_age_seconds` (intero o `null`). Nessuna autenticazione,
nessun dato sensibile: non espone né il server attivo né l'altezza. È il
`healthcheck` del container.
*Codice:* `app/main.py:health` · *Test:* —
*Stato:* da implementare (B-92) · *Oggi:* l'endpoint non esiste; esiste solo
`GET /health` ([API-41]).
*Codice:* `app/main.py:health` · *Test:* `tests/unit/test_observability.py`
#### Pagine e file statici
@@ -1039,7 +1000,7 @@ dei parametri operativi in
|---|---|---|---|---|---|
| `session_expired` | 401 | `app/auth/dependencies.py:get_current_user` | — | token illeggibile, scaduto, utente inesistente o token superato da un cambio/reset password | sì |
| `invalid_credentials` | 401 | `app/auth/routes.py:login` | — | username inesistente o password sbagliata (risposta identica) | sì |
| `rate_limited` | 429 | `app/auth/routes.py:register`, `app/auth/routes.py:login`; anche `app/api/routes/users.py:change_password` e `app/api/routes/bug_reports.py` *da implementare (B-94)* | `retry_after_seconds` | quota di registrazione, blocco per tentativi falliti, o quota di segnalazioni | sì |
| `rate_limited` | 429 | `app/auth/routes.py:register`, `app/auth/routes.py:login`; anche `app/api/routes/users.py:change_password` e `app/api/routes/bug_reports.py` | `retry_after_seconds` | quota di registrazione, blocco per tentativi falliti, o quota di segnalazioni | sì |
| `username_taken` | 409 | `app/auth/routes.py:register` | — | nome già usato (senza distinzione di maiuscole) | sì |
| `derivation_index_conflict` | 409 | `app/auth/routes.py:register` | — | nessun indice di derivazione libero dopo 5 tentativi | sì |
| `current_password_incorrect` | 401 | `app/api/routes/users.py:change_password` | — | password attuale errata | sì |
@@ -1050,7 +1011,7 @@ dei parametri operativi in
| `already_betting` | 400 | `app/bets/service.py:place_bet` | — | l'utente ha già una riga di partecipazione nel round | sì |
| `round_full` | 400 | `app/bets/service.py:place_bet` | `max_participants` | il round ha già 400 partecipanti (in qualunque stato) | sì |
| `insufficient_balance` | 400 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/wallet/psbt_builder.py:select_utxos` | `required_sats` (assente se sollevato da `select_utxos`) | le monete confermate e non spese non coprono l'importo | sì |
| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal`; anche `app/bets/service.py:place_bet` *da implementare (B-96)* | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal`; anche `app/bets/service.py:place_bet` | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
| `too_many_inputs` | 400 | `app/wallet/psbt_builder.py:select_utxos` | `max_inputs` | servirebbero più di 50 monete in ingresso | sì |
| `balance_leaves_no_change` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` (solo scommesse) | `required_extra_sats` | dopo la scommessa il resto sarebbe sotto il limite di polvere (294 sat) | sì |
| `amount_below_network_fee` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` | — | l'importo non copre la commissione di rete | sì |
@@ -1062,15 +1023,15 @@ dei parametri operativi in
| `internal_error` | 500 | `app/main.py:log_unhandled_exception` | — | eccezione non gestita | sì |
| `withdrawal_to_platform_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni | sì |
| `service_busy` | 503 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/auth/routes.py:register` | — | il database non ha concesso il lock di scrittura entro il timeout; nulla è stato trasmesso, si può riprovare | sì |
| `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) — *da implementare* | no — da aggiungere |
| `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `bug_report_not_found` | 404 | `app/api/routes/admin.py` | — | segnalazione inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) | sì |
| `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente | sì |
| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato | sì |
| `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente | sì |
| `bug_report_not_found` | 404 | `app/api/routes/admin.py` | — | segnalazione inesistente | sì |
| `round_not_found` | 404 | `app/api/routes/rounds.py` (`GET /rounds/{id}/proof`) | — | round inesistente | sì |
| `not_found` | 404 | gestore delle eccezioni HTTP in `app/main.py` | — | percorso inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `method_not_allowed` | 405 | gestore delle eccezioni HTTP in `app/main.py` | — | metodo non ammesso — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `too_many_streams` | 503 | `app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
| `not_found` | 404 | gestore delle eccezioni HTTP in `app/main.py` | — | percorso inesistente | sì |
| `method_not_allowed` | 405 | gestore delle eccezioni HTTP in `app/main.py` | — | metodo non ammesso | sì |
| `too_many_streams` | 503 | `app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto | sì |
Due codici di `app/wallet/psbt_builder.py` — `winner_share_below_network_fee`
e `commission_below_dust_limit` — riguardano solo la costruzione del
+17 -41
View File
@@ -19,7 +19,7 @@ flowchart LR
C -- "HTTP app:8123<br/>(rete interna compose)" --> A["Container app<br/>1 processo uvicorn<br/>FastAPI + 6 task asyncio"]
A -- "aiosqlite" --> D[("./data/db/plm_lottery.db<br/>SQLite, WAL")]
A -- "legge" --> K["./data/keys/master.xprv.enc"]
A -- "scrive" --> L["./data/logs/app.log<br/>(e stdout, B-92)"]
A -- "scrive" --> L["./data/logs/app.log<br/>(e stdout)"]
A -- "Electrum (TCP/SSL)<br/>una connessione attiva" --> E["Server Electrum PLM<br/>primario + riserve"]
```
@@ -44,9 +44,8 @@ flowchart LR
stato in memoria descritto in
[Assunzioni di processo singolo](#assunzioni-di-processo-singolo) dipende da
questo.
*Codice:* `Dockerfile` (`CMD`) · `docker-compose.yml`
*Stato:* da implementare (B-92) · *Oggi:* uvicorn non legge gli header del
proxy e il log di accesso registra l'IP di Caddy.
*Codice:* `Dockerfile` (`CMD`) · `docker-compose.yml` ·
*Test:* `tests/unit/test_observability.py`
- **[ARC-03]** All'avvio il container `app`, nell'ordine: rifiuta di partire
se il file indicato da `MASTER_KEY_PATH` non esiste (stampando il comando
per generarlo), poi esegue `alembic upgrade head`, poi avvia uvicorn con
@@ -55,9 +54,8 @@ flowchart LR
installate da un file di lock con gli hash
([sicurezza.md](sicurezza.md#sec-51) SEC-51, SEC-52, SEC-53 (B-91)). I controlli che richiedono il codice
applicativo (chiave, quorum, ripristino) sono nel lifespan ([ARC-60]).
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py`
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root,
`python:3.12-slim` non è fissato per digest e le dipendenze sono `>=`.
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py` · `requirements.lock` ·
*Test:* `tests/unit/test_container_hardening.py`
- **[ARC-04]** `app` è solo `expose`d sulla rete interna di compose (porta
8123), mai pubblicato sull'host: l'unico ingresso è Caddy, che pubblica 80 e
443. Su questo si basa la lettura dell'IP del client (vedi
@@ -79,9 +77,9 @@ flowchart LR
rilascio, così un rollback sa quale immagine rimettere
([operativita.md](operativita.md) (B-93)).
*Codice:* `docker-compose.yml`
*Stato:* da implementare (B-91, B-93) · *Oggi:* la chiave di cifratura è la
variabile `XPRV_ENCRYPTION_KEY` in `.env`, i permessi sono quelli di default
(`.env` e file di chiave leggibili da tutti) e l'immagine non ha etichetta.
*Test:* `tests/unit/test_container_hardening.py`
*Stato:* da implementare (B-93), per l'etichetta per rilascio · *Oggi:*
l'immagine di `app` ha l'etichetta fissa `plm-lottery-app:local`.
- **[ARC-61]** **Salute del container.** Il container `app` ha un
`healthcheck` che interroga `http://127.0.0.1:8123/health/ready` ogni 30s
@@ -95,9 +93,7 @@ flowchart LR
([operativita.md](operativita.md#segnali-da-tenere-docchio)), e il riavvio
resta affidato a `restart: unless-stopped` quando il processo esce.
*Codice:* `docker-compose.yml` · `app/main.py:health` ·
*Test:* —
*Stato:* da implementare (B-92) · *Oggi:* nessun `healthcheck`; esiste solo
`/health`, che risponde sempre `ok` ([ARC-17]).
*Test:* `tests/unit/test_observability.py`
### Database
@@ -139,10 +135,8 @@ flowchart LR
`docker compose logs` li mostra senza entrare nel volume. La configurazione
avviene all'import di `app/main.py`, prima di qualunque altro modulo
applicativo.
*Codice:* `app/logging_config.py:setup_logging`
*Stato:* da implementare (B-92) · *Oggi:* i log dell'applicazione vanno solo
nel file; su console arrivano solo quelli di uvicorn, per la sua
configurazione predefinita.
*Codice:* `app/logging_config.py:setup_logging` ·
*Test:* `tests/unit/test_observability.py`
## Mappa del codice
@@ -343,8 +337,8 @@ sequenceDiagram
L->>L: validate_runtime_secrets()
L->>L: parse_endpoints(...)
L->>L: quorum minimo (validate_quorum_servers)
L->>L: verifica della master key (B-89, da implementare)
L->>L: START_PAUSED (B-90, da implementare)
L->>L: verifica della master key (B-89)
L->>L: START_PAUSED (B-90)
L->>L: app.state.electrum_listener, app.state.user_locks
L->>L: create_task ×6
Note over L: il server serve richieste<br/>(controllo dei buchi di indici alla prima connessione, B-90)
@@ -398,7 +392,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
`/app`.
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` ·
`app/main.py:user_guide` · `app/main.py:report_bug_page`
*Stato:* da implementare (B-94) · *Oggi:* `Cache-Control` c'è **solo** sulle
quattro pagine HTML; i file serviti da `StaticFiles` (e `/index.html`
chiesto per nome) non ne hanno, e il browser può usare uno script vecchio
con una pagina nuova.
@@ -415,8 +408,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
veri.
*Codice:* `app/config.py:validate_runtime_secrets` ·
*Test:* `tests/unit/test_config.py`
*Stato:* da implementare (B-91) · *Oggi:* si controlla che la variabile
`XPRV_ENCRYPTION_KEY` non sia vuota.
- **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST`,
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`
(`host:porta[:notls][:pin=<sha256>]` separati da virgola, [ARC-30]); un
@@ -448,9 +439,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
([operativita.md](operativita.md#ripristino), B-90).
*Codice:* `app/main.py:lifespan` · `app/config.py:validate_runtime_secrets` · `app/config.py:validate_quorum_servers` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-89, B-90), per i controlli 2 e 3 · *Oggi:* dopo
i segreti, gli endpoint e il quorum il lifespan crea subito i task; la
chiave si decifra alla prima registrazione o al primo pagamento.
- **[ARC-21]** Lo stato condiviso con le route vive su `app.state`:
`electrum_listener` (l'`ElectrumListener`) e `user_locks` (un
`UserLocks`), creati nel lifespan; `auth_rate_limiters` viene invece creato
@@ -601,9 +589,7 @@ La configurazione è divisa in due posti con regole diverse.
| `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) |
*Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py`
*Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e
`XPRV_ENCRYPTION_KEY_FILE` non esistono, la chiave Fernet è la variabile
`XPRV_ENCRYPTION_KEY`.
- **[ARC-31]** **Parametri operativi e di business** (costo della scommessa,
durata e pausa del round, animazione, commissione di rete, timeout RBF,
indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei
@@ -616,8 +602,6 @@ La configurazione è divisa in due posti con regole diverse.
effetti in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
*Codice:* `app/rounds/config.py:get_round_config` ·
`app/db/models.py:RoundConfig`
*Stato:* da implementare (B-82, B-95) · *Oggi:* `deposit_confirmations` e
`max_round_pool_sats` non esistono.
- **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili:
la ripartizione 70/30, le conferme richieste per le transazioni della
piattaforma (1) e per le uscite coinbase (120), i limiti di input e di
@@ -626,8 +610,6 @@ La configurazione è divisa in due posti con regole diverse.
dei depositi sono invece configurabili ([ARC-31]).
*Codice:* `app/rounds/service.py:winner_share` ·
`app/wallet/psbt_builder.py` · `app/tx/confirmation.py`
*Stato:* da implementare (B-82) · *Oggi:* 1 conferma per ogni moneta e
transazione, depositi e coinbase compresi.
## Aggiornamenti in tempo reale (SSE)
@@ -694,9 +676,8 @@ flowchart LR
`app/withdrawals/service.py:_release_failed_withdrawal`;
- transazione confermata — `app/tx/confirmation.py:poll_once`;
- riga promossa o abbandonata dal riconciliatore —
`app/tx/reconcile.py:reconcile_once`. Oggi il riconciliatore pubblica per
**ogni** riga che ricontrolla, anche quando non cambia nulla; la
pubblicazione solo sui cambiamenti è in B-96;
`app/tx/reconcile.py:reconcile_once`. Il riconciliatore pubblica solo
per le righe che ha cambiato ([TX-27](transazioni-e-riconciliazione.md#riconciliazione));
- deposito accreditato, UTXO segnata come spesa fuori piattaforma, UTXO
ripristinata — `app/deposits/service.py:credit_confirmed_utxos`,
`app/deposits/service.py:mark_utxos_spent_externally`,
@@ -704,8 +685,6 @@ flowchart LR
- nuovo header ricevuto — `app/electrum/listener.py:ElectrumListener._consume_headers`.
*Test:* `tests/unit/test_bets.py` (verifica la notifica di una scommessa)
*Stato:* da implementare (B-96) · *Oggi:* il riconciliatore pubblica una
notifica per ogni riga ricontrollata, anche senza cambiamenti.
## Lock per utente
@@ -787,13 +766,10 @@ flowchart LR
bloccato o caduto si aggiornano **solo** riaprendo la sezione. La sezione
Parametri non viene ricaricata dagli eventi. Il piano aggiunge al pannello
lo stato della connessione Electrum (endpoint, età dell'ultimo header) e i
filtri dell'audit log (B-86, B-92).
filtri dell'audit log (B-92).
*Codice:* `app/static/admin.js:startChainStatusPolling` ·
`app/static/admin.js:connectAdminEvents` ·
`app/static/admin.js:onAdminServerEvent`
*Stato:* da implementare (B-86, B-92), solo per lo stato Electrum e i
filtri dell'audit · *Oggi:* il pannello mostra solo l'altezza della catena,
e l'audit log le ultime righe senza filtri.
### `/report-bug` e `/guida`
+16 -54
View File
@@ -44,9 +44,6 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
`app/tx/broadcast.py:RbfBumper._tick`, `app/main.py:lifespan`,
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/electrum/listener.py:ElectrumListener._record_reorg`
*Stato:* da implementare (B-87, B-90, B-96), per tre degli eventi
nuovi · *Oggi:* gli eventi osservativi sono solo i primi quattro e
`chain_reorg_detected`.
- **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna
`payload_json`. `created_at` è impostato dall'applicazione, non dal
@@ -84,9 +81,6 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
[api.md](api.md#get-adminaudit-log) (API-37), che ne è il proprietario.
*Codice:* `app/api/routes/admin.py:list_audit_log` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-92), per filtri e paginazione · *Oggi:* accetta
solo `limit`; le righe più vecchie delle ultime 500 si leggono solo
direttamente dal database.
- **[AUD-08]** La sezione "Audit log" di `/admin` chiama l'endpoint senza
parametri (quindi le ultime 200 righe) al login e ogni volta che si apre la
@@ -114,10 +108,7 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
`app/tx/broadcast.py:RbfBumper._tick`,
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py` (solo `payout_failed`)
*Stato:* da implementare (B-87, B-96), per le ultime due letture ·
*Oggi:* le letture sono quelle di `payout_failed` e di
`draw_baseline_tip_unknown`.
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_state_consistency.py`
## Catalogo degli eventi
@@ -187,8 +178,6 @@ flowchart LR
riga. `user_id` no, `round_id` no. Stessa transazione della modifica.
*Codice:* `app/api/routes/admin.py:update_config` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82, B-95), per gli ultimi due campi ·
*Oggi:* i campi sono i primi otto.
- **[AUD-11]** `lottery_paused` — la lotteria è stata messa in pausa, da
chiunque. Payload `{"reason"}`, più `"note"` quando l'operatore l'ha
@@ -215,11 +204,7 @@ flowchart LR
`app/electrum/listener.py:ElectrumListener._record_reorg`,
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
`app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-82, B-90) · *Oggi:* scritto da
`POST /admin/pause` (`operator`), dal riconciliatore (`resurrected_tx`) e dal
listener (`reorg`); `payout_blocked` e l'avvio con `START_PAUSED` non lo
scrivono ancora.
*Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`, `tests/unit/test_restore_gap.py`
- **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload
`{"reason"}`: il `paused_reason` che la ripresa ha tolto (`null` se la
@@ -229,9 +214,7 @@ flowchart LR
`paused = false`, `paused_reason = NULL` e dell'azzeramento di
`payout_blocked_at`.
*Codice:* `app/api/routes/admin.py:resume_lottery` ·
*Test:* `tests/unit/test_pause_reason.py`
*Stato:* da implementare (B-82), per l'azzeramento di `payout_blocked_at` ·
*Oggi:* `rounds.payout_blocked_at` non esiste ancora.
*Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_admin.py::test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain`
- **[AUD-13]** `admin_privkey_accessed` — `GET /admin/users/{id}/privkey` ha
esportato la chiave privata (WIF) di un utente. Payload `{"user_id"}`.
@@ -270,9 +253,7 @@ flowchart LR
`user_id` sì (il nuovo utente), `round_id` no. Stessa transazione che crea
l'utente. È ciò che, dopo un ripristino, permette di ricostruire quali
indici erano già assegnati. Nessuna logica rilegge la riga.
*Codice:* `app/auth/routes.py:register` · *Test:* —
*Stato:* da implementare (B-92) · *Oggi:* la registrazione non scrive alcun
evento.
*Codice:* `app/auth/routes.py:register` · *Test:* `tests/unit/test_observability.py`
- **[AUD-42]** `password_changed` — un utente ha cambiato la propria password
con `POST /users/me/change-password`
@@ -283,8 +264,6 @@ flowchart LR
sbagliata, nuova troppo corta) non scrive nulla. Nessuna logica rilegge la
riga.
*Codice:* `app/api/routes/users.py:change_password` · *Test:* —
*Stato:* da implementare (B-94) · *Oggi:* il cambio password non scrive
alcun evento.
- **[AUD-43]** `restore_gap_detected` — il controllo all'avvio ha trovato
cronologia sulla catena per almeno uno dei 20 indici di derivazione
@@ -298,8 +277,7 @@ flowchart LR
ogni avvio in cui il controllo trova indirizzi usati; transazione propria
([AUD-03]). La sospensione delle registrazioni che ne segue non dipende da
questa riga. Procedura in [operativita.md](operativita.md) (OPS-28).
*Codice:* `app/main.py:lifespan` · *Test:* —
*Stato:* da implementare (B-90) · *Oggi:* nessun controllo e nessun evento.
*Codice:* `app/electrum/listener.py:ElectrumListener._check_restore_gap` · *Test:* `tests/unit/test_restore_gap.py`
### Denaro degli utenti
@@ -396,9 +374,7 @@ flowchart LR
correnti, secondi trascorsi dalla scadenza. `user_id` no, `round_id` sì.
Transazione propria ([AUD-03]). La riga più recente del round è l'orologio
della ripetizione successiva, 1800 s dopo ([AUD-09]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · *Test:* —
*Stato:* da implementare (B-96) · *Oggi:* l'attesa in `closing` non scrive
alcun evento.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · *Test:* `tests/unit/test_state_consistency.py`
- **[AUD-37]** `draw_committed` — il round è entrato in `drawing`: elenco dei
partecipanti e altezza del blocco di estrazione sono fissati
@@ -491,7 +467,7 @@ flowchart LR
scrive invece `payout_blocked` ([AUD-44]). L'elenco dei motivi è di
[RND-25](ciclo-di-vita-del-round.md#4--pagamento); la verifica senza
quorum di [RND-39](ciclo-di-vita-del-round.md#4--pagamento) termina anche
lei con `payout_failed`, motivo `pool verification unavailable` (B-82). Cosa fare per ogni motivo è descritto in
lei con `payout_failed`, motivo `pool verification unavailable`. Cosa fare per ogni motivo è descritto in
[operativita.md](operativita.md).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
`app/rounds/scheduler.py:RoundScheduler._log_payout_failure` ·
@@ -515,10 +491,8 @@ flowchart LR
riscritto dopo l'esclusione. `user_id` sì (il partecipante), `round_id` sì.
Stessa transazione del passaggio a `reorged_out` e della riscrittura di
`pool_amount_sats`. Nessuna logica rilegge la riga.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* —
*Stato:* da implementare (B-82) · *Oggi:* nessun partecipante viene mai
escluso; il pagamento fallisce con `insufficient pool UTXOs`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
*Test:* `tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there`, `tests/unit/test_pool_verification.py::test_several_bets_gone_at_once_each_get_a_row_in_one_transaction`
- **[AUD-44]** `payout_blocked` — la verifica prima del pagamento ha trovato
assente la scommessa **del vincitore**: il pagamento non si costruisce, il
@@ -532,10 +506,8 @@ flowchart LR
verifica si ripete: se la scommessa è ancora assente si scrive una nuova
riga. Non è un `payout_failed` e non fa da orologio dei tentativi.
Procedura in [operativita.md](operativita.md) (OPS-30).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* —
*Stato:* da implementare (B-82) · *Oggi:* il pagamento fallisce con
`insufficient pool UTXOs` a ogni tentativo, senza pausa.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
*Test:* `tests/unit/test_pool_verification.py::test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery`, `tests/unit/test_pool_verification.py::test_after_the_resume_the_verification_repeats`
### Riconciliazione
@@ -550,9 +522,7 @@ flowchart LR
([AUD-21]) o `payout_sent` ([AUD-29]) — con lo stesso payload
([TX-16](transazioni-e-riconciliazione.md#riconciliazione)).
*Codice:* `app/tx/reconcile.py:_promote` ·
*Test:* nessun test verifica la riga di audit.
*Stato:* da implementare (B-96), per il secondo evento · *Oggi:* si scrive
solo `pending_tx_recovered`.
*Test:* `tests/unit/test_state_consistency.py`
- **[AUD-31]** `pending_tx_abandoned` — una transazione `building` o `pending`
è stata dichiarata persa **con una prova**
@@ -596,10 +566,8 @@ flowchart LR
nel registro una riga uguale ([AUD-09]); un nuovo fallimento con la stessa
coppia va solo nei log, mentre un nuovo txid (dopo un aumento riuscito) o un
motivo diverso scrivono una riga nuova.
*Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:bump_fee` ·
*Test:* —
*Stato:* da implementare (B-87) · *Oggi:* l'errore va solo nel log
(`could not bump pending_transaction N`), ogni 30 secondi.
*Codice:* `app/tx/broadcast.py:RbfBumper._record_failure`, `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_rbf_dust.py::test_a_change_too_small_bump_writes_one_rbf_bump_failed_row_per_txid_and_reason`, `tests/unit/test_rbf_dust.py::test_the_maximum_fee_rate_is_reported`, `tests/unit/test_rbf_dust.py::test_a_replacement_the_network_refuses_is_reported_and_leaves_the_row_alone`, `tests/unit/test_rbf_dust.py::test_a_new_txid_or_a_new_reason_writes_a_new_row`
### Catena
@@ -642,9 +610,6 @@ flowchart LR
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/confirmation.py:_on_payout_confirmed`,
`app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee`
*Stato:* da implementare (B-87, B-92, B-94), per i tre fatti che escono
dall'elenco · *Oggi:* gli RBF impossibili, la registrazione e il cambio
password riuscito non producono alcuna riga.
## Log applicativo
@@ -667,11 +632,8 @@ va letto sul server.
container, quindi `docker compose logs app` li mostra insieme all'output di
avvio del container (controllo della chiave, migrazioni Alembic). La regola
è di [ARC-09](architettura.md#log), che ne è il proprietario.
*Codice:* `app/logging_config.py:setup_logging`
*Stato:* da implementare (B-92) · *Oggi:* il logger radice ha solo
l'handler su file: `docker compose logs app` mostra solo l'output di avvio
del container e ciò che uvicorn scrive con la propria configurazione
(avvio, richieste).
*Codice:* `app/logging_config.py:setup_logging` ·
*Test:* `tests/unit/test_observability.py`
- **[AUD-35]** Un'eccezione non gestita in una richiesta HTTP viene registrata
per intero nel log (`Unhandled error on <metodo> <percorso>`) e al client
+14 -33
View File
@@ -105,10 +105,7 @@ richiesta di scommessa (vedi
Il round usa sempre i valori fotografati: una modifica da `/admin` vale dal
round successivo, mai per quello in corso.
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:round_deadline` ·
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py` (solo durata e pausa)
*Stato:* da implementare (B-95) · *Oggi:* sono fotografate durata, pausa e
`draw_confirmations`; costo della scommessa e `fee_address` sono letti dalla
configurazione a ogni uso, `max_round_pool_sats` non esiste.
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py` (durata e pausa), `tests/unit/test_round_snapshot.py`
- **[RND-06]** Pause e configurazione non pagabile bloccano solo l'apertura
del *prossimo* round: un round già in corso prosegue fino al pagamento con i
valori fotografati all'apertura ([RND-05]), quindi una modifica di
@@ -119,12 +116,9 @@ richiesta di scommessa (vedi
`lottery_configured`) perché `/` mostri avvisi diversi. Le procedure sono in
[guida-admin.md](guida-admin.md).
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/api/routes/rounds.py:current_round`, `app/api/routes/admin.py:RoundConfigUpdate` ·
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_rounds_route.py`
*Stato:* da implementare (B-95) · *Oggi:* il pagamento legge `fee_address`
dalla configurazione a ogni tentativo: una modifica durante il round
sposta la commissione del round in corso, e un `fee_address` vuoto (solo
modificando il database a mano) blocca il pagamento con
`no fee_address configured`.
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_rounds_route.py`, `tests/unit/test_round_snapshot.py::test_the_payout_pays_the_fee_address_the_round_opened_with`, `tests/unit/test_round_snapshot.py::test_clearing_the_fee_address_mid_round_does_not_stop_its_payout`
Un `fee_address` vuoto sulla riga del round (solo modificando il database a
mano) blocca il pagamento con `no fee_address configured`.
- **[RND-07]** L'apertura di un round invia una notifica SSE.
*Codice:* `app/rounds/service.py:open_new_round_if_needed`
- **[RND-34]** **Impegno sul segreto**: all'apertura il server genera un
@@ -410,11 +404,7 @@ del montepremi del round.
([RND-38]). La ripartizione è fissata nel codice, non modificabile da
`/admin`.
*Codice:* `app/rounds/service.py:winner_share`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_payout_builder.py`
*Stato:* da implementare (B-82) · *Oggi:* la ripartizione si calcola
su `pool_amount_sats` fissato all'estrazione, senza la verifica di
[RND-39]; la riserva per l'RBF è già tolta dalla quota delle commissioni
(B-79).
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there`
- **[RND-22]** Il pagamento spende le uscite delle scommesse dei partecipanti
del round **verificate** ([RND-39]), all'indirizzo del montepremi del round
([RND-35]), e nient'altro: altre monete arrivate a quell'indirizzo non
@@ -427,12 +417,7 @@ del montepremi del round.
dalla più grande fino a coprire il montepremi; non abbastanza monete dà
`insufficient pool UTXOs`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_payout_builder.py`, `tests/unit/test_round_pool_address.py`
*Stato:* da implementare (B-82) · *Oggi:* spende le monete confermate
dell'indirizzo del round (per un round vecchio, di quello condiviso) lette
dal solo server attivo, scelte dalla più grande fino a coprire il
montepremi, e non solo le uscite delle scommesse dei partecipanti: un'altra
moneta arrivata a quell'indirizzo verrebbe spesa.
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_payout_builder.py`, `tests/unit/test_round_pool_address.py`, `tests/unit/test_pool_verification.py::test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else`
- **[RND-23]** La transazione ha un'uscita verso l'indirizzo del vincitore,
una verso l'indirizzo delle commissioni fotografato all'apertura
([RND-05]) e una verso l'indirizzo di riserva ([RND-38]). Se gli ingressi
@@ -447,9 +432,6 @@ del montepremi del round.
`commission_below_dust_limit`).
*Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share`, `tests/unit/test_payout_builder.py::test_payout_folds_excess_input_value_into_the_reserve_output`, `tests/unit/test_payout_builder.py::test_payout_leaves_a_dust_sized_excess_to_the_network_fee`, `tests/unit/test_payout_builder.py::test_payout_with_one_participant_at_the_smallest_viable_pool`
*Stato:* da implementare (B-95) · *Oggi:* `fee_address` è letto dalla
configurazione corrente al momento del pagamento, non fotografato
all'apertura.
- **[RND-24]** **Prima si scrive, poi si invia**: prima dell'invio il round
registra importo del vincitore (`winner_amount_sats`), commissioni
(`fee_amount_sats`), riserva per l'RBF (`bump_reserve_sats`, [RND-38]) e
@@ -469,7 +451,7 @@ del montepremi del round.
`insufficient pool UTXOs`, il codice dell'errore di costruzione
(`too_many_inputs`, `winner_share_below_network_fee`,
`commission_below_dust_limit`), `payout build failed` per ogni altro errore
di costruzione, `broadcast rejected`, e — *da implementare (B-82)* —
di costruzione, `broadcast rejected`, e
`pool verification unavailable` quando la verifica del montepremi prima del
pagamento ([RND-39]) non ottiene abbastanza risposte dal quorum. Fanno eccezione due casi, che non
scrivono `payout_failed`: un pagamento già in volo per il round ([RND-26]),
@@ -524,18 +506,17 @@ del montepremi del round.
`paying_out` senza altri tentativi finché l'operatore non toglie la pausa
(`POST /admin/resume`); al tentativo successivo la verifica si ripete. Lo
schema è in [modello-dati.md](modello-dati.md) (B-82), la procedura in
[operativita.md](operativita.md) (B-82).
[operativita.md](operativita.md) (B-82). La riga del vincitore **non**
passa a `reorged_out` in questo caso: resta `confirmed`, così che la
verifica dopo la ripresa la riesamini davvero; i partecipanti diversi dal
vincitore la cui scommessa è assente sono invece esclusi nella stessa
transazione.
- Se il quorum non risponde o non concorda, non si conclude nulla: nessun
partecipante cambia stato, il tentativo termina con `payout_failed` e si
riprova secondo [RND-25].
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* —
*Stato:* da implementare (B-82) · *Oggi:* le monete sono lette dal solo
server attivo e nessun partecipante viene mai escluso: se una scommessa
sparisce dalla catena il pagamento fallisce con
`insufficient pool UTXOs` a ogni tentativo, con i soldi di tutti gli altri
bloccati.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:RoundScheduler._verify_round_pool`, `app/electrum/listener.py:ElectrumListener.verify_pool_outpoints` ·
*Test:* `tests/unit/test_pool_verification.py`
- **[RND-40]** **Configurazione pagabile**: `rounds_can_open` rifiuta una
configurazione in cui un round non si potrebbe pagare, verificandola per
**n = 1** e per **n = `MAX_PARTICIPANTS_PER_ROUND`** partecipanti (i due
+32 -26
View File
@@ -62,6 +62,16 @@ Le difese sono cinque.
di [ELC-14].
*Codice:* `app/config.py:validate_quorum_servers`, `app/main.py:lifespan` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[ELC-30]** Subito dopo `server.version` la connessione chiede
`server.features`: il `genesis_hash` deve essere quello di PLM
(`000000000019d668…ce26f`). PLM condivide genesis e `powLimit` con Bitcoin
(`palladiumcore/src/chainparams.cpp`), quindi un server Electrum di Bitcoin
supererebbe ogni controllo sugli header di [ELC-11]; solo il genesis lo
distingue. Un genesis diverso o assente, o un errore alla richiesta, fa
fallire la connessione come un server irraggiungibile ([ELC-02]); vale anche
per le connessioni del quorum ([ELC-14]).
*Codice:* `app/electrum/client.py:ElectrumClient._check_genesis`, `app/wallet/plm_network.py:GENESIS_HASH` ·
*Test:* `tests/unit/test_electrum_client.py`
## 2. Richieste con limite di tempo
@@ -98,11 +108,7 @@ Le difese sono cinque.
indirizzo non termina la sessione
([DEP-16](registrazione-e-depositi.md#deposito)).
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once`, `app/electrum/client.py:ElectrumClient.wait_closed`, `app/electrum/listener.py:ElectrumListener._consume_scripthash` ·
*Test:* `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-96), per gli errori di aggiornamento di un
utente · *Oggi:* qualunque eccezione in `refresh_user` chiamato da
`_consume_scripthash` (ad esempio la collisione sull'outpoint di due
aggiornamenti concorrenti) termina la sessione.
*Test:* `tests/unit/test_electrum_client.py`, `tests/unit/test_state_consistency.py`
- **[ELC-07]** Una connessione viene resa disponibile al resto del sistema
**solo dopo** che il primo header è stato applicato: se il client c'è,
l'altezza della catena è nota. Se il primo header è inutilizzabile (privo
@@ -253,12 +259,10 @@ Ogni decisione che sposta denaro in base a una risposta della rete viene
confermata dagli **altri** server configurati, mai dal solo server attivo
(che è proprio quello che un attaccante controllerebbe).
Oggi non è ancora così per una sola decisione: la lettura delle monete del
montepremi per il pagamento
(`app/rounds/scheduler.py:RoundScheduler._trigger_payout`) si basa sul solo
server attivo, ed è la riga [ELC-27]. Le conferme delle transazioni
([ELC-26]), il reintegro di una moneta ricomparsa ([ELC-25]), l'abbandono
([ELC-28]) e l'invio ([TX-04], [TX-21]) usano già gli altri server.
Vale per ogni decisione di questo tipo: le monete del montepremi per il
pagamento ([ELC-27]), le conferme delle transazioni ([ELC-26]), il reintegro
di una moneta ricomparsa ([ELC-25]), l'abbandono ([ELC-28]) e l'invio
([TX-04], [TX-21]) usano gli altri server.
- **[ELC-14]** La corroborazione interroga in parallelo tutti i server
configurati tranne quello attivo, ciascuno con una connessione nuova e un
@@ -286,17 +290,11 @@ server attivo, ed è la riga [ELC-27]. Le conferme delle transazioni
*Codice:* `app/electrum/listener.py:ElectrumListener.agreed_block_hash`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit`,
`app/electrum/listener.py:ElectrumListener.verify_pool_outpoints`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_reinstatement`,
`app/electrum/listener.py:ElectrumListener.confirmation_proven`,
`app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_confirmation_proof.py`
Stato delle righe della tabella:
- [ELC-17] — *Stato:* da implementare (B-82) · *Oggi:* basta un'altezza
maggiore di 0 (1 conferma).
- [ELC-27] — *Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie
fra le monete che il server attivo elenca per il montepremi.
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_confirmation_proof.py`, `tests/unit/test_deposit_confirmations.py`, `tests/unit/test_pool_verification.py`
I saldi si muovono in entrambe le direzioni, quindi entrambe richiedono lo
stesso quorum. Le conseguenze di una verifica non riuscita:
@@ -311,7 +309,15 @@ stesso quorum. Le conseguenze di una verifica non riuscita:
al controllo successivo;
- un blocco di estrazione non verificato fa attendere e richiedere la stessa
altezza, e lo si registra in audit (`draw_header_corroboration_failed`,
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione)).
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione));
- le monete del montepremi non verificate fanno terminare il tentativo di
pagamento con `payout_failed` (`pool verification unavailable`), senza
escludere nessun partecipante ([RND-39](ciclo-di-vita-del-round.md#4--pagamento)).
La verifica ([ELC-27]) chiede a ogni altro server l'elenco delle monete
dell'indirizzo del round: per ogni uscita di scommessa una maggioranza
stretta di chi risponde dice *presente* (stesso importo, confermata) o
*assente* (non elencata, o con un altro importo); un'uscita elencata solo
nel mempool, o senza maggioranza, non dà alcun verdetto e si riprova.
L'**invio** di una transazione non è una corroborazione ma usa gli stessi
server: dopo che il server attivo l'ha accettata, la transazione grezza viene
@@ -338,11 +344,11 @@ B-83).
- **[ELC-29]** Il listener espone lo stato della connessione: se è connesso,
il server attivo, l'altezza della punta e da quanti secondi è stato
accettato l'ultimo header. Lo leggono `GET /health/ready` (non pronto se non
connesso o se l'ultimo header ha più di 360 secondi,
[api.md](api.md) B-92) e l'indicatore Electrum di `/admin`. L'altezza
connesso o se l'ultimo header ha più di 1800 secondi (con blocchi da 120 s
una soglia di 360 s scatterebbe a vuoto ogni ventina di blocchi,
e^-3 ≈ 5%; la rotazione di [ELC-21] resta a 360 s perché agisce solo se un
altro server ha davvero una punta più alta),
[api.md](api.md) API-46) e l'indicatore Electrum di `/admin`. L'altezza
corrente resta visibile anche nella barra di stato della catena di `/` (da
`chain_tip_height` di `GET /rounds/current`).
*Codice:* `app/electrum/listener.py:ElectrumListener.current_endpoint`, `app/main.py:health` · *Test:* —
*Stato:* da implementare (B-86, B-92) · *Oggi:* `/health` risponde sempre
`ok`; l'età dell'ultimo header non è registrata da nessuna parte e `/admin`
mostra solo l'altezza.
*Codice:* `app/electrum/listener.py:ElectrumListener.current_endpoint`, `app/main.py:health` · *Test:* `tests/unit/test_observability.py`
+4 -2
View File
@@ -47,11 +47,13 @@ business — quelli si toccano solo da qui.
| Campo | Significato |
|---|---|
| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: su un'istanza nuova è vuoto, e finché resta vuoto **non si apre nessun round** (il payout non sarebbe costruibile, quindi il round accetterebbe scommesse per poi restare bloccato con i soldi già nel montepremi). Il pannello lo segnala con un avviso in cima ai Parametri, e la pagina utente mostra un banner "lotteria non ancora pronta". Una volta impostato non si può più svuotare dal pannello: un valore vuoto non è un indirizzo `plm1...` valido e viene rifiutato. **Attenzione:** oggi il payout legge questo campo al momento in cui viene costruito, quindi cambiarlo mentre un round è in corso sposta anche la commissione di quel round sul nuovo indirizzo. |
| **Bet amount (PLM)** | Il costo fisso d'ingresso per round. È anche l'importo minimo prelevabile: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). |
| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee); una modifica vale dal round successivo, mai per quello in corso. Obbligatorio: su un'istanza nuova è vuoto, e finché resta vuoto **non si apre nessun round** (il payout non sarebbe costruibile, quindi il round accetterebbe scommesse per poi restare bloccato con i soldi già nel montepremi). Il pannello lo segnala con un avviso in cima ai Parametri, e la pagina utente mostra un banner "lotteria non ancora pronta". Una volta impostato non si può più svuotare dal pannello: un valore vuoto non è un indirizzo `plm1...` valido e viene rifiutato. **Attenzione:** oggi il payout legge questo campo al momento in cui viene costruito, quindi cambiarlo mentre un round è in corso sposta anche la commissione di quel round sul nuovo indirizzo. |
| **Bet amount (PLM)** | Il costo fisso d'ingresso per round, **dal round successivo**: il round in corso tiene il costo con cui si è aperto, così tutti i suoi giocatori pagano la stessa posta. È anche l'importo minimo prelevabile, e questo vale subito: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). Un valore con cui un round non si potrebbe pagare (commissione di rete, riserva e soglia di polvere non coperte) viene rifiutato. |
| **Durata round (secondi)** | Quanto resta aperto un round prima di chiudersi ed estrarre il vincitore. Il taglio per le nuove giocate scatta esattamente allo scadere di questo tempo (verificato ad ogni bet, non dipende dal ciclo dello scheduler) — è un "semaforo giallo": nessuna nuova entrata, ma le bet già trasmesse prima dello scadere hanno comunque tempo di confermarsi prima che il round chiuda ed estragga. |
| **Tetto al montepremi di un round (PLM)** | 0 = nessun tetto. Con un tetto, una scommessa è rifiutata (`round_full`) quando la somma delle scommesse del round lo ha raggiunto; il montepremi può quindi superarlo al massimo di una scommessa. Non può essere minore del costo della scommessa. Vale dal round successivo. |
| **Pausa tra un round e il successivo (secondi)** | Cooldown dopo la chiusura di un round, prima che il successivo si apra — dà tempo ai giocatori di vedere l'esito. |
| **Durata animazione estrazione (secondi)** | Tempo minimo per cui la dashboard di ogni utente mostra l'animazione "Estrazione in corso" dopo la chiusura del round, prima di rivelare il vincitore. È solo un minimo: il processo reale aspetta più blocchi in sequenza (conferma dell'ultima bet in sospeso, il blocco dell'estrazione con le sue conferme, conferma del payout — un blocco ogni 2 minuti in media), quindi l'animazione può durare più a lungo di questo valore, mai meno. |
| **Conferme per accreditare un deposito** | Da 1 a 100 (predefinito 6): dopo quante conferme un deposito arrivato all'indirizzo di un utente viene accreditato e diventa spendibile. Un deposito è l'unico ingresso che un utente può spendere due volte con una catena privata, perciò il valore non va abbassato senza motivo. Vale subito per gli accrediti successivi; le uscite di mining (coinbase) ne richiedono comunque 120, e le transazioni costruite dalla piattaforma (resto di una bet, vincite) una sola. |
| **Conferme sul blocco di estrazione** | Da 1 a 20 (predefinito 3): quanti blocchi deve avere la catena a partire dal blocco di estrazione prima che il suo hash scelga il vincitore. Più conferme rendono l'estrazione più robusta a una riorganizzazione della catena e allungano l'attesa di circa 2 minuti per blocco. Si applica dal round successivo. |
| **Fee rate di rete (sat/vB)** | Fee per byte usata per costruire bet, payout e prelievi. |
| **Timeout prima del fee-bump RBF (secondi)** | Dopo quanto tempo senza conferma una transazione viene ritrasmessa con fee più alta. |
+9 -4
View File
@@ -102,15 +102,20 @@ L'avviso sparisce da solo appena l'operatore riprende la lotteria.
confermata — così non sembra che il saldo sia crollato più del dovuto
subito dopo un'operazione. Il colore indica lo stato:
- **verde**: tutto confermato, il saldo mostrato è quello definitivo
- **arancione**: c'è una bet o un prelievo ancora in attesa di conferma —
il numero è corretto, ma non ancora "finale"
- **arancione**: c'è una bet o un prelievo ancora in attesa di conferma,
oppure un deposito che ha qualche conferma ma non ancora quelle richieste —
il numero è corretto, ma non ancora "finale"; un deposito in attesa non è
spendibile finché non viene accreditato
- Il tuo **indirizzo di deposito**, con bottone per copiarlo negli appunti
- Il **QR code** dello stesso indirizzo, comodo per inviare PLM da un altro
wallet scansionandolo invece di copiare l'indirizzo a mano
Per depositare, invia PLM (mainnet reale) a quell'indirizzo da un wallet
esterno. Il saldo si aggiorna da solo dopo la prima conferma (e quasi subito,
grazie all'aggiornamento in tempo reale); premi "Aggiorna" se vuoi comunque
esterno. Un deposito viene accreditato (e diventa spendibile) dopo
**6 conferme** sulla rete (il valore è configurabile dall'operatore; un'uscita
di mining ne richiede 120), cioè circa 12 minuti; nel frattempo compare in
arancione come in attesa. Il saldo si aggiorna da solo, con l'aggiornamento in
tempo reale; premi "Aggiorna" se vuoi comunque
ricontrollarlo a mano.
### Bet
+50 -118
View File
@@ -16,8 +16,6 @@ Un limite che il [piano delle correzioni](piano-correzioni.md) chiuderà porta
atteso; quando la correzione entra nel codice, il limite si barra come
`~~[LIM-nn]~~ Rimosso: chiuso da B-nn`.
Formato di ogni voce: descrizione, *Impatto*, *Mitigazione attuale*, *Per
chiuderlo*, e il codice coinvolto.
Formato di ogni voce: descrizione, *Impatto*, *Mitigazione attuale*, *Per
chiuderlo*, e il codice coinvolto.
@@ -33,11 +31,11 @@ implementato il [piano delle correzioni](piano-correzioni.md).
| Limite | Blocca il non presidiato oggi? | Dopo il piano |
|---|---|---|
| ~~[LIM-01]~~ `drawing` non riprende dopo un errore o un riavvio | Non più (chiuso da B-81) | — |
| [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** | Chiuso da B-79 e B-87 |
| [LIM-02] RBF di una sola forma | Non più il pagamento senza resto (B-79) né il resto sotto soglia (B-87); resta una transazione che esaurisce resto o riserva | Visibile in `rbf_bump_failed`; resta in attesa senza abbandono |
| [LIM-03] Attesa in `closing` senza limite di tempo | **Sì, in caso di congestione** | Resta senza limite di tempo per scelta ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)); con B-78 e B-87 finisce quando la rete decide |
| [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** | Da chiudere con la prova su rete reale ([operativita.md](operativita.md#prova-su-rete-reale)) prima di aprire al pubblico |
| [LIM-10] Riorganizzazioni non gestite | **Sì** — una scommessa sparita rende il round impagabile | Chiuso da B-82 e B-85 (la pausa e il registro sono attivi; resta il pagamento, B-82); resta [LIM-24] |
| [LIM-11], [LIM-12] Quorum facoltativo e parziale | **Sì** — un solo server decide conferme e abbandoni | Chiusi da B-83 e B-84 (resta la lettura delle monete del montepremi, B-82) |
| ~~[LIM-10]~~ Riorganizzazioni non gestite | Non più (chiuso da B-82 e B-85) | Resta [LIM-24] |
| [LIM-11], ~~[LIM-12]~~ Quorum facoltativo e parziale | Non più (chiusi da B-82, B-83 e B-84) | Resta la scelta dell'operatore di server indipendenti ([LIM-11]) |
| [LIM-13] Estrazione manipolabile | **Sì** — per un giocatore che mina | Chiuso da B-73; resta [LIM-25] |
| [LIM-14] Header senza difficoltà minima | **Sì** — un header falso congela ogni estrazione | Chiuso da B-74 per il blocco; resta il costo basso di un header a difficoltà minima |
| [LIM-06] Token admin unico | No per il funzionamento, ma è il rischio di sicurezza più ampio | Invariato |
@@ -50,38 +48,31 @@ implementato il [piano delle correzioni](piano-correzioni.md).
stesso vincitore ([RND-36](ciclo-di-vita-del-round.md)).
- **[LIM-02]** **L'RBF gestisce una sola forma di transazione**: un'unica uscita
di resto verso il mittente, abbastanza grande da assorbire l'aumento. Non c'è
il ripiego «aggiungi un'altra moneta». Ogni aumento costa almeno 1 sat/vB
sull'intera dimensione della transazione (141 sat per una transazione
con un ingresso e due uscite) e si toglie dal resto; quando l'aumento supera
il resto, `bump_fee` solleva `RbfError`. Inoltre `bump_fee` **non controlla
che il resto ridotto resti sopra la soglia di polvere**: con un resto vicino
al minimo di 294 sat ([ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md)) già il primo aumento produce un'uscita sotto
soglia, che le regole di inoltro standard rifiutano. **Il pagamento del
round, nel caso normale, non ha alcun resto** (spende esattamente il
montepremi del round, e un resto sotto soglia va alla commissione), quindi
non è accelerabile affatto.
- *Impatto:* con la rete congestionata una transazione a 1 sat/vB può
restare in mempool indefinitamente. Poiché il riconciliatore abbandona solo
ciò che il server **non conosce**, una transazione ferma ma presente in
mempool non viene mai abbandonata: una scommessa così blocca il round in
`closing`, un pagamento così lo blocca in `paying_out`. `RbfBumper`
registra un errore ogni 30s.
- *Mitigazione attuale:* resto minimo garantito sulle transazioni utente;
tetto al fee rate (`MAX_FEE_RATE_SAT_VB`); abbandono dopo 6 ore per ciò che
sparisce dalla rete; ritentativo automatico del pagamento dopo un
abbandono.
- *Stato:* da chiudere con B-87 (la riserva del pagamento, B-79, c'è).
- *Per chiuderlo:* ogni pagamento porta un'uscita di riserva che
l'accelerazione riduce ([ADR-17](adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md), B-79); un aumento che lascerebbe un
resto sotto `DUST_LIMIT_SATS` elimina il resto e ne versa il valore in
commissione, e quella sostituzione è l'ultima ([ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md), B-87); ogni
`RbfError` e ogni sostituzione rifiutata scrivono `rbf_bump_failed` in
audit. Resta accettato, dopo il piano: nessun ripiego «aggiungi un'altra
moneta» né CPFP; una transazione che ha esaurito resto o riserva e non
conferma nemmeno al tetto del fee rate resta in attesa, visibile, e non
viene abbandonata ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)). Il docstring di `bump_fee` cita ancora come
esempio «una scommessa senza resto», caso che dopo B-62 non esiste più.
riducibile — il resto verso il mittente per scommesse e prelievi, l'uscita
di riserva per il pagamento ([RND-38](ciclo-di-vita-del-round.md#4--pagamento), B-79).
Non c'è il ripiego «aggiungi un'altra moneta» né il CPFP. Ogni aumento costa
almeno 1 sat/vB sull'intera dimensione della transazione (141 sat per una
transazione con un ingresso e due uscite) e si toglie da quell'uscita; se
quanto resta sarebbe sotto la soglia di polvere l'uscita viene eliminata e
il suo valore va in commissione, e quella sostituzione è l'ultima (B-87,
[ADR-19](adr/ADR-19-ogni-transazione-utente-conserva-uscita-resto.md)); se
nemmeno l'intera uscita raggiunge l'aumento minimo, o non esiste, `bump_fee`
solleva `RbfError`.
- *Impatto:* con la rete congestionata una transazione a 1 sat/vB che ha
esaurito resto o riserva può restare in mempool indefinitamente. Poiché il
riconciliatore abbandona solo ciò che il server **non conosce**, una
transazione ferma ma presente in mempool non viene mai abbandonata: una
scommessa così blocca il round in `closing`, un pagamento così lo blocca
in `paying_out`.
- *Mitigazione attuale:* la riserva del pagamento è dimensionata per circa
10 sat/vB; resto minimo garantito sulle transazioni utente; tetto al fee
rate (`MAX_FEE_RATE_SAT_VB`); ogni accelerazione impossibile o rifiutata
scrive `rbf_bump_failed` (una volta per transazione e motivo); abbandono
dopo 6 ore per ciò che sparisce dalla rete; ritentativo automatico del
pagamento dopo un abbandono.
- *Stato:* accettato dopo B-79 e B-87: una transazione che ha esaurito resto
o riserva e non conferma nemmeno al tetto del fee rate resta in attesa,
visibile, e non viene abbandonata ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)).
*Codice:* `app/tx/broadcast.py:bump_fee` · `app/wallet/psbt_builder.py:build_payout_transaction` · `app/tx/reconcile.py:reconcile_once`
@@ -95,7 +86,7 @@ implementato il [piano delle correzioni](piano-correzioni.md).
nuovo round si apre nel frattempo.
- *Mitigazione attuale:* lo stato è visibile (`closing`) e il pannello
«Transazioni pendenti» mostra la scommessa che trattiene il round.
- *Stato:* B-78 e B-77 chiusi; resta da chiudere B-87 (resto minimo), e per scelta l'assenza di un limite di tempo.
- *Stato:* B-78, B-77 e B-87 chiusi; resta per scelta l'assenza di un limite di tempo.
- *Per chiuderlo:* con B-78 l'attesa finisce quando la scommessa conferma
(una qualsiasi delle sue versioni, B-77) o quando c'è la prova che non
confermerà mai ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)), non dopo un tempo fisso; con B-87 la scommessa
@@ -160,10 +151,10 @@ implementato il [piano delle correzioni](piano-correzioni.md).
blocco per-utente li serializza, non li limita); il cambio password
permette di provare password attuali senza ritardi a chi possiede già un
token valido; le segnalazioni anonime possono riempire il database.
- *Stato:* da chiudere in parte con B-94 (cambio password limitato e
registrato, segnalazioni di bug limitate per IP, lunghezza massima di 256
caratteri sui campi delle credenziali). Scommesse, prelievi ed endpoint
admin restano senza limite di frequenza anche dopo il piano.
- *Stato:* chiuso in parte da B-94 (cambio password limitato e registrato,
segnalazioni di bug limitate per IP, lunghezza massima di 256 caratteri sui
campi delle credenziali). Scommesse, prelievi ed endpoint admin restano
senza limite di frequenza.
- *Per chiuderlo:* un limitatore per utente e per IP sugli endpoint
interessati, sullo stesso modello di `RateLimiter`/`RollingQuota`.
@@ -174,9 +165,9 @@ implementato il [piano delle correzioni](piano-correzioni.md).
(le scrive solo il reset da parte dell'admin).
- *Impatto:* dopo una compromissione non c'è traccia di quando e da dove sia
avvenuto l'accesso.
- *Stato:* da chiudere in parte con B-92 (`user_registered`, con indice di
derivazione e indirizzo) e B-94 (`password_changed`). Login riusciti e
falliti restano senza voce di audit dopo il piano.
- *Stato:* chiuso in parte da B-92 (`user_registered`, con indice di
derivazione e indirizzo) e da B-94 (`password_changed`). Login riusciti e
falliti restano senza voce di audit.
- *Per chiuderlo:* eventi di audit per questi casi (vedi
[audit-log.md](audit-log.md)).
@@ -211,30 +202,11 @@ implementato il [piano delle correzioni](piano-correzioni.md).
### Fiducia nella rete
- **[LIM-10]** **Riorganizzazioni non gestite, depositi a 1 conferma.** Un
deposito accreditato a 1 conferma può sparire con una riorganizzazione; la
punta della catena non regredisce mai, un header concorrente alla stessa
altezza è ignorato e nessuna riorganizzazione viene rilevata.
- *Impatto:* il caso grave è una scommessa **già confermata** che sparisce:
un utente scommette con un deposito a 1 conferma e, se perde, sostituisce
il deposito con una catena privata. La scommessa non è più in catena, ma il
partecipante resta `confirmed` e contato, e il pagamento fallisce per
sempre con «monete del montepremi insufficienti», con il denaro di tutti
gli altri giocatori bloccato. Anche le uscite coinbase sono accreditate a 1
conferma, mentre maturano a 120.
- *Mitigazione attuale:* una moneta che sparisce dall'elenco del server viene,
previo quorum, segnata come spesa all'esterno e tolta dal saldo; se
riappare viene ripristinata. Una scommessa **non ancora inviata** costruita
su una moneta scomparsa è rifiutata dalla rete e annullata; per una
scommessa già confermata non esiste alcuna mitigazione (la versione
precedente di questa voce diceva il contrario).
- *Stato:* da chiudere con B-82; le riorganizzazioni sono già rilevate, registrate e (se profonde) in pausa (B-85).
- *Per chiuderlo:* depositi a `deposit_confirmations` conferme e coinbase a
120 ([ADR-06](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md)); riorganizzazioni rilevate, registrate e, se profonde,
con pausa; monete del round verificate prima del pagamento, con i
partecipanti spariti esclusi ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)). Resta accettato il 51% ([LIM-24]).
*Codice:* `app/deposits/service.py:find_utxos_missing_from` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/electrum/listener.py:ElectrumListener._apply_header`
- ~~[LIM-10]~~ Rimosso: chiuso da B-82 e B-85. I depositi si accreditano a
`deposit_confirmations` conferme (le coinbase a 120), le riorganizzazioni sono
seguite, registrate e, da profondità 2, mettono in pausa; le monete del round
si verificano con il quorum prima del pagamento e i partecipanti spariti
restano fuori ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)). Resta accettato il 51% ([LIM-24]).
- **[LIM-11]** **I server del quorum li sceglie l'operatore.** Il server
rifiuta di partire con meno di 2 server di riserva e ogni corroborazione
@@ -253,26 +225,9 @@ implementato il [piano delle correzioni](piano-correzioni.md).
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority`
- **[LIM-12]** **Non tutte le letture di rete sono corroborate.** Passano dal
solo server attivo: l'elenco delle monete del montepremi usato per il
pagamento.
- *Impatto:* un server ostile potrebbe far scegliere al pagamento monete che
non esistono (il pagamento verrebbe rifiutato dalla rete) o nasconderne
di vere (il pagamento fallirebbe e verrebbe ritentato).
- *Mitigazione attuale:* l'abbandono richiede una prova ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)) e l'invio
arriva a tutti i server; un invio basato su monete inesistenti è rifiutato
dalla rete.
- *Stato:* da chiudere con B-82 (monete del round prima del pagamento);
conferme e ripristini sono chiusi da B-83.
- *Per chiuderlo:* una conferma vale quando il quorum restituisce una prova
merkle valida (`blockchain.transaction.get_merkle`) contro un header che
possediamo; il ripristino richiede lo stesso quorum di un accredito;
l'abbandono richiede la prova di [ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md); le monete del round si
verificano con il quorum prima di costruire il pagamento ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)); ogni
transazione accettata dal server attivo viene inviata anche a tutti i
server di riserva.
*Codice:* `app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout`
- ~~[LIM-12]~~ Rimosso: chiuso da B-83 e B-82. Conferme (prova merkle),
reintegri, abbandoni e le monete del montepremi usate per il pagamento sono
tutti decisi dal quorum degli altri server, mai dal solo server attivo.
- **[LIM-13]** **L'estrazione attuale è manipolabile da chi produce o
trasmette i blocchi.**
@@ -393,19 +348,10 @@ implementato il [piano delle correzioni](piano-correzioni.md).
### Configurazione ed economia
- **[LIM-16]** **Solo i tempi del round sono fotografati all'apertura.** Costo
della scommessa, fee rate e `fee_address` sono letti dal vivo.
- *Impatto:* cambiare il costo a metà round fa pagare poste diverse a
giocatori con la stessa probabilità, e cambia subito il prelievo minimo;
cambiare `fee_address` a metà round devia la commissione del round in
corso (la modifica è comunque registrata in audit).
- *Stato:* da chiudere con B-95.
- *Per chiuderlo:* fotografare costo, `fee_address` e `draw_confirmations`
sulla riga del round, come già si fa per durata e pausa ([ADR-10](adr/ADR-10-parametri-round-fotografati-all-apertura.md)). Il fee
rate resta letto dal vivo per scelta, e il prelievo minimo segue il costo
corrente della configurazione ([ADR-20](adr/ADR-20-prelievo-minimo-costo-corrente-scommessa.md)).
*Codice:* `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/withdrawals/service.py:request_withdrawal`
- ~~[LIM-16]~~ Rimosso: chiuso da B-95. Costo della scommessa, `fee_address` e
tetto al montepremi sono fotografati sulla riga del round all'apertura, come
durata e pausa ([ADR-10](adr/ADR-10-parametri-round-fotografati-all-apertura.md)); il fee rate resta letto dal vivo per scelta e il prelievo
minimo segue il costo corrente della configurazione ([ADR-20](adr/ADR-20-prelievo-minimo-costo-corrente-scommessa.md)).
- **[LIM-17]** **Nessun consolidamento automatico del montepremi.** Monete
avanzate e resti si accumulano sugli indirizzi del montepremi (fino a B-80
@@ -468,21 +414,7 @@ implementato il [piano delle correzioni](piano-correzioni.md).
*Codice:* `app/static/i18n.js:t`
- **[LIM-22]** **L'API di amministrazione e alcuni errori di contorno non usano
il formato d'errore strutturato.** Gli endpoint `/admin/*` rispondono con un
`detail` stringa (per esempio `"invalid admin token"`, `"user not found"`), e
così il 503 del canale SSE oltre il tetto; è coerente con un pannello non
tradotto, ma non è dichiarato altrove.
- *Stato:* da chiudere con B-94.
- *Per chiuderlo:* errori strutturati ovunque: `/admin/*`, il 503 del canale
SSE e gli errori generati dal framework usano lo stesso formato
`{code, message, params}` ([ADR-47](adr/ADR-47-l-api-parla-sola-lingua-risponde.md)).
- ~~[LIM-22]~~ Rimosso: chiuso da B-94. Gli endpoint `/admin/*`, il `503` del canale SSE e gli errori del framework (`401`, `404`, `405`, `422`) rispondono con il formato strutturato `{code, message, params}` ([API-16](api.md#contratto-degli-errori), [ADR-47](adr/ADR-47-l-api-parla-sola-lingua-risponde.md)).
*Codice:* `app/api/routes/admin.py:require_admin` · `app/api/routes/rounds.py:round_stream`
- ~~[LIM-23]~~ Rimosso: chiuso da B-94. `GET /admin/users` è paginato ([API-33](api.md)).
- **[LIM-23]** **`GET /admin/users` non ha limite né paginazione**, a
differenza degli altri elenchi admin.
- *Impatto:* con molti utenti la risposta cresce senza limite.
- *Stato:* da chiudere con B-94 (paginazione dell'elenco utenti).
*Codice:* `app/api/routes/admin.py:list_users`
+58 -86
View File
@@ -53,8 +53,8 @@ leggono dalla rete, non da una tabella:
| Riserva del pool (il "pool" unico di oggi) | `m/84'/746'/0'/1/0` | nessuna tabella; `app/wallet/hd.py:derive_pool_key` |
| Montepremi di un round | `m/84'/746'/0'/2/<round_id>` | `rounds.pool_address` ([DAT-60]) |
Il diagramma descrive lo schema **atteso**; le colonne e la tabella marcate
"da implementare" non esistono ancora (vedi le tabelle di dettaglio).
Il diagramma descrive lo schema **atteso**; ciò che non esiste ancora è
marcato *da implementare* nelle tabelle di dettaglio.
```mermaid
erDiagram
@@ -98,15 +98,16 @@ erDiagram
datetime closed_at
int duration_seconds "snapshot"
int cooldown_seconds "snapshot"
bigint bet_amount_sats "snapshot (da implementare)"
string fee_address "snapshot (da implementare)"
int draw_confirmations "snapshot (da implementare)"
bigint bet_amount_sats "snapshot"
string fee_address "snapshot"
bigint max_round_pool_sats "snapshot"
int draw_confirmations "snapshot"
string pool_address
string server_seed_enc "cifrato fino alla rivelazione (da implementare)"
string server_seed "in chiaro solo dopo la rivelazione (da implementare)"
string server_seed_hash "da implementare"
string participants_hash "da implementare"
int draw_height "da implementare"
string server_seed_enc "cifrato fino alla rivelazione"
string server_seed "in chiaro solo dopo la rivelazione"
string server_seed_hash
string participants_hash
int draw_height
datetime drawing_started_at
int draw_block_height
string draw_block_hash
@@ -117,7 +118,7 @@ erDiagram
bigint fee_amount_sats
bigint bump_reserve_sats
string payout_txid
datetime payout_blocked_at "da implementare"
datetime payout_blocked_at
}
round_participants {
int id PK
@@ -125,10 +126,10 @@ erDiagram
int user_id FK
bigint bet_amount_sats
string bet_txid
int bet_vout "da implementare"
int bet_vout
datetime broadcast_at
datetime confirmed_at
int draw_position "UK con round_id (da implementare)"
int draw_position "UK con round_id"
string status "building broadcast confirmed reorged_out"
}
round_config {
@@ -140,11 +141,11 @@ erDiagram
int draw_animation_seconds
int fee_rate_sat_vb
int rbf_timeout_seconds
int deposit_confirmations "da implementare"
int draw_confirmations "da implementare"
bigint max_round_pool_sats "da implementare"
int deposit_confirmations
int draw_confirmations
bigint max_round_pool_sats
bool paused
string paused_reason "da implementare"
string paused_reason
datetime updated_at
}
pending_transactions {
@@ -345,9 +346,7 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
*Codice:* `app/deposits/service.py:find_new_credit_candidates` ·
`app/deposits/service.py:credit_confirmed_utxos` ·
`app/electrum/listener.py:ElectrumListener.refresh_user` ·
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-82) · *Oggi:* si accredita ogni output
con `height > 0` (1 conferma), coinbase comprese.
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`, `tests/unit/test_deposit_confirmations.py`
- **[DAT-11]** Gli UTXO dei montepremi e della riserva **non** sono in
`utxo_events`: non appartengono a un utente. Il pagamento di un round spende
@@ -360,11 +359,10 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
lo stesso motivo l'abbandono di un pagamento non ha input da liberare.
*Codice:* `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie fra tutti gli
UTXO confermati dell'indirizzo del round (per un round vecchio, del
condiviso), lette dal solo server attivo, e non solo fra le uscite delle
scommesse dei partecipanti.
`app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_pool_verification.py::test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else`
Un round aperto prima dell'indirizzo per round (il cui montepremi è
l'indirizzo di riserva condiviso) conserva la scelta di prima: le monete
confermate dell'indirizzo, lette dal server attivo.
- **[DAT-12]** `spent_txid` ha tre significati:
- `NULL`: l'output è spendibile e conta nel saldo;
@@ -429,8 +427,9 @@ restano nella tabella come storico.
| `closed_at` | DateTime | sì | `NULL` | Chiusura delle scommesse, poi conferma del pagamento ([RND-28](ciclo-di-vita-del-round.md)) | `app/rounds/scheduler.py:RoundScheduler._tick`; `app/rounds/confirmation.py:_on_payout_confirmed` |
| `duration_seconds` | Integer | no | `600` (anche `server_default`) | Durata di **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` |
| `cooldown_seconds` | Integer | no | `30` (anche `server_default`) | Pausa dopo **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` |
| `bet_amount_sats` | BigInteger | no | backfill da `round_config` nella migrazione | Costo della scommessa di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` |
| `fee_address` | String(128) | no | backfill da `round_config` nella migrazione | Destinatario della commissione di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` |
| `bet_amount_sats` | BigInteger | no | backfill da `round_config` nella migrazione | Costo della scommessa di **questo** round, copiato all'apertura ([DAT-61]) | `open_new_round_if_needed` |
| `fee_address` | String(128) | no | backfill da `round_config` nella migrazione | Destinatario della commissione di **questo** round, copiato all'apertura ([DAT-61]) | `open_new_round_if_needed` |
| `max_round_pool_sats` | BigInteger | no | `0` (`server_default`); backfill `0` | Tetto alla somma delle scommesse di **questo** round, copiato all'apertura ([DAT-61], [DAT-27]); `0` = nessun tetto | `open_new_round_if_needed` |
| `draw_confirmations` | Integer | no | `3` (`server_default`) | Conferme richieste sul blocco di estrazione di **questo** round, copiate all'apertura ([DAT-61]) | `open_new_round_if_needed` |
| `pool_address` | String(128) | no | `""` solo fino alla fine della transazione di apertura; backfill con l'indirizzo di riserva per i round esistenti | Indirizzo del montepremi di **questo** round ([DAT-60]) | `open_new_round_if_needed` |
| `server_seed_enc` | Text | sì | `NULL` | Il segreto del round cifrato con Fernet, con la stessa chiave della master xprv; scritto all'apertura ([DAT-64]) | `open_new_round_if_needed` |
@@ -443,12 +442,12 @@ restano nella tabella come storico.
| `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_complete_draw` |
| `seed_int` | String(128) | sì | `NULL` | Il seme in decimale (fino a 78 cifre, per questo è una stringa). Il valore intero di `sha256(server_seed ‖ block_hash ‖ participants_hash)`, con la codifica definita in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (RND-17) | `_complete_draw` |
| `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_complete_draw` |
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`, `_complete_draw`; `RoundScheduler._trigger_payout` (atteso, B-82) |
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`, `_complete_draw`; `RoundScheduler._trigger_payout` (la ridefinisce la verifica, solo al ribasso) |
| `winner_amount_sats` | BigInteger | sì | `NULL` | Quanto riceve il vincitore (70% meno la commissione di rete) | `RoundScheduler._trigger_payout` |
| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` nello schema atteso | `_trigger_payout` |
| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` | `_trigger_payout` |
| `bump_reserve_sats` | BigInteger | sì | `NULL` | Output di riserva per l'RBF del pagamento, verso l'indirizzo di riserva ([DAT-73]) | `_trigger_payout`; `app/tx/broadcast.py:bump_fee` |
| `payout_txid` | String(64) | sì | `NULL` | Txid corrente della transazione di pagamento | `_trigger_payout`; `_retarget_txid_references`; `app/tx/reconcile.py:_abandon` |
| `payout_blocked_at` | DateTime | sì | `NULL` | Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) — *da implementare (B-82)* | `_trigger_payout`; `app/api/routes/admin.py:resume_lottery` |
| `payout_blocked_at` | DateTime | sì | `NULL` | Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) | `_trigger_payout`; `app/api/routes/admin.py:resume_lottery` |
- ~~**[DAT-15]**~~ Assorbito da
[RND-02](ciclo-di-vita-del-round.md): i valori di `status` e le transizioni
@@ -536,11 +535,7 @@ restano nella tabella come storico.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/tx/broadcast.py:_retarget_txid_references` · `app/tx/reconcile.py:_abandon` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`,
`tests/unit/test_reconcile.py`
*Stato:* da implementare (B-96) · *Oggi:* l'abbandono azzera solo
`payout_txid` e lascia `winner_amount_sats`, `fee_amount_sats` e
`bump_reserve_sats` del tentativo abbandonato (le riscrive il tentativo
successivo).
`tests/unit/test_reconcile.py`, `tests/unit/test_state_consistency.py`
- **[DAT-60]** Ogni round ha il proprio indirizzo del montepremi,
`pool_address`, derivato all'apertura da `m/84'/746'/0'/2/<id>` e scritto
@@ -563,28 +558,26 @@ restano nella tabella come storico.
tabella con righe; il codice lo riscrive prima del commit e ogni consumatore
rifiuta un indirizzo vuoto.
- **[DAT-61]** `bet_amount_sats`, `fee_address` e `draw_confirmations` di
`rounds` sono una **copia** dei valori di `round_config` presa quando il
- **[DAT-61]** `bet_amount_sats`, `fee_address`, `max_round_pool_sats` e
`draw_confirmations` di `rounds` sono una **copia** dei valori di `round_config` presa quando il
round si apre, nella stessa transazione dell'inserimento, e non cambiano
più. Le scommesse del round costano `rounds.bet_amount_sats`, il suo
pagamento paga la commissione a `rounds.fee_address`, la sua estrazione
attende `rounds.draw_confirmations` conferme. La regola di dominio è in
attende `rounds.draw_confirmations` conferme, le scommesse oltre il tetto
`rounds.max_round_pool_sats` sono rifiutate. La regola di dominio è in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-95). La
migrazione riempie le righe esistenti con i valori attuali di
`round_config` ([DAT-55]).
*Codice:* `app/rounds/service.py:open_new_round_if_needed` ·
`app/bets/service.py:place_bet` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* —
*Stato:* da implementare (B-95) · *Oggi:* `draw_confirmations` è già copiato
all'apertura (B-73); `place_bet` e `_trigger_payout`
leggono `bet_amount_sats` e `fee_address` da `round_config` al momento
dell'uso, quindi una modifica a metà round cambia il round in corso.
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_round_snapshot.py`, `tests/unit/test_rounds_service.py`, `tests/unit/test_migrations.py`
- **[DAT-64]** Il segreto del round sono 32 byte casuali generati con
`secrets.token_bytes(32)`. All'inserimento del round si scrivono insieme
`server_seed_hash` (`sha256` di quei 32 byte, esadecimale minuscolo) e
`server_seed_enc`: il segreto **cifrato con Fernet** usando la stessa chiave
che cifra la master xprv (`XPRV_ENCRYPTION_KEY`, dopo B-91 il file di
che cifra la master xprv (il file di cifratura,
[sicurezza.md](sicurezza.md#sec-53) SEC-53). Il segreto in chiaro non viene
mai scritto nel database prima della rivelazione: `server_seed` resta
`NULL` e si valorizza (esadecimale minuscolo, 64 caratteri) nella stessa
@@ -622,10 +615,8 @@ restano nella tabella come storico.
database**. Il valore scritto è sempre minore o uguale al precedente.
La regola di dominio (quorum, ripartizione su ciò che c'è) è in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-82).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* —
*Stato:* da implementare (B-82) · *Oggi:* `pool_amount_sats` non cambia dopo
l'estrazione, e un pagamento che non trova tutte le scommesse fallisce con
"insufficient pool UTXOs" a ogni tentativo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
*Test:* `tests/unit/test_pool_verification.py`
- **[DAT-68]** `payout_blocked_at` si scrive quando la verifica di [DAT-67]
trova assente la scommessa del **vincitore**: il round resta `paying_out`,
@@ -634,8 +625,8 @@ restano nella tabella come storico.
([DAT-69]). Torna `NULL` solo con `POST /admin/resume` ([api.md](api.md)),
dopo di che la verifica si ripete al tentativo successivo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/api/routes/admin.py:resume_lottery` · *Test:* —
*Stato:* da implementare (B-82) · *Oggi:* la colonna non esiste.
`app/api/routes/admin.py:resume_lottery` ·
*Test:* `tests/unit/test_pool_verification.py::test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery`, `tests/unit/test_pool_verification.py::test_after_the_resume_the_verification_repeats`, `tests/unit/test_admin.py::test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain`
- **[DAT-73]** Ogni pagamento ha un output di **riserva** verso l'indirizzo
di riserva (`m/84'/746'/0'/1/0`), di valore
@@ -691,7 +682,7 @@ Una scommessa: un utente in un round.
broadcast --> confirmed: _on_bet_confirmed
building --> [*]: cancellata (_release_failed_bet / _abandon)
broadcast --> [*]: cancellata (_abandon)
confirmed --> reorged_out: verifica prima del pagamento (da implementare)
confirmed --> reorged_out: verifica prima del pagamento
confirmed --> [*]
reorged_out --> [*]
```
@@ -703,7 +694,7 @@ Una scommessa: un utente in un round.
| `building`, `broadcast` | `confirmed` | `app/bets/confirmation.py:_on_bet_confirmed` |
| `building` | cancellata | `app/bets/service.py:_release_failed_bet` (broadcast rifiutato esplicitamente) |
| `building`, `broadcast` | cancellata | `app/tx/reconcile.py:_abandon` (abbandono con prova) |
| `confirmed` | `reorged_out` | `app/rounds/scheduler.py:RoundScheduler._trigger_payout` (da implementare) |
| `confirmed` | `reorged_out` | `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` (da `_trigger_payout`), per ogni partecipante tranne il vincitore ([RND-39](ciclo-di-vita-del-round.md#4--pagamento)) |
Se il compare-and-set di [DAT-16] fallisce, la riga `building` non arriva
nemmeno al commit. Un broadcast dall'esito ambiguo (timeout, connessione
@@ -713,10 +704,7 @@ Una scommessa: un utente in un round.
`app/bets/service.py:_release_failed_bet` · `app/tx/reconcile.py:_promote` ·
`app/tx/reconcile.py:_abandon` · `app/bets/confirmation.py:_on_bet_confirmed` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_confirmation.py`,
`tests/unit/test_reconcile.py`
*Stato:* da implementare (B-82, B-76) · *Oggi:* `reorged_out` non esiste (un
partecipante confermato la cui scommessa sparisce resta `confirmed`), e
qualunque eccezione del broadcast cancella la riga come un rifiuto.
`tests/unit/test_reconcile.py`, `tests/unit/test_pool_verification.py`
- **[DAT-25]** `broadcast_at` è l'istante della fase 1 di `place_bet`, appena
prima del broadcast, e non viene mai riscritto (nemmeno da un bump RBF). Non
@@ -754,21 +742,18 @@ Una scommessa: un utente in un round.
moltiplicando il numero di partecipanti per il costo (B-11).
*Codice:* `app/bets/service.py:place_bet` ·
`app/wallet/psbt_builder.py:build_signed_transaction` ·
`app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_bets.py`
*Stato:* da implementare (B-95) · *Oggi:* il costo è
`round_config.bet_amount_sats` letto al momento della scommessa.
`app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py`
- **[DAT-27]** Il numero di righe di un round, contando tutti gli stati, non
supera `MAX_PARTICIPANTS_PER_ROUND` (400): la 401ª scommessa è rifiutata con
`round_full` prima che si muova denaro (B-52). Con
`round_config.max_round_pool_sats` diverso da 0 ([DAT-62]), una scommessa è
rifiutata con `round_full` anche quando la somma dei `bet_amount_sats` del
round, contando tutti gli stati, ha già raggiunto quel tetto; la regola di
`rounds.max_round_pool_sats` (copiato da `round_config` all'apertura,
[DAT-61], [DAT-62]) diverso da 0, una scommessa è rifiutata con `round_full`
anche quando la somma dei `bet_amount_sats` del round, contando tutti gli
stati, ha già raggiunto quel tetto; la regola di
dominio è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95).
*Codice:* `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py` ·
*Test:* `tests/unit/test_bets.py`
*Stato:* da implementare (B-95), solo per il tetto sul montepremi · *Oggi:*
esiste solo il limite dei 400 partecipanti.
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py`
### `round_config`
@@ -778,16 +763,16 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
| Colonna | Tipo | Default | Limiti accettati da `PUT /admin/config` | Significato | Quando ha effetto |
|---|---|---|---|---|---|
| `id` | Integer PK | autoincrement | — | — | — |
| `fee_address` | String(128), non null | nessun default nel modello; la riga nasce con `""` | indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Atteso: dal round **successivo**, copiato all'apertura ([DAT-61]). Oggi: subito, si legge a ogni costruzione del pagamento, quindi vale anche per il round in corso se il suo pagamento non è ancora stato costruito |
| `bet_amount_sats` | BigInteger | `1_000_000_000` (10 PLM) | `> 0`, `≤ 100 000 PLM` | Costo della scommessa; anche il prelievo minimo | Atteso: per le scommesse dal round **successivo** ([DAT-61]), per il prelievo minimo subito. Oggi: subito, per le scommesse e i prelievi successivi; le scommesse già piazzate tengono il proprio importo ([DAT-26]) |
| `fee_address` | String(128), non null | nessun default nel modello; la riga nasce con `""` | indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Dal round **successivo**, copiato all'apertura ([DAT-61]); il round in corso paga la commissione all'indirizzo con cui si è aperto |
| `bet_amount_sats` | BigInteger | `1_000_000_000` (10 PLM) | `> 0`, `≤ 100 000 PLM` | Costo della scommessa; anche il prelievo minimo | Per le scommesse, dal round **successivo** ([DAT-61]); per il prelievo minimo, subito ([ADR-20](adr/ADR-20-prelievo-minimo-costo-corrente-scommessa.md)). Le scommesse già piazzate tengono il proprio importo ([DAT-26]) |
| `round_duration_seconds` | Integer | `600` | 30 s – 7 giorni | Durata del round | Dal round **successivo** ([RND-05](ciclo-di-vita-del-round.md)) |
| `round_cooldown_seconds` | Integer | `30` | 0 s – 24 h | Pausa dopo la chiusura | Dal round **successivo**: la pausa dopo il round in corso è quella copiata alla sua apertura ([RND-05](ciclo-di-vita-del-round.md)) |
| `draw_animation_seconds` | Integer | `20` | 0 – 600 s | Durata minima dell'animazione dell'estrazione nel frontend; non condiziona l'estrazione vera | Subito (letto da `GET /rounds/current`) |
| `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (10 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` |
| `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (5 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` |
| `rbf_timeout_seconds` | Integer | `900` | 60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) |
| `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) — *da implementare (B-82)* | Subito, per gli accrediti successivi; gli output già accreditati restano |
| `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) | Subito, per gli accrediti successivi; gli output già accreditati restano |
| `draw_confirmations` | Integer | `3` | 1 – 20 | Conferme richieste sul blocco di estrazione ([DAT-62]) | Dal round **successivo**, copiato all'apertura ([DAT-61]) |
| `max_round_pool_sats` | BigInteger | `0` (tetto disattivato) | `0`, oppure `≥ bet_amount_sats` e `≤ 4 000 000 000 000 000` (400 × 100 000 PLM) | Tetto alla somma delle scommesse di un round ([DAT-27], [DAT-62]) — *da implementare (B-95)* | Subito, anche per il round in corso: vale per le scommesse successive |
| `max_round_pool_sats` | BigInteger | `0` (tetto disattivato) | `0`, oppure `≥ bet_amount_sats` e `≤ 4 000 000 000 000 000` (400 × 100 000 PLM) | Tetto alla somma delle scommesse di un round ([DAT-27], [DAT-62]) | Dal round **successivo**, copiato all'apertura ([DAT-61]): un round tiene il tetto con cui si è aperto |
| `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa della lotteria | Subito per l'apertura del round successivo; il round in corso non è toccato |
| `paused_reason` | String(16) | `NULL` | non modificabile da `PUT /admin/config` | Perché la lotteria è in pausa ([DAT-69]) | Insieme a `paused` |
| `updated_at` | DateTime | `utcnow`, aggiornato a ogni modifica (`onupdate`) | — | Ultima modifica della riga | ORM |
@@ -810,8 +795,7 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
valore uguale a quello attuale non conta come modifica.
*Codice:* `app/api/routes/admin.py:update_config` ·
`app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82, B-95), per le ultime due colonne ·
*Oggi:* le colonne modificabili sono le prime otto.
- **[DAT-30]** `paused` e `paused_reason` si modificano solo con
`POST /admin/pause` e `POST /admin/resume`, che scrivono `lottery_paused` e
@@ -819,8 +803,6 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
[DAT-69], che scrivono la propria voce di audit.
*Codice:* `app/api/routes/admin.py:pause_lottery` ·
`app/api/routes/admin.py:resume_lottery` · *Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82) · *Oggi:* le pause automatiche sono
`resurrected_tx` (B-78) e `reorg` (B-85); quella di B-82 non esiste ancora.
- **[DAT-62]** `deposit_confirmations` (predefinito 6) è il numero di
conferme dopo cui un output che paga un utente si accredita ([DAT-10]);
@@ -834,10 +816,7 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
[scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95). La migrazione le
aggiunge con un `server_default` temporaneo pari al predefinito ([DAT-55]).
*Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82, B-95) · *Oggi:* `deposit_confirmations` e
`max_round_pool_sats` non esistono: i depositi usano 1 conferma e non c'è
tetto; `draw_confirmations` esiste.
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_migrations.py`
- **[DAT-69]** `paused_reason` è `NULL` esattamente quando `paused` è falso;
con `paused` vero vale uno di questi valori, e nessun altro:
@@ -861,9 +840,6 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
`app/electrum/listener.py:ElectrumListener._record_reorg` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/tx/reconcile.py:reconcile_once` · *Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-82, B-90) · *Oggi:* si scrivono
`operator` (`POST /admin/pause`), `resurrected_tx` (il riconciliatore) e
`reorg` (il listener); `payout_blocked` e `START_PAUSED` non hanno ancora chi li scrive.
- **[DAT-31]** Con `fee_address` vuoto (o di soli spazi) non si apre nessun round
(B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la
@@ -1274,11 +1250,7 @@ letti con una scansione completa.
*Codice:* `app/rounds/service.py:winner_share` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie fra
tutti gli UTXO confermati dell'indirizzo del round, senza la verifica di
[DAT-67]; l'identità con la riserva vale già per i round pagati con tutti
e soli i loro ingressi.
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py`, `tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there`
- ~~**[DAT-47]**~~ Assorbito da [RND-12](ciclo-di-vita-del-round.md): l'attesa
delle scommesse in volo prima dell'estrazione è comportamento del round. Il
+42 -47
View File
@@ -44,9 +44,9 @@ flowchart LR
Docker con nome `caddy_data` e `caddy_config`, non in `./data`.
*Codice:* `docker-compose.yml`, `Caddyfile`
Oggi il container gira come root, e i file che scrive in `./data` (database,
backup, chiave cifrata dagli script) appartengono a root sull'host. Dopo B-91
gira come utente non root, con `./data` in `0700` e i file di chiave in
Il container gira come utente non root (uid 1000, quello dell'utente dell'host:
un servizio `data-permissions`, a ogni avvio, riporta `./data` a quel
proprietario), con `./data` in `0700` e i file di chiave in
`0600`, e la chiave di cifratura sta in un file a parte
([architettura.md](architettura.md) ARC-03, ARC-05;
[SEC-51](sicurezza.md#sec-51)); le procedure che scrivono in `./data`
@@ -69,9 +69,6 @@ dovranno allora rispettare quel proprietario.
`app/config.py:validate_runtime_secrets`,
`app/electrum/client.py:parse_endpoints`, `app/wallet/hd.py:_load_account_key` ·
*Test:* `tests/unit/test_config.py`, `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-89) · *Oggi:* della master key si controlla
solo che il file esista e che `XPRV_ENCRYPTION_KEY` non sia vuota; una
chiave sbagliata emerge alla prima derivazione.
- **[OPS-03]** Il database SQLite lavora in modalità **WAL**: accanto a
`plm_lottery.db` esistono `plm_lottery.db-wal` e `plm_lottery.db-shm`, e le
@@ -86,7 +83,7 @@ dovranno allora rispettare quel proprietario.
[Segnali da tenere d'occhio](#segnali-da-tenere-docchio)).
*Codice:* `app/main.py:health`
Dopo B-92 esiste anche `GET /health/ready`, che risponde 503 se la
Esiste anche `GET /health/ready`, che risponde 503 se la
connessione Electrum manca, l'ultimo header è vecchio o lo scheduler non gira
([api.md](api.md) API-46), ed è il `healthcheck` del container
([architettura.md](architettura.md) ARC-61). Un container `unhealthy` **non**
@@ -109,7 +106,7 @@ Il file da salvare è `./data/keys/master.xprv.enc`, quello che il container
legge. Su questa installazione esiste anche una seconda copia,
`./master.xprv.enc` nella radice del progetto, a cui punta il
`MASTER_KEY_PATH` di `.env` (B-71): oggi decifrano alla stessa xprv, ma non è
il file dell'app. Dopo B-91 la chiave di cifratura non sta più in `.env` ma
il file dell'app. La chiave di cifratura non sta più in `.env` ma
nel file `./secrets/xprv_encryption.key` ([SEC-53](sicurezza.md#sec-53),
[architettura.md](architettura.md) ARC-05), fuori da `./data/`: si salva come
oggi la variabile, separata dalla master key.
@@ -165,7 +162,7 @@ denaro di oggi.
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); c.execute('UPDATE round_config SET paused=1'); c.commit(); print('pausa impostata')"
```
La modifica non lascia una riga di audit: annotarla altrove.
Dopo B-90 si usa invece `START_PAUSED=true` in `.env` ([OPS-27]), che
Si usa invece `START_PAUSED=true` in `.env` ([OPS-27]), che
scrive anche il motivo della pausa e la riga di audit.
5. **Elencare, sempre ad app ferma, i round non chiusi e le transazioni in
volo** del backup:
@@ -238,8 +235,7 @@ denaro di oggi.
Gli utenti registrati dopo il backup spariscono con il ripristino, e i loro
indici vengono riassegnati ai prossimi iscritti: il nuovo utente riceve lo
**stesso indirizzo** di un utente perso, e con esso i fondi che quello vi
aveva depositato. Oggi il codice non ha alcuna protezione: l'unica è la
procedura [OPS-28]. Dopo B-90 l'app controlla da sola gli indici successivi
aveva depositato. L'app lo previene: controlla da sola gli indici successivi
e sospende le registrazioni
([REG-15](registrazione-e-depositi.md#registrazione)); la procedura resta il
modo di rimuovere la sospensione. Gli utenti persi non si possono
@@ -256,13 +252,11 @@ denaro di oggi.
("Riprendi lotteria"). Con il valore predefinito `false` l'avvio non tocca
la pausa.
*Codice:* `app/config.py:Settings`, `app/main.py:lifespan`,
`app/rounds/service.py:open_new_round_if_needed` ·
*Test:* —
*Stato:* da implementare (B-90) · *Oggi:* la variabile non esiste; la pausa
si imposta a mano nel database (passo 4 di [OPS-07]).
`app/rounds/pause.py:pause_by_operator` ·
*Test:* `tests/unit/test_restore_gap.py`
- **[OPS-28]** **Occupare gli indici di utenti persi.** Procedura, ad app
ferma, valida oggi e dopo B-90:
ferma:
1. Trovare il massimo indice assegnato e ricavare gli indirizzi dei 20
indici successivi:
```bash
@@ -286,7 +280,7 @@ denaro di oggi.
conoscere il proprio indirizzo, gli si consegna il segnaposto
corrispondente con un reset della password ([OPS-19]).
Dopo B-90 il controllo automatico di
Il controllo automatico di
[REG-15](registrazione-e-depositi.md#registrazione), al riavvio successivo,
non trova più indirizzi usati oltre i segnaposto e le registrazioni
riprendono.
@@ -395,7 +389,7 @@ container (controllo della chiave, migrazioni) e ciò che uvicorn scrive per
conto suo (avvio, richieste, errori di avvio). Vedi
[audit-log.md](audit-log.md#log-applicativo).
Dopo B-92 il log applicativo va anche sull'output del container e si legge
Il log applicativo va anche sull'output del container e si legge
con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
### Electrum irraggiungibile
@@ -498,7 +492,9 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
- **[OPS-14]**
**Sintomi.** `GET /rounds/current` riporta `status: "closing"` a lungo, con
`has_pending_bets` vero; nel log compare
`has_pending_bets` vero e `closing_waiting_since` valorizzato; dopo 30 minuti oltre la
scadenza il registro di audit riceve `closing_stalled` (con i txid ancora in volo), ripetuto
ogni 30 minuti ([TX-29](transazioni-e-riconciliazione.md#riconciliazione)); nel log compare
`round N: N bet(s) still in flight at close time, waiting` o nulla (il
controllo principale non scrive). In "Transazioni pendenti" c'è almeno una
riga `bet` di quel round in `building` o `pending`.
@@ -561,7 +557,7 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
(`m/84'/746'/0'/1/0`, anche il montepremi dei round aperti prima di B-80) si
ottiene con
`docker compose exec app python -c "from app.wallet.hd import derive_pool_address; print(derive_pool_address())"`;
se ne può controllare il saldo con un explorer. Dopo B-82 un'uscita di
se ne può controllare il saldo con un explorer. Un'uscita di
scommessa scomparsa dalla catena esclude quel partecipante invece di
bloccare il pagamento; solo la scommessa scomparsa del vincitore lo blocca
([OPS-30]).
@@ -608,15 +604,18 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
### RBF impossibile
- **[OPS-17]**
**Sintomi.** Nel log, ogni 30 secondi per la stessa riga,
`could not bump pending_transaction N` con un `RbfError`: "no change output
large enough to absorb a fee bump" oppure "already at the maximum fee rate".
Nessuna riga di audit. `attempt_count` smette di salire. Un aumento che la
rete rifiuta (per esempio perché il resto scenderebbe sotto la soglia di
polvere) compare invece come errore di invio nello stesso messaggio.
**Causa.** L'RBF riduce solo il resto che torna al mittente; se il resto non
basta a coprire l'aumento, o la commissione è già al massimo (10.000 sat/vB),
non si può fare. Un pagamento ha un'uscita di riserva
**Sintomi.** Una riga `rbf_bump_failed` nel registro di audit (sezione
Audit log del pannello, con `pending_transaction_id`, `txid` e `reason`):
`change_too_small`, `max_fee_rate` o `replacement_rejected`. Se ne scrive una
sola per transazione e motivo; nel log, ogni 30 secondi, resta
`could not bump pending_transaction N`. `attempt_count` smette di salire.
**Causa.** L'RBF riduce un'unica uscita, il resto che torna al mittente; se
l'aumento la porterebbe sotto la soglia di polvere l'uscita viene eliminata
e il suo valore va in commissione, e quella sostituzione è l'ultima
(`change_too_small` ai tentativi successivi). Non si può fare nemmeno se
l'uscita non esiste o non basta all'aumento minimo, o se la commissione è già
al massimo (5.000 sat/vB, `max_fee_rate`); `replacement_rejected` è una
sostituzione che la rete ha rifiutato esplicitamente. Un pagamento ha un'uscita di riserva
(B-79) che l'aumento riduce; la riserva è dimensionata per arrivare a circa
10 sat/vB, oltre la quale (o per un pagamento costruito prima di B-79, che
non ce l'ha) la transazione non si può accelerare.
@@ -628,9 +627,6 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
([OPS-23]); il sistema accrediterà l'uscita della nuova transazione come un
normale deposito. Per un pagamento non c'è un'alternativa supportata: la
chiave del montepremi non è esportabile ([OPS-24]).
Dopo B-87 un aumento che lascerebbe un resto sotto la soglia di polvere
toglie il resto invece di fallire, e ogni fallimento scrive
`rbf_bump_failed`.
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:RbfBumper._tick` ·
*Test:* `tests/unit/test_broadcast.py`
@@ -639,10 +635,12 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
- **[OPS-18]**
**Sintomi.** Un utente segnala un deposito che non vede nel saldo.
**Cause, in ordine di probabilità.**
1. La transazione non ha ancora le conferme richieste: oggi 1, dopo B-82
`deposit_confirmations` (predefinito 6) e 120 per un'uscita coinbase
1. La transazione non ha ancora le conferme richieste:
`deposit_confirmations` (predefinito 6, modificabile da `/admin`) e 120
per un'uscita coinbase
([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito)).
Il deposito non viene mostrato nemmeno come "in attesa" finché non le ha.
Nel frattempo compare come deposito *in attesa* nel saldo mostrato
(ambra), non spendibile; si accredita da solo al blocco in cui matura.
2. La corroborazione fallisce: nel log
`N new UTXO(s) for user_id=… not corroborated by the other servers` e
spesso `could not corroborate … with any of N other configured server(s)`.
@@ -752,9 +750,9 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
copia cifrata con essa: la copia nella radice (`./master.xprv.enc`, B-71)
e i backup precedenti del file. Il file scritto dal container appartiene a
root con permessi `0644`: `sudo chmod 600 data/keys/master.xprv.enc`.
Dopo B-89 gli script scrivono `0600` e rifiutano una xprv i cui
Gli script scrivono `0600` e rifiutano una xprv i cui
indirizzi non coincidono con quelli del database
([SEC-50](sicurezza.md#sec-50)); dopo B-91 la chiave sta in un file e non
([SEC-50](sicurezza.md#sec-50)); la chiave sta in un file e non
in `.env` ([SEC-53](sicurezza.md#sec-53)).
**Se è trapelata la master key stessa** (file cifrato *e* chiave, oppure la
@@ -790,9 +788,6 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
`app/tx/confirmation.py:poll_once`,
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-82, B-90) · *Oggi:* il sistema mette in
pausa la lotteria per `resurrected_tx` (B-78) e `reorg` (B-85);
`payout_blocked` non esiste ancora.
### Intervento manuale sui fondi
@@ -854,17 +849,17 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
| Log | `data/logs/app.log` | `all N Electrum server(s) unreachable`, `round scheduler tick failed`, `could not bump`, `invariant violated` |
| Disco | `./data/db`, `./data/logs` | Il database cresce senza limite (registro di audit incluso); i log ruotano a 6 × 10 MB |
| Container | `docker compose ps` | `app` che si riavvia in continuazione: rifiuto all'avvio ([OPS-02]) |
| Prontezza (dopo B-92) | `GET /health/ready`, `docker compose ps` | 503 / `unhealthy` ([OPS-04]) |
| Prontezza | `GET /health/ready`, `docker compose ps` | 503 / `unhealthy` ([OPS-04]) |
| Catena e sistema (dopo B-78, B-82, B-85, B-87, B-90) | Audit: `chain_reorg_detected`, `pending_tx_resurrected`, `payout_blocked`, `participant_reorged_out`, `rbf_bump_failed`, `restore_gap_detected`; `paused_reason` diverso da `operator` | Qualunque occorrenza ([OPS-30], [OPS-28]) |
*Codice:* `app/api/routes/rounds.py`, `app/api/routes/admin.py`,
`app/logging_config.py:setup_logging`
Dopo B-92 l'indicatore Electrum di `/admin` mostra lo stato della
L'indicatore Electrum di `/admin` mostra lo stato della
connessione ([ELC-29](connessione-electrum.md#stato-della-connessione)):
connessa o no, server attivo, altezza, età dell'ultimo header. Oggi mostra
solo l'ultima altezza nota ("Blocco #N"), che resta visibile anche a
connessione caduta.
connessa o no, server attivo, età dell'ultimo header e, al passaggio del
mouse, l'ultimo cambio di server con il motivo; la barra mostra anche l'ultima
altezza nota ("Blocco #N"), che resta visibile a connessione caduta.
## Prova su rete reale
@@ -892,7 +887,7 @@ proposito.
| 6 | Riorganizzazione di 1 blocco | Solo su una rete di prova con due minatori, se disponibile | La punta segue il ramo nuovo senza chiudere la sessione; `chain_reorg_detected` con profondità 1; nessuna pausa. Con profondità 2: pausa con motivo `reorg` (B-85, B-74) |
| 7 | Cronologia molto lunga | Indirizzo con risposta oltre 64 KiB (per esempio mille piccoli invii a un indirizzo di prova) | La connessione resta aperta (limite di 16 MiB); le uscite sotto `DUST_LIMIT_SATS` non vengono accreditate (B-75) |
| 8 | Tutte le riserve giù | Bloccare con il firewall ogni server di `ELECTRUM_FALLBACK_SERVERS` | Accrediti rinviati, estrazione in attesa, nessuna spesa esterna registrata, nessun denaro mosso; al ritorno dei server tutto riprende. Un avvio con meno di 2 riserve è rifiutato (B-84) |
| 9 | Server connesso ma fermo | Un server Electrum di prova che smette di seguire la catena | Dopo 360 secondi senza avanzamento il listener passa a un server con una punta più alta (B-86); `GET /health/ready` risponde 503 nel frattempo (B-92) |
| 9 | Server connesso ma fermo | Un server Electrum di prova che smette di seguire la catena | Dopo 360 secondi senza avanzamento il listener passa a un server con una punta più alta (B-86); `GET /health/ready` risponde 503 solo oltre 1800 secondi senza header (B-92) |
| 10 | Salto dell'orologio | Portare avanti l'orologio dell'host di 7 ore con transazioni `pending` | Nessuna transazione abbandonata per il solo tempo trascorso (B-78) |
| 11 | Disco pieno | Riempire il filesystem di `./data/db` durante l'attività | Nessuna transazione trasmessa senza la sua riga salvata prima; scommesse e prelievi falliscono prima dell'invio; liberato lo spazio, il sistema riprende senza interventi sul database |
| 12 | Ripristino da backup | Backup, poi un round completo e una registrazione, poi [OPS-07] | Avvio in pausa con `START_PAUSED=true`; `restore_gap_detected` e registrazioni rifiutate; il round rigiocato non viene pagato una seconda volta (B-90, B-80, B-82) |
@@ -921,7 +916,7 @@ master key usare il container.
`generate_master_key.py` non ha il ripiego su `./data/keys/` degli altri
due: dal venv scrive esattamente dove dice `MASTER_KEY_PATH` di `.env`. I
file scritti hanno i permessi predefiniti del sistema (di norma `0644`) e,
nel container, appartengono a root. Dopo B-89 gli script scrivono `0600` e
nel container, appartengono a root. Gli script scrivono `0600` e
rifiutano una xprv diversa da quella degli indirizzi già nel database
([SEC-50](sicurezza.md#sec-50)).
*Codice:* `scripts/generate_master_key.py`, `app/wallet/hd.py:generate_master_key`,
+18 -42
View File
@@ -97,16 +97,11 @@ L'utente sceglie username e password (`POST /auth/register`). Il server:
ricostruire gli utenti mancanti (procedura in
[operativita.md](operativita.md#ripristino), B-90). La registrazione è
rifiutata allo stesso modo finché il controllo non è stato completato.
*Codice:* `app/main.py:lifespan`, `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` · *Test:* —
*Stato:* da implementare (B-90) · *Oggi:* nessun controllo; dopo un
ripristino il prossimo utente riceve il primo indice non presente nel
database, anche se il suo indirizzo ha già ricevuto fondi.
*Codice:* `app/electrum/listener.py:ElectrumListener._check_restore_gap`, `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` · *Test:* `tests/unit/test_restore_gap.py`
- **[REG-16]** Ogni registrazione riuscita scrive l'evento `user_registered`
con l'indice di derivazione e l'indirizzo assegnati, nella stessa
transazione che crea l'utente.
*Codice:* `app/auth/routes.py:register` · *Test:* —
*Stato:* da implementare (B-92) · *Oggi:* la registrazione non scrive alcun
evento di audit.
*Codice:* `app/auth/routes.py:register` · *Test:* `tests/unit/test_observability.py`
### Sessione e password
@@ -126,8 +121,6 @@ il freno sui tentativi di login falliti è
prima di qualunque verifica.
*Codice:* `app/auth/routes.py:login`, `app/auth/routes.py:LoginRequest` ·
*Test:* `tests/unit/test_auth.py`
*Stato:* da implementare (B-94), per la lunghezza massima · *Oggi:* i campi
del login non hanno limiti di lunghezza.
- ~~[REG-10]~~ Assorbito da [SEC-30](sicurezza.md#limiti-di-frequenza):
il freno sui login falliti (chiavi, soglie, azzeramento) non ha nulla di
specifico del dominio oltre a ciò che SEC-30 già dice.
@@ -148,10 +141,7 @@ il freno sui tentativi di login falliti è
login. Questo requisito è il proprietario del flusso; [sicurezza.md](sicurezza.md)
e [api.md](api.md) vi rimandano.
*Codice:* `app/api/routes/users.py:change_password` ·
*Test:* `tests/unit/test_users.py`
*Stato:* da implementare (B-94), per lunghezza massima, freno ed evento
`password_changed` · *Oggi:* nessun limite di lunghezza, nessun freno sui
tentativi, nessun evento di audit.
*Test:* `tests/unit/test_users.py`, `tests/unit/test_abuse_limits.py`
- **[REG-13]** Non esiste recupero password: non viene raccolta alcuna email.
L'unica strada è il reset dall'operatore
(`POST /admin/users/{id}/reset-password`), che imposta una password casuale
@@ -213,10 +203,7 @@ flowchart TB
H -- "Sì, e il quorum<br/>la conferma non spesa" --> I["Reintegrata<br/>(utxo_external_spend_reinstated)"]
```
*Comportamento atteso.* Oggi ogni moneta confermata si accredita a 1
conferma, di qualunque importo e anche se coinbase; il reintegro non è
corroborato; gli aggiornamenti dello stesso utente non sono in fila; le
monete sotto la soglia non compaiono nel saldo mostrato.
Gli aggiornamenti dello stesso utente sono messi in fila ([DEP-15]).
- **[DEP-01]** Non c'è un importo minimo di deposito oltre alla soglia di
polvere: una moneta di valore inferiore a `DUST_LIMIT_SATS` (294 sat) non
@@ -260,10 +247,8 @@ monete sotto la soglia non compaiono nel saldo mostrato.
Una moneta accreditata e poi sparita per una riorganizzazione più profonda
della soglia segue la strada delle monete spese altrove
([DEP-06], [DEP-07]); non c'è altra logica di annullamento.
*Codice:* `app/deposits/service.py:find_new_credit_candidates`, `app/electrum/listener.py:ElectrumListener.refresh_user` ·
*Test:* `tests/unit/test_deposits.py`
*Stato:* da implementare (B-82) · *Oggi:* **una conferma basta** per ogni
moneta, deposito o coinbase che sia.
*Codice:* `app/deposits/service.py:find_new_credit_candidates`, `app/deposits/service.py:read_deposit_policy`, `app/deposits/service.py:classify_new_deposits`, `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/electrum/listener.py:ElectrumListener.is_coinbase` ·
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_deposit_confirmations.py`
- **[DEP-04]** **Ogni accredito è corroborato.** Prima di aumentare un saldo,
il server chiede agli *altri* server Electrum configurati se quella moneta
esiste davvero, con quell'importo e con le conferme richieste
@@ -312,11 +297,13 @@ monete sotto la soglia non compaiono nel saldo mostrato.
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_confirmation_proof.py`
- **[DEP-09]** **Due vie, un solo codice.** Un aggiornamento parte quando il
server notifica un cambiamento su un indirizzo osservato, alla
(re)iscrizione di un indirizzo, e al giro periodico: tutte e tre usano la
stessa funzione di aggiornamento, così i due percorsi non possono
comportarsi diversamente.
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/electrum/listener.py:ElectrumListener._subscribe_and_refresh`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
*Test:* `tests/unit/test_deposit_reconcile.py`
(re)iscrizione di un indirizzo, al giro periodico e, per gli utenti che
hanno un deposito in attesa ([DEP-14]), a ogni nuovo blocco — la notifica
dell'indirizzo non arriva quando un deposito matura, perché la sua
cronologia non cambia —: tutti usano la stessa funzione di aggiornamento,
così i percorsi non possono comportarsi diversamente.
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/electrum/listener.py:ElectrumListener._subscribe_and_refresh`, `app/electrum/listener.py:ElectrumListener._refresh_waiting_deposits`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
*Test:* `tests/unit/test_deposit_reconcile.py`, `tests/unit/test_deposit_confirmations.py::test_new_blocks_refresh_the_users_that_have_a_waiting_deposit`
- **[DEP-10]** <a id="dep-10"></a>Il giro periodico (`DepositReconciler`)
riaggiorna **tutti** gli utenti, non solo quelli che risultano non
iscritti: serve a recuperare un'iscrizione persa silenziosamente. Prima di
@@ -335,9 +322,7 @@ monete sotto la soglia non compaiono nel saldo mostrato.
prelievi ([scommesse-e-prelievi.md](scommesse-e-prelievi.md)), così una
corroborazione in corso non ritarda una scommessa. Utenti diversi si
aggiornano in parallelo come oggi.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` · *Test:* —
*Stato:* da implementare (B-96) · *Oggi:* due aggiornamenti dello stesso
utente possono girare insieme e tentare di accreditare la stessa moneta.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` · *Test:* `tests/unit/test_state_consistency.py`
- **[DEP-16]** Un errore durante l'aggiornamento di un utente non chiude la
connessione Electrum: viene registrato nei log e l'utente viene ripreso
all'aggiornamento successivo. In particolare una violazione dell'unicità
@@ -345,9 +330,7 @@ monete sotto la soglia non compaiono nel saldo mostrato.
si annulla la transazione del database di quell'accredito e si prosegue.
Fa eccezione solo la caduta della connessione stessa, che termina la
sessione come in [ELC-06](connessione-electrum.md#3-caduta-rilevata).
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/deposits/service.py:credit_confirmed_utxos` · *Test:* —
*Stato:* da implementare (B-96) · *Oggi:* l'eccezione risale da
`refresh_user` a `_consume_scripthash` e termina la sessione Electrum.
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/deposits/service.py:credit_confirmed_utxos` · *Test:* `tests/unit/test_state_consistency.py`
## Il saldo mostrato
@@ -388,9 +371,7 @@ corso (`has_pending`).
contata due volte. `has_pending` è vero finché esiste almeno una
transazione di (a) o un deposito di (b).
*Codice:* `app/wallet/balance.py:compute_pending_balance`, `app/api/routes/users.py:me` ·
*Test:* `tests/unit/test_balance.py`
*Stato:* da implementare (B-82), per la parte (b) · *Oggi:* conta solo la
parte (a).
*Test:* `tests/unit/test_balance.py`, `tests/unit/test_deposit_confirmations.py::test_the_displayed_balance_includes_waiting_deposits_but_the_spendable_one_does_not`, `tests/unit/test_users.py::test_me_shows_waiting_deposits_in_the_pending_balance_only`
- **[DEP-14]** **Depositi in attesa.** Una moneta sopra la soglia di polvere,
confermata (altezza maggiore di 0), non ancora registrata e con meno
conferme della sua soglia ([DEP-03]) è un *deposito in attesa*. Il listener
@@ -401,10 +382,8 @@ corso (`has_pending`).
non sposta denaro e non è spendibile, quindi non è corroborato — e dopo un
riavvio si ricostruisce alla prima reiscrizione ([ELC-18](connessione-electrum.md#riconnessione)).
Le monete ancora in mempool non sono depositi in attesa.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/wallet/balance.py:compute_pending_balance` · *Test:* —
*Stato:* da implementare (B-82) · *Oggi:* con la soglia a 1 conferma non
esistono depositi in attesa; una moneta in mempool non compare in nessun
saldo.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/electrum/listener.py:ElectrumListener.pending_deposit_sats`, `app/wallet/balance.py:compute_pending_balance` ·
*Test:* `tests/unit/test_deposit_confirmations.py::test_a_deposit_is_credited_exactly_at_six_confirmations`, `tests/unit/test_deposit_confirmations.py::test_a_waiting_deposit_that_vanishes_stops_being_shown`
- **[DEP-13]** Ogni spesa (scommessa, prelievo) usa solo monete accreditate e
non spese: il saldo mostrato è informativo, non spendibile. Se un utente
chiede un prelievo o una scommessa coperti dal saldo mostrato ma non ancora
@@ -414,6 +393,3 @@ corso (`has_pending`).
la scommessa [scommesse-e-prelievi.md](scommesse-e-prelievi.md), B-96).
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_withdrawals.py`
*Stato:* da implementare (B-96), per la scommessa · *Oggi:* solo il
prelievo distingue i due casi; una scommessa coperta solo dal saldo
mostrato riceve `insufficient_balance`.
+7 -21
View File
@@ -38,9 +38,7 @@ Prima dei controlli valgono due condizioni di contorno:
un'attesa sul blocco durante una riconnessione non fa usare una connessione
già chiusa; il controllo di [BET-01] si fa su quel valore.
*Codice:* `app/api/routes/bets.py:create_bet`, `app/tx/locks.py:UserLocks.acquire` ·
*Test:* —
*Stato:* da implementare (B-96) · *Oggi:* `listener.client` viene letto e
controllato prima di acquisire il blocco.
*Test:* `tests/unit/test_state_consistency.py`
Una scommessa (`POST /bets`, senza corpo) passa solo se **tutte** queste
condizioni sono vere, verificate nell'ordine della tabella; altrimenti viene
@@ -53,8 +51,8 @@ cifra. Il **costo** è quello del round ([BET-21]):
| **[BET-04]** | Il round è in stato `open` e la sua **scadenza** — apertura più la durata fotografata all'apertura (`opened_at + duration_seconds`), non lo stato nel database — non è ancora passata | `round_closing` |
| **[BET-05]** | L'utente non ha già una scommessa in questo round (in qualunque stato) | `already_betting` |
| **[BET-06]** | Il round ha meno di 400 partecipanti (`MAX_PARTICIPANTS_PER_ROUND`), contando anche quelli non ancora confermati | `round_full` (parametro `max_participants`) |
| **[BET-20]** | Se il round ha un tetto al montepremi (`max_round_pool_sats` fotografato all'apertura, [RND-05](ciclo-di-vita-del-round.md#0--apertura)) diverso da 0, la somma degli importi netti dei partecipanti del round, contando anche quelli non ancora confermati, è minore del tetto. *Stato:* da implementare (B-95) · *Oggi:* il tetto non esiste | `round_full` |
| **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`), come per il prelievo ([WDR-05](#prelievo)). *Stato:* da implementare (B-96) · *Oggi:* sempre `insufficient_balance`, anche quando il saldo mostrato all'utente copre la scommessa |
| **[BET-20]** | Se il round ha un tetto al montepremi (`max_round_pool_sats` fotografato all'apertura, [RND-05](ciclo-di-vita-del-round.md#0--apertura)) diverso da 0, la somma degli importi netti dei partecipanti del round, contando anche quelli non ancora confermati, è minore del tetto. | `round_full` |
| **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`), come per il prelievo ([WDR-05](#prelievo)). |
| **[BET-08]** | Il costo si copre con al massimo 50 monete (`MAX_TX_INPUTS`), scelte dalla più grande fino a coprirlo | `too_many_inputs` (parametro `max_inputs`) |
| **[BET-09]** | Le monete scelte ([BET-08]) superano il costo di almeno 294 sat (`DUST_LIMIT_SATS`), che restano come resto | `balance_leaves_no_change` (parametro `required_extra_sats`) |
| **[BET-10]** | Il costo meno la commissione di rete resta positivo e di almeno 294 sat | `amount_below_network_fee`, `amount_below_dust_limit` |
@@ -77,21 +75,14 @@ ancora `open` oltre la scadenza.
[WDR-09] scatta solo quando nessuna combinazione di monete lascia un resto
sufficiente.
*Codice:* `app/wallet/psbt_builder.py:select_utxos` ·
*Test:* —
*Stato:* da implementare (B-98) · *Oggi:* la selezione si ferma alla prima
moneta che copre il costo, e il resto si misura solo su quelle monete: con
10 + 1 PLM una scommessa da 10 PLM è rifiutata con `balance_leaves_no_change`
e un prelievo da 10 PLM viene ridotto invece di usare la seconda moneta.
*Test:* `tests/unit/test_psbt_builder.py`
- **[BET-21]** Il **costo** di una scommessa è il `bet_amount_sats`
fotografato sul round alla sua apertura
([RND-05](ciclo-di-vita-del-round.md#0--apertura)), non il valore corrente
di `round_config`: una modifica da `/admin` vale dal round successivo.
*Codice:* `app/bets/service.py:place_bet` ·
*Test:* —
*Stato:* da implementare (B-95) · *Oggi:* il costo è letto da
`round_config` a ogni scommessa, quindi una modifica vale subito anche per
il round in corso.
*Test:* `tests/unit/test_round_snapshot.py::test_every_bet_of_a_round_pays_the_roundd_cost_whatever_is_edited_meanwhile`
- **[BET-11]** La scadenza viene verificata due volte: all'arrivo della
richiesta ([BET-04]) e di nuovo dopo aver costruito e firmato la
transazione. L'iscrizione al round viene poi salvata nella stessa
@@ -248,9 +239,7 @@ esterno scelto dall'utente.
avviene tenendo il **blocco per-utente**; il client della rete viene letto
e controllato dopo aver acquisito il blocco, come in [BET-02].
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/tx/locks.py:UserLocks.acquire` ·
*Test:* — (nessun test verifica `network_unavailable`)
*Stato:* da implementare (B-96) · *Oggi:* `listener.client` viene letto e
controllato prima di acquisire il blocco.
*Test:* `tests/unit/test_state_consistency.py`
Le condizioni sono verificate nell'ordine della tabella; un rifiuto risponde
HTTP 400 prima che si muova qualunque cifra:
@@ -297,10 +286,7 @@ solo se la rete rifiuta esplicitamente, RBF se non conferma in tempo.
prelievo è rifiutato con `amount_below_minimum` (parametro `minimum_sats`)
prima che si muova qualunque cifra.
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
*Test:* —
*Stato:* da implementare (B-96) · *Oggi:* il minimo è verificato solo
sull'importo richiesto, quindi un prelievo dell'intero saldo può inviare
fino a 294 sat meno del minimo.
*Test:* `tests/unit/test_state_consistency.py`
- **[WDR-10]** Prima dell'invio, in un'unica transazione del database
([TX-28](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia)),
le monete spese vengono segnate come tali, il saldo ricalcolato, e si
+16 -18
View File
@@ -1,13 +1,13 @@
# Setup
Passaggi da eseguire una tantum per preparare un'istanza di PLM Lottery, prima di
poterla avviare (in locale o via Docker). Per come avviarla poi ogni volta, vedi
poterla avviare. Il server gira sempre in Docker; il venv serve solo a test, migrazioni da generare e script una tantum. Per come avviarla poi ogni volta, vedi
[running-the-server.md](running-the-server.md).
## 1. Prerequisiti
- Python 3.12+ (serve solo per il workflow locale/venv — puoi saltarlo se usi solo Docker)
- Docker + Docker Compose (serve solo per il workflow a container)
- Docker + Docker Compose (il server gira solo così)
- Python 3.12+ (solo per gli strumenti locali: test, `alembic revision` e gli script delle chiavi)
- Un server Electrum raggiungibile per la rete PLM. Il server di bootstrap per lo
sviluppo è `santantonio.sytes.net:50002` (SSL) — va bene per i test, ma in
produzione conviene usarne uno di cui ci si fida o gestirne uno proprio.
@@ -31,7 +31,7 @@ cp .env.example .env
| Variabile | Scopo | Come generarla |
|---|---|---|
| `XPRV_ENCRYPTION_KEY` | Chiave simmetrica che cifra a riposo la master xprv del server. **Perdere questa chiave significa perdere per sempre l'accesso ai fondi di tutti gli utenti.** | `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` |
| `XPRV_ENCRYPTION_KEY_FILE` | Percorso del file che contiene la chiave simmetrica che cifra a riposo la master xprv del server (`./secrets/xprv_encryption.key`, cartella `0700`, file `0600`; nel container è montato come secret). **Perdere questa chiave significa perdere per sempre l'accesso ai fondi di tutti gli utenti.** | `mkdir -p secrets && chmod 700 secrets && python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" > secrets/xprv_encryption.key && chmod 600 secrets/xprv_encryption.key` |
| `JWT_SECRET` | Firma i token di sessione degli utenti. | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
| `ADMIN_TOKEN` | Token bearer richiesto sugli endpoint admin (header `X-Admin-Token`). | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
@@ -61,7 +61,7 @@ certificato del primario. Attenzione: un valore scritto male **blocca
l'avvio** dell'app — è voluto, meglio accorgersene subito che durante il
disservizio in cui il fallback serve davvero.
`JWT_SECRET` e `XPRV_ENCRYPTION_KEY` vengono verificati all'avvio: se sono vuoti
`JWT_SECRET` e la chiave in `XPRV_ENCRYPTION_KEY_FILE` vengono verificati all'avvio: se sono vuoti
(o `JWT_SECRET` è più corto di 32 caratteri) il container si rifiuta di partire con
un errore esplicito, invece di avviarsi e rompersi al primo login. Nota: `.env`
contiene solo segreti e configurazione di infrastruttura — i parametri di
@@ -74,15 +74,13 @@ admin dopo l'avvio, non qui — vedi [guida-admin.md](guida-admin.md).
Il server deriva l'indirizzo di deposito di ogni utente (e l'indirizzo pool) da
un'unica master xprv, generata una volta e cifrata a riposo con
`XPRV_ENCRYPTION_KEY`. Questo passaggio va eseguito esattamente una volta per
ogni deployment, dopo aver impostato `XPRV_ENCRYPTION_KEY` in `.env`:
la chiave in `XPRV_ENCRYPTION_KEY_FILE`. Questo passaggio va eseguito esattamente una volta per
ogni deployment, dopo aver creato quel file e impostato `XPRV_ENCRYPTION_KEY_FILE` in `.env`:
- **Locale/venv**: `PYTHONPATH=. python scripts/generate_master_key.py`
- **Docker**: `docker compose run --rm app python scripts/generate_master_key.py`
Dal venv: `PYTHONPATH=. python scripts/generate_master_key.py`, con `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, così lo script scrive esattamente il file che legge il container.
Questo scrive un file cifrato (`MASTER_KEY_PATH`, default `./master.xprv.enc` in
locale o `./data/keys/master.xprv.enc` con Docker). **Fai il backup di questo
file insieme a `XPRV_ENCRYPTION_KEY`** — uno dei due da solo è inutile, ma
Questo scrive un file cifrato (`MASTER_KEY_PATH`, in Docker `./data/keys/master.xprv.enc`). **Fai il backup di questo
file insieme a `secrets/xprv_encryption.key`** — uno dei due da solo è inutile, ma
perderli entrambi insieme significa perdere i fondi di tutti gli utenti senza
possibilità di recupero.
@@ -107,10 +105,10 @@ Entrambi vanno eseguiti localmente (o dentro il container via
pannello admin: chi ottiene questa xprv ottiene il controllo dei fondi di
tutti gli utenti e del pool.
## 4. Installare le dipendenze (solo workflow locale/venv)
## 4. Installare gli strumenti locali (venv)
Salta questo passaggio se usi solo Docker — l'immagine installa le proprie
dipendenze durante la build.
Servono per i test e per gli script delle chiavi; il server non ne ha bisogno,
perché l'immagine installa le proprie dipendenze (da `requirements.lock`).
```bash
python3 -m venv .venv
@@ -120,9 +118,9 @@ pip install -e ".[dev]"
## 5. Applicare le migrazioni del database
- **Locale/venv**: `alembic upgrade head`
- **Docker**: le migrazioni vengono eseguite automaticamente all'avvio del
container (vedi il `CMD` del `Dockerfile`) — nessun passaggio manuale.
Le migrazioni vengono eseguite automaticamente all'avvio del container (vedi il
`CMD` del `Dockerfile`) — nessun passaggio manuale, e `alembic upgrade head` non
si lancia mai a mano.
## 6. Impostare l'indirizzo delle fee
+24 -83
View File
@@ -90,8 +90,7 @@ Restano **accettati**, e descritti in
riorganizzazioni con pausa sopra la profondità 1 e verifica delle monete
del round prima del pagamento ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md), B-82,
B-85). Il denaro che un attaccante riesce a sottrarre è perso per la
piattaforma. *Oggi:* depositi a 1 conferma e nessuna verifica prima
del pagamento (B-82); le riorganizzazioni sono rilevate e, da profondità 2, mettono in pausa.
piattaforma.
## Inventario dei segreti
@@ -105,11 +104,7 @@ Restano **accettati**, e descritti in
| Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore |
| JWT dell'utente | Browser, `localStorage` (`plm_token`) | Emesso al login/registrazione | Sessione dell'utente fino alla scadenza (24h) o al cambio password | — | Cambio password o reset dall'operatore |
*Oggi* (B-91): la chiave di cifratura è la variabile d'ambiente
`XPRV_ENCRYPTION_KEY` in `.env`, visibile a chiunque esegua `docker inspect`
sul container; `.env` e i file di chiave hanno i permessi predefiniti del
sistema (di norma `0644`, leggibili da tutti).
`JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente anche dopo B-91: le
`JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente (B-91): le
vede con `docker inspect` solo chi ha accesso a Docker, che è già fidato come
l'host.
@@ -119,10 +114,6 @@ l'host.
`master.xprv.enc`, i database `*.db`, `data/` e `logs/` sono esclusi sia da
git sia dall'immagine Docker.
*Codice:* `app/config.py:Settings`, `.gitignore`, `.dockerignore`
*Stato:* da implementare (B-91), per la chiave in un file e l'esclusione di
`secrets/` · *Oggi:* anche la chiave di cifratura sta in `.env`
(`XPRV_ENCRYPTION_KEY`), e né `.gitignore` né `.dockerignore` escludono
`secrets/`.
- **[SEC-03]** Tutti i parametri operativi stanno nel database
(`round_config`); in `.env` restano solo segreti, collegamenti
@@ -132,8 +123,6 @@ l'host.
riavvio. L'elenco completo è in
[architettura.md](architettura.md#configurazione).
*Codice:* `app/config.py:Settings`
*Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e
`XPRV_ENCRYPTION_KEY_FILE` non esistono.
`.env.example` documenta le chiavi necessarie e il comando per generarle;
non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque
@@ -183,10 +172,7 @@ flowchart TD
`0600` (senza passare da permessi più larghi) e lo riscrivono con gli
stessi permessi.
*Codice:* `scripts/generate_master_key.py`, `scripts/encrypt_master_key.py`,
`app/wallet/hd.py:generate_master_key` · *Test:* —
*Stato:* da implementare (B-89) · *Oggi:* gli script accettano qualunque
xprv senza guardare il database e scrivono il file con i permessi
predefiniti del sistema (di norma `0644`).
`app/wallet/hd.py:generate_master_key`, `app/wallet/verify.py` · *Test:* `tests/unit/test_master_key_check.py`
### Cifratura a riposo
@@ -209,10 +195,7 @@ sì che un backup dei dati non contenga entrambe.
container, quindi `docker inspect` non la mostra, e non finisce in un
backup di `./data/`.
*Codice:* `app/config.py:Settings`, `app/wallet/keystore.py:decrypt_xprv`,
`docker-compose.yml` · *Test:* —
*Stato:* da implementare (B-91) · *Oggi:* la chiave è la variabile
`XPRV_ENCRYPTION_KEY` di `.env`, passata al container con `env_file` e
visibile con `docker inspect`.
`docker-compose.yml` · *Test:* `tests/unit/test_container_hardening.py`, `tests/unit/test_config.py`
### Derivazione
@@ -238,10 +221,8 @@ sì che un backup dei dati non contenga entrambe.
verificata ([SEC-49]). Da quel momento la chiave dell'account
`m/84'/746'/0'` resta in memoria nel processo per tutta la sua durata; la
root xprv non viene conservata.
*Codice:* `app/wallet/hd.py:_load_account_key`, `app/main.py:lifespan`
*Stato:* da implementare (B-89) · *Oggi:* la chiave è decifrata al primo
uso (la prima derivazione, tipicamente una registrazione o il primo
pagamento), non all'avvio.
*Codice:* `app/wallet/hd.py:_load_account_key`, `app/main.py:lifespan` ·
*Test:* `tests/unit/test_master_key_check.py`
### Recupero
@@ -272,8 +253,6 @@ riserva è [ELC-19](connessione-electrum.md#1-rotazione-tra-server) (vedi
reali.
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` ·
*Test:* `tests/unit/test_config.py`
*Stato:* da implementare (B-91), per la chiave letta da file · *Oggi:* si
controlla che la variabile `XPRV_ENCRYPTION_KEY` non sia vuota.
- **[SEC-13]** Un `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello
risulta semplicemente chiuso (vedi [SEC-24]).
@@ -300,14 +279,9 @@ riserva è [ELC-19](connessione-electrum.md#1-rotazione-tra-server) (vedi
decifri. Lo scopo è non partire con una master key diversa da quella che
ha prodotto gli indirizzi salvati, che sposterebbe in silenzio il
montepremi e farebbe firmare transazioni che la rete rifiuta.
*Codice:* `app/main.py:lifespan`, `app/wallet/hd.py:_load_account_key`,
`app/wallet/hd.py:derive_user_address`, `app/wallet/hd.py:derive_pool_address` ·
*Test:* —
*Stato:* da implementare (B-89) · *Oggi:* all'avvio non si decifra nulla;
una chiave di cifratura sbagliata o malformata emerge solo al primo uso
della master key (prima registrazione o primo pagamento), e una xprv
diversa da quella originale non emerge affatto: produce indirizzi nuovi,
compreso quello del montepremi.
*Codice:* `app/main.py:lifespan`, `app/wallet/verify.py:verify_master_key`,
`app/wallet/verify.py:sample_problems` ·
*Test:* `tests/unit/test_master_key_check.py`
## Autenticazione degli utenti
@@ -349,10 +323,7 @@ che ne è il proprietario; qui ci sono le regole di sicurezza.
[REG-12](registrazione-e-depositi.md#sessione-e-password).
*Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`,
`app/auth/routes.py:LoginRequest`, `app/api/routes/users.py:ChangePasswordRequest` ·
*Test:* `tests/unit/test_users.py`
*Stato:* da implementare (B-94), per il massimo su login e cambio password ·
*Oggi:* il massimo di 256 vale solo per la password della registrazione;
i campi del login e del cambio password non hanno limite di lunghezza.
*Test:* `tests/unit/test_users.py`, `tests/unit/test_abuse_limits.py`
- ~~[SEC-18]~~ Assorbito da [REG-01](registrazione-e-depositi.md#registrazione)
e [REG-02](registrazione-e-depositi.md#registrazione): formato dello
@@ -400,9 +371,6 @@ che ne è il proprietario; qui ci sono le regole di sicurezza.
*Codice:* `app/auth/dependencies.py:get_current_user`, `app/api/routes/users.py:change_password`,
`app/api/routes/admin.py:reset_user_password` ·
*Test:* `tests/unit/test_users.py`, `tests/unit/test_security.py`, `tests/unit/test_admin.py`
*Stato:* da implementare (B-94), per `not_authenticated` · *Oggi:*
l'header assente risponde 401 con il corpo non strutturato di FastAPI
`{"detail": "Not authenticated"}`.
- **[SEC-23]** Gli endpoint raggiungibili anche da non autenticati (ad esempio
`GET /rounds/current`, `POST /bug-reports`) usano l'utente solo per
@@ -424,8 +392,6 @@ endpoint `/admin/*`, che richiedono l'header `X-Admin-Token`.
`invalid_admin_token` ([API-08](api.md#convenzioni-generali)).
*Codice:* `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-94), per il codice · *Oggi:* 403 con il corpo
non strutturato `{"detail": "invalid admin token"}`.
- **[SEC-25]** Il token è **unico e condiviso**: non esiste un'identità per
singolo operatore, e l'audit log registra *cosa* è cambiato ma non *chi*
@@ -483,8 +449,8 @@ ogni riavvio e non funzionerebbero con più worker.
| Login per username | `user:<username minuscolo>` | Dal 5° fallimento: blocco di 2s, raddoppiato a ogni fallimento successivo, massimo 300s. Il contatore si azzera dopo 900s senza fallimenti, o con un login riuscito | `POST /auth/login` |
| Login per IP | `ip:<ip>` | Dal 20° fallimento: stessa progressione (2s → 300s), stesso decadimento di 900s. **Non** si azzera con un login riuscito | `POST /auth/login` |
| Registrazione per IP | `ip:<ip>` | Al massimo 5 account creati per IP in una finestra mobile di 3600s. Contano solo gli account creati davvero | `POST /auth/register` |
| Cambio password per utente *(B-94, da implementare)* | `user:<id utente>`, in un limitatore separato da quello del login | Dal 5° fallimento della password attuale: stessa progressione del login (2s → 300s), stesso decadimento di 900s; un cambio riuscito azzera il contatore | `POST /users/me/change-password` |
| Segnalazioni di bug per IP *(B-94, da implementare)* | `ip:<ip>` | Al massimo 10 segnalazioni per IP in una finestra mobile di 3600s, anche da autenticati. Contano solo le segnalazioni salvate davvero | `POST /bug-reports` |
| Cambio password per utente *(B-94)* | `user:<id utente>`, in un limitatore separato da quello del login | Dal 5° fallimento della password attuale: stessa progressione del login (2s → 300s), stesso decadimento di 900s; un cambio riuscito azzera il contatore | `POST /users/me/change-password` |
| Segnalazioni di bug per IP *(B-94)* | `ip:<ip>` | Al massimo 10 segnalazioni per IP in una finestra mobile di 3600s, anche da autenticati. Contano solo le segnalazioni salvate davvero | `POST /bug-reports` |
| Stream SSE per IP | IP | Al massimo 5 stream contemporanei per IP: il 6° chiude il più vecchio dello stesso IP | `GET /rounds/stream` |
| Stream SSE globale | — | Al massimo 500 stream contemporanei: oltre, 503 | `GET /rounds/stream` |
@@ -519,9 +485,7 @@ ogni riavvio e non funzionerebbero con più worker.
[API-21](api.md).
*Codice:* `app/api/routes/users.py:change_password`,
`app/auth/rate_limit.py:RateLimiter`, `app/auth/rate_limit.py:AuthRateLimiters` ·
*Test:* —
*Stato:* da implementare (B-94) · *Oggi:* nessun freno: con un token valido
la password attuale si può provare senza limiti.
*Test:* `tests/unit/test_abuse_limits.py`
- **[SEC-55]** L'invio di segnalazioni di bug è una **quota** per IP
([SEC-35]): al massimo 10 segnalazioni per IP ogni 3600s (finestra mobile,
@@ -532,9 +496,7 @@ ogni riavvio e non funzionerebbero con più worker.
vecchia ([API-28](api.md)).
*Codice:* `app/api/routes/bug_reports.py:create_bug_report`,
`app/auth/rate_limit.py:RollingQuota` ·
*Test:* —
*Stato:* da implementare (B-94) · *Oggi:* nessun limite: un anonimo può
inserire segnalazioni senza limite, fino a riempire il database.
*Test:* `tests/unit/test_abuse_limits.py`
- **[SEC-32]** La memoria dei limitatori è limitata: le voci esaurite vengono
rimosse (al più ogni 60s, e alla lettura), e comunque non si superano 50.000
@@ -549,10 +511,7 @@ ogni riavvio e non funzionerebbero con più worker.
[API-16](api.md#contratto-degli-errori); il browser ripiega sul polling).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe`,
`app/api/routes/rounds.py:round_stream` ·
*Test:* `tests/unit/test_round_events.py`
*Stato:* da implementare (B-94), per il codice `too_many_streams` ·
*Oggi:* il 503 ha il corpo non strutturato
`{"detail": "too many concurrent update streams"}`.
*Test:* `tests/unit/test_round_events.py`, `tests/unit/test_abuse_limits.py`
- **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi,
endpoint admin (tentativi sul token compresi), QR, `GET /rounds/current` e
@@ -562,7 +521,6 @@ ogni riavvio e non funzionerebbero con più worker.
che non è un limite di frequenza ([LIM-07](limiti-noti.md)).
*Codice:* `app/api/routes/bets.py`, `app/api/routes/withdrawals.py`,
`app/api/routes/admin.py:require_admin`
*Stato:* da implementare (B-94) · *Oggi:* anche il cambio password e
l'invio di segnalazioni di bug sono senza limite ([SEC-54], [SEC-55]).
## Indirizzo IP del client
@@ -640,11 +598,7 @@ entra nell'immagine.
`0700`; `.env`, `master.xprv.enc` e `./secrets/xprv_encryption.key` hanno
`0600`; tutti appartengono all'utente con cui gira il container. I file che
l'app crea (database, backup, log) nascono con permessi `0600`.
*Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* —
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root, i
file che scrive in `./data/` appartengono a root sull'host, e `.env` e i
file di chiave hanno i permessi predefiniti del sistema (di norma `0644`,
leggibili da ogni utente dell'host).
*Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* `tests/unit/test_container_hardening.py`
- **[SEC-52]** Ciò che entra nelle immagini è fissato: le immagini di base
(`python:3.12-slim` per `app`, `caddy:2-alpine` per `caddy`) sono indicate
@@ -652,11 +606,7 @@ entra nell'immagine.
di lock con l'hash di ogni pacchetto (`pip install --require-hashes`), non
dagli intervalli `>=` di `pyproject.toml`. Aggiornare una dipendenza o
un'immagine significa rigenerare il lock o cambiare il digest in un commit.
*Codice:* `Dockerfile`, `docker-compose.yml`, `pyproject.toml` · *Test:* —
*Stato:* da implementare (B-91) · *Oggi:* le immagini di base sono
indicate per etichetta mobile e le dipendenze con `>=`: ogni build può
installare versioni diverse, comprese versioni compromesse pubblicate dopo
l'ultima build.
*Codice:* `Dockerfile`, `docker-compose.yml`, `requirements.lock` · *Test:* `tests/unit/test_container_hardening.py`
La chiave di cifratura montata come file, e non come variabile d'ambiente, è
[SEC-53]. Le procedure che scrivono in `./data/` devono rispettarne il
@@ -701,7 +651,6 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
chiamate all'API solo dalla stessa origine.
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`,
`app/main.py:user_guide`, `app/main.py:report_bug_page`, `app/main.py` (`app.mount`)
*Stato:* da implementare (B-94), per i file statici · *Oggi:* i file
serviti dal mount di `app/static/` non hanno `Cache-Control`, solo
`ETag`/`Last-Modified`, e il browser può riusare uno script vecchio con
una pagina nuova.
@@ -720,11 +669,8 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
validazione e framework compresi — ha il formato strutturato con un codice
([API-16](api.md#contratto-degli-errori)) e non ripete il valore ricevuto,
così una password troppo lunga non torna indietro in chiaro.
*Codice:* `app/main.py:log_unhandled_exception`, `app/main.py`
*Stato:* da implementare (B-94), per gli errori diversi dal 500 ·
*Oggi:* 401 senza header, 403 admin, 404/405 del framework e 503 dell'SSE
hanno `detail` come stringa, e il 422 di validazione ripete nel campo
`input` il valore ricevuto.
*Codice:* `app/main.py:log_unhandled_exception`, `app/main.py` ·
*Test:* `tests/unit/test_abuse_limits.py`
Il pannello `/admin` non è collegato da `/` (né viceversa), ma è una scelta
di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
@@ -768,7 +714,6 @@ di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
`app/api/routes/admin.py:list_pending_transactions`, `app/api/routes/admin.py:list_bug_reports`,
`app/api/routes/admin.py:list_users` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-94), per l'elenco utenti · *Oggi:*
`GET /admin/users` restituisce tutti gli utenti in una sola risposta, senza
limite né paginazione.
@@ -789,17 +734,15 @@ sicurezza.
successivi al più alto assegnato e, se uno ha cronologia in catena, la
registrazione è rifiutata (`registration_unavailable`) e si scrive
`restore_gap_detected`
([REG-15](registrazione-e-depositi.md#registrazione), B-90). *Oggi:*
nessun controllo; l'unica difesa è la procedura
[OPS-28](operativita.md#ripristino).
([REG-15](registrazione-e-depositi.md#registrazione), B-90); la procedura
[OPS-28](operativita.md#ripristino) toglie la sospensione.
- **Round rigiocati.** Con `START_PAUSED=true` il server parte con la
lotteria in pausa ([OPS-27](operativita.md#ripristino), B-90), e ogni round
ha il proprio indirizzo del montepremi, le cui monete sono verificate con
il quorum prima del pagamento
([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-82): un round
del backup già pagato risulta impagabile invece di essere pagato due volte
con il denaro dei round successivi. *Oggi:* la pausa si imposta a mano nel
database, e le monete del round non sono ancora verificate con il quorum.
con il denaro dei round successivi.
- **Chiave sbagliata.** Una master key che non corrisponde al database
ripristinato ferma l'avvio ([SEC-49]).
- **Commit persi.** Con `synchronous=FULL` un commit confermato al chiamante
@@ -846,10 +789,8 @@ La struttura completa delle tabelle è in [modello-dati.md](modello-dati.md).
`decrypt_master_key.py`, per scelta e dopo conferma. WIF e password
generate dal reset compaiono solo nella risposta HTTP all'operatore.
*Codice:* `app/logging_config.py:setup_logging`, `app/api/routes/admin.py:user_privkey`,
`app/api/routes/admin.py:reset_user_password`
*Stato:* da implementare (B-92), per stdout e IP del client · *Oggi:* il
log va solo nel file, e il log di accesso registra l'indirizzo di Caddy,
non quello del client ([ARC-02](architettura.md#panoramica-del-deployment)).
`app/api/routes/admin.py:reset_user_password` ·
*Test:* `tests/unit/test_observability.py`
## Rischi accettati
+20 -19
View File
@@ -193,8 +193,8 @@ limiti accettati: lo stato `drawing` non riprende dopo un riavvio (vedi
| B-68 | CLAUDE.md e README descrivevano uno stato che il codice aveva superato | Bassa | `162ceed` Describe the code the docs actually ship with | `test_docs_current.py` | ARC |
| B-69 | Commenti nel codice non più veri | Bassa | `666cb1a` Retire in-code comments that outlived what they described | `test_code_comments.py` | ARC |
| B-70 | Il diagramma diceva che prelievo e scommessa in corso non potevano coesistere, ma il codice non lo impone (e non deve) | Bassa | `526a649` Say what bets and withdrawals actually exclude | `test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed`, `::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive` | WDR |
| B-71 | `.env` punta `MASTER_KEY_PATH` a una seconda copia della chiave master (e `DATABASE_URL` a un secondo database) invece che ai file in `./data/` letti dal container | Bassa | **aperto** | — | OPS, SEC |
| B-72 | `docs/setup.md` presenta ancora l'installazione come «in locale o via Docker», contro la regola «solo Docker» | Bassa | **aperto** | — | OPS |
| B-71 | `.env` punta `MASTER_KEY_PATH` a una seconda copia della chiave master (e `DATABASE_URL` a un secondo database) invece che ai file in `./data/` letti dal container | Bassa | corretto con l'allineamento dei file e B-89 | `test_master_key_check.py` | OPS, SEC |
| B-72 | `docs/setup.md` presenta ancora l'installazione come «in locale o via Docker», contro la regola «solo Docker» | Bassa | *da registrare* | — (documentazione) | OPS |
Note:
@@ -204,7 +204,7 @@ Note:
insieme» di [scommesse-e-prelievi.md](scommesse-e-prelievi.md): quel titolo e
le frasi che il test cerca vanno mantenuti.
## Revisione della specifica del 2026-09-28 (B-73 … B-98)
## Revisione della specifica del 2026-09-28 (B-73 … B-100)
Quarta revisione, condotta sulla specifica in `docs/` invece che sul solo
codice: quattro analisi indipendenti (meccanica della coin, macchina a stati
@@ -230,31 +230,32 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-79 | Il pagamento di norma non ha resto, quindi non si può accelerare e può bloccare la lotteria | Critica | *da registrare* | `test_payout_builder.py`, `test_broadcast.py`, `test_scheduler.py`, `test_rounds_service.py`, `test_admin.py`, `test_migrations.py` | RND, TX |
| B-80 | Un solo indirizzo del montepremi per tutti i round | Critica | *da registrare* | `test_round_pool_address.py`, `test_pending_address.py`, `test_scheduler.py` | RND, BET, WDR |
| B-81 | `drawing` resta bloccato dopo qualsiasi eccezione, non solo dopo un riavvio | Critica | *da registrare* | `test_draw_proof.py` | RND |
| B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | **aperto** | — | DEP, RND |
| B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | *da registrare* | `test_deposit_confirmations.py`, `test_pool_verification.py`, `test_electrum_listener.py`, `test_admin.py`, `test_users.py` | DEP, RND |
| B-83 | Conferme e reintegri si basano solo sul server attivo | Alta | *da registrare* | `test_confirmation_proof.py`, `test_confirmation.py` | ELC, TX, DEP |
| B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | *da registrare* | `test_electrum_client.py`, `test_electrum_listener.py` | ELC, SEC |
| B-85 | Le riorganizzazioni della catena non vengono né seguite né segnalate | Alta | *da registrare* | `test_electrum_listener.py` | ELC, RND |
| B-86 | Un server connesso ma fermo non viene mai sostituito | Alta | *da registrare* | `test_electrum_listener.py` | ELC, OPS |
| B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | **aperto** | — | TX |
| B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | *da registrare* | `test_rbf_dust.py`, `test_broadcast.py` | TX |
| B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC |
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | **aperto** | — | SEC, OPS |
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | **aperto** | — | OPS, REG |
| B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | **aperto** | — | SEC, OPS |
| B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API |
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | *da registrare* | `test_master_key_check.py` | SEC, OPS |
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | *da registrare* | `test_restore_gap.py` | OPS, REG |
| B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | *da registrare* | `test_container_hardening.py`, `test_config.py` | SEC, OPS |
| B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | *da registrare* | `test_observability.py` | OPS, AUD, API |
| B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS |
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | **aperto** | — | SEC, API |
| B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | **aperto** | — | RND, BET |
| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | **aperto** | — | TX, BET, WDR, DEP |
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | *da registrare* | `test_abuse_limits.py` | SEC, API |
| B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | *da registrare* | `test_round_snapshot.py`, `test_admin.py`, `test_rounds_route.py`, `test_scheduler.py` | RND, BET |
| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | *da registrare* | `test_state_consistency.py` | TX, BET, WDR, DEP |
| B-97 | Le migrazioni possono eliminare in silenzio gli indici su espressione | Media | *da registrare* | `test_migrations.py` | DAT |
| B-98 | La selezione delle monete si ferma alla prima che copre l'importo e rifiuta una scommessa pagabile | Media | **aperto** | — | BET, WDR |
| B-98 | La selezione delle monete si ferma alla prima che copre l'importo e rifiuta una scommessa pagabile | Media | *da registrare* | `test_psbt_builder.py` | BET, WDR |
| B-99 | Un server Electrum di Bitcoin supera ogni controllo sugli header (PLM ne condivide genesis e `powLimit`): il genesis non veniva verificato | Media | *da registrare* | `test_electrum_client.py` | ELC |
| B-100 | Il tetto del fee rate coincide con il `maxfeerate` del nodo (10 000 sat/vB): una tariffa al tetto, con vsize stimato per eccesso, verrebbe rifiutata | Bassa | *da registrare* | — (costante; copertura da `test_rbf_dust.py`) | TX |
## Stato attuale
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
- **Corretti:** B-01 … B-72, tranne B-16, che è chiuso nella sostanza (il
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79.
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (1 critico,
4 alti, 6 medi), da implementare nell'ordine di
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-92, B-94, B-95, B-96, B-98, B-99, B-100.
- **Aperti:** dalla revisione del 2026-09-28, B-93 (0 critici,
0 alti, 1 medio), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-99`.
- **Prossimo numero libero:** `B-101`.
+11 -29
View File
@@ -217,10 +217,10 @@ sostituibilità fin dal primo invio.
*Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:should_bump` ·
*Test:* `tests/unit/test_broadcast.py`
- **[TX-10]** La nuova commissione punta a 1 sat/vB in più della tariffa
registrata (`_FEE_RATE_INCREMENT`), senza superare 10.000 sat/vB
registrata (`_FEE_RATE_INCREMENT`), senza superare 5.000 sat/vB
(`MAX_FEE_RATE_SAT_VB`); l'aumento assoluto non è mai inferiore alla
dimensione stimata della transazione per 1 sat/vB, il minimo richiesto da
BIP125 per una sostituzione. Una riga già a 10.000 sat/vB non viene più
BIP125 per una sostituzione. Una riga già a 5.000 sat/vB non viene più
accelerata. Gli importi delle monete spese sono letti dalle transazioni
precedenti come interi in satoshi.
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_prevout_amount` ·
@@ -243,11 +243,7 @@ sostituibilità fin dal primo invio.
partita ([TX-17]): finché almeno un server la tiene in mempool non viene
mai abbandonata.
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_find_change_output` ·
*Test:* `tests/unit/test_broadcast.py`
*Stato:* da implementare (B-87) · *Oggi:* il pagamento si accelera già
dall'uscita di riserva (B-79), ma per ogni transazione basta che l'uscita
superi l'aumento: un resto di 294 sat diventa 153 sat, sotto la soglia di
dust, e la rete rifiuta la sostituzione a ogni giro.
*Test:* `tests/unit/test_broadcast.py`, `tests/unit/test_rbf_dust.py`
- **[TX-12]** **Prima si scrive la sostituzione, poi si invia.** La
sostituzione firmata viene registrata come candidata in
`pending_transaction_txids` (txid e transazione completa, [TX-19]) e
@@ -282,10 +278,8 @@ sostituibilità fin dal primo invio.
tentativo che fallisce di nuovo per lo stesso motivo sullo stesso txid va
solo nei log, così una transazione non accelerabile non riempie il registro
con una riga ogni 30 secondi.
*Codice:* `app/tx/broadcast.py:RbfBumper._tick` ·
*Test:* —
*Stato:* da implementare (B-87) · *Oggi:* l'errore finisce solo nei log
dell'applicazione, invisibile da `/admin`.
*Codice:* `app/tx/broadcast.py:RbfBumper._tick`, `app/tx/broadcast.py:RbfBumper._record_failure` ·
*Test:* `tests/unit/test_rbf_dust.py`
Il resto minimo di scommesse e prelievi è di 294 sat
([BET-14](scommesse-e-prelievi.md#cosa-succede),
@@ -334,11 +328,7 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
corrente vale [TX-19]. Da lì la conferma segue la via normale. Una riga già
`pending` presente in rete non viene toccata.
*Codice:* `app/tx/reconcile.py:_promote` ·
*Test:* `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-96) · *Oggi:* si scrive solo
`pending_tx_recovered`: una scommessa o un pagamento recuperati non hanno
mai `bet_placed` o `payout_sent` (e un prelievo recuperato non ha
`withdrawal_sent`).
*Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_state_consistency.py`
- **[TX-17]** **Abbandono solo con prova.** Una riga viene abbandonata solo
se vale una di queste due prove:
1. un suo ingresso risulta **speso in un blocco** da una transazione che
@@ -376,9 +366,7 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
cui ha cambiato qualcosa (promozione, abbandono, riallineamento del txid),
non per ogni riga esaminata.
*Codice:* `app/tx/reconcile.py:reconcile_once` ·
*Test:* —
*Stato:* da implementare (B-96) · *Oggi:* pubblica una notifica per ogni
riga che ha interrogato, anche quando non cambia nulla.
*Test:* `tests/unit/test_state_consistency.py`
- **[TX-25]** Una scommessa in volo esce dall'attesa del round in `closing`
([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#2--chiusura)) in
due soli modi: confermata ([TX-07]) o abbandonata con prova ([TX-17]). Non
@@ -389,16 +377,14 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
- **[TX-29]** L'attesa in `closing` senza tempo massimo ([TX-25]) è
**visibile**: quando un round resta in `closing` oltre la scadenza per più
di `CLOSING_STALL_THRESHOLD_SECONDS` (1800 s), lo scheduler scrive un
evento `closing_stalled` ([audit-log.md](audit-log.md) (B-96)) con il round,
evento `closing_stalled` ([AUD-46](audit-log.md)) con il round,
il numero di scommesse ancora in volo e i loro txid, e lo ripete ogni 1800 s
finché l'attesa dura; `GET /rounds/current` espone da quando il round attende
(`closing_waiting_since`). L'operatore decide con la procedura di
[operativita.md](operativita.md) (B-96); il sistema non esclude da solo una
[OPS-14](operativita.md#round-bloccato-in-closing); il sistema non esclude da solo una
scommessa in volo dall'estrazione.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* —
*Stato:* da implementare (B-96) · *Oggi:* l'attesa non produce alcun
segnale oltre al round che resta in `closing`.
*Test:* `tests/unit/test_state_consistency.py`
## Pagamenti del round: riprovare senza pagare due volte
@@ -420,11 +406,7 @@ queste ultime due, un nuovo tentativo potrebbe pagare il vincitore due volte.
`paying_out` senza transazione né importi di un pagamento che non esiste, e
lo scheduler ricostruisce il pagamento al tentativo successivo.
*Codice:* `app/tx/reconcile.py:_abandon`, `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` ·
*Test:* — (`tests/unit/test_scheduler.py` verifica i nuovi tentativi di un
round in `paying_out`, non la sequenza abbandono → ricostruzione)
*Stato:* da implementare (B-96) · *Oggi:* azzera solo `payout_txid`;
`winner_amount_sats` e `fee_amount_sats` restano quelli del pagamento
abbandonato.
*Test:* `tests/unit/test_state_consistency.py`
## Dove guardare
@@ -0,0 +1,53 @@
"""snapshot the round's parameters at open: rounds.bet_amount_sats/fee_address/max_round_pool_sats, round_config.max_round_pool_sats (B-95)
Revision ID: a7d3e9f15b48
Revises: f2c64a8d1e93
Create Date: 2026-10-15 16:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a7d3e9f15b48'
down_revision: Union[str, Sequence[str], None] = 'f2c64a8d1e93'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_ACTIVE = "'open', 'closing', 'drawing', 'paying_out'"
def upgrade() -> None:
"""Upgrade schema.
B-95 (DAT-27, DAT-55, DAT-61, DAT-62): a round now carries its own `bet_amount_sats`,
`fee_address` and `max_round_pool_sats`, copied from `round_config` when it opens. Rounds
that already exist are filled with the *current* values of `round_config` — for one still
in progress that is exactly what it was using until now, since it read them live. The
server defaults stay (changing them away would rebuild the table on SQLite).
Plain add_column / UPDATE calls — never batch_alter_table, which on SQLite rebuilds the
table and silently drops the expression indexes (B-97, DAT-56).
"""
op.add_column("round_config", sa.Column("max_round_pool_sats", sa.BigInteger(), server_default="0", nullable=False))
op.add_column("rounds", sa.Column("bet_amount_sats", sa.BigInteger(), server_default="1000000000", nullable=False))
op.add_column("rounds", sa.Column("fee_address", sa.String(length=128), server_default="", nullable=False))
op.add_column("rounds", sa.Column("max_round_pool_sats", sa.BigInteger(), server_default="0", nullable=False))
op.execute(
"UPDATE rounds SET "
"bet_amount_sats = COALESCE((SELECT bet_amount_sats FROM round_config ORDER BY id LIMIT 1), bet_amount_sats), "
"fee_address = COALESCE((SELECT fee_address FROM round_config ORDER BY id LIMIT 1), fee_address)"
)
def downgrade() -> None:
"""Downgrade schema."""
for column in ("max_round_pool_sats", "fee_address", "bet_amount_sats"):
op.drop_column("rounds", column)
op.drop_column("round_config", "max_round_pool_sats")
# drop_column on SQLite is a table rebuild: recreate the expression index (B-97).
op.execute("DROP INDEX IF EXISTS ix_rounds_single_active")
op.execute(f"CREATE UNIQUE INDEX ix_rounds_single_active ON rounds ((1)) WHERE status IN ({_ACTIVE})")
@@ -0,0 +1,44 @@
"""deposit confirmations and the held payout: round_config.deposit_confirmations, rounds.payout_blocked_at (B-82)
Revision ID: f2c64a8d1e93
Revises: e5a91c3d7b26
Create Date: 2026-10-15 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f2c64a8d1e93'
down_revision: Union[str, Sequence[str], None] = 'e5a91c3d7b26'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_ACTIVE = "'open', 'closing', 'drawing', 'paying_out'"
def upgrade() -> None:
"""Upgrade schema.
B-82 (DAT-62, DAT-68): `round_config.deposit_confirmations` (default 6) is how many
confirmations an output paying a user needs before it is credited as a deposit;
`rounds.payout_blocked_at` marks a payout held because the winner's own bet left the
chain. Both are plain add_column calls — never batch_alter_table, which on SQLite
rebuilds the table and silently drops the expression indexes (B-97, DAT-56).
"""
op.add_column(
"round_config", sa.Column("deposit_confirmations", sa.Integer(), server_default="6", nullable=False)
)
op.add_column("rounds", sa.Column("payout_blocked_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column("rounds", "payout_blocked_at")
op.drop_column("round_config", "deposit_confirmations")
# drop_column on SQLite is a table rebuild: recreate the expression index (B-97).
op.execute("DROP INDEX IF EXISTS ix_rounds_single_active")
op.execute(f"CREATE UNIQUE INDEX ix_rounds_single_active ON rounds ((1)) WHERE status IN ({_ACTIVE})")
+1 -1
View File
@@ -5,7 +5,7 @@ requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"sqlalchemy>=2.0",
"sqlalchemy[asyncio]>=2.0",
"aiosqlite>=0.20",
"alembic>=1.13",
"pydantic-settings>=2.6",
+1301
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -8,7 +8,7 @@ locally, only when you actually need it (e.g. disaster-recovery backup).
Usage:
python scripts/decrypt_master_key.py
Requires XPRV_ENCRYPTION_KEY to already be set (.env), same as the running
Requires XPRV_ENCRYPTION_KEY_FILE (.env) to point at the key file, same as the running
server. Looks for the encrypted key at MASTER_KEY_PATH (.env) — if that path
doesn't exist, falls back to ./data/keys/master.xprv.enc, the location
docker-compose.yml bind-mounts it to, since .env's default (./master.xprv.enc)
@@ -34,7 +34,7 @@ def _resolve_key_path() -> str:
if __name__ == "__main__":
if not settings.xprv_encryption_key:
raise SystemExit("XPRV_ENCRYPTION_KEY is not set")
raise SystemExit("XPRV_ENCRYPTION_KEY_FILE is not set, or its file is missing or empty")
key_path = _resolve_key_path()
+12 -6
View File
@@ -1,6 +1,6 @@
"""One-time ops bootstrap: encrypt an externally-generated master xprv and
write it to disk, using the exact same scheme generate_master_key.py uses
(Fernet, XPRV_ENCRYPTION_KEY). Use this instead of generate_master_key.py
(Fernet, the key in XPRV_ENCRYPTION_KEY_FILE). Use this instead of generate_master_key.py
when you already have an xprv from elsewhere (e.g. generated offline/air-
gapped) and want to bring your own instead of letting the server create one.
@@ -12,7 +12,7 @@ Usage:
python scripts/encrypt_master_key.py
python scripts/encrypt_master_key.py --overwrite # replace an existing key
Requires XPRV_ENCRYPTION_KEY to already be set (.env). Writes to
Requires XPRV_ENCRYPTION_KEY_FILE (.env) to point at the key file. Writes to
MASTER_KEY_PATH (.env) — falls back to ./data/keys/master.xprv.enc (the
docker-compose.yml bind-mount location) if that directory exists and .env's
default path doesn't, same fallback as decrypt_master_key.py.
@@ -25,7 +25,8 @@ import os
from embit.bip32 import HDKey
from app.config import settings
from app.wallet.keystore import encrypt_xprv
from app.wallet.keystore import encrypt_xprv, write_secret_file
from app.wallet.verify import account_key, load_sample, sample_problems
_DOCKER_COMPOSE_DIR = "./data/keys"
@@ -47,7 +48,7 @@ if __name__ == "__main__":
args = parser.parse_args()
if not settings.xprv_encryption_key:
raise SystemExit("XPRV_ENCRYPTION_KEY is not set")
raise SystemExit("XPRV_ENCRYPTION_KEY_FILE is not set, or its file is missing or empty")
key_path = _resolve_key_path()
if os.path.exists(key_path) and not args.overwrite:
@@ -69,8 +70,13 @@ if __name__ == "__main__":
if not parsed.is_private:
raise SystemExit("This is a public key (xpub), not a private key (xprv) — refusing to encrypt it as one.")
# SEC-05/SEC-50: the version bytes cannot tell a PLM key from a Bitcoin one, the stored
# addresses can. A mismatch is refused even with --overwrite.
problems = sample_problems(account_key(xprv), load_sample())
if problems:
raise SystemExit("this xprv does not match the database — refusing to write it: " + "; ".join(problems))
os.makedirs(os.path.dirname(key_path) or ".", exist_ok=True)
with open(key_path, "wb") as f:
f.write(encrypt_xprv(xprv))
write_secret_file(key_path, encrypt_xprv(xprv))
print(f"Master key written (encrypted) to {key_path}")
+7 -2
View File
@@ -2,15 +2,20 @@
Usage:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# put that value in XPRV_ENCRYPTION_KEY (.env), then:
# write it to the file XPRV_ENCRYPTION_KEY_FILE (.env) points at, then:
python scripts/generate_master_key.py
"""
from app.config import settings
from app.wallet.hd import generate_master_key
from app.wallet.verify import load_sample
if __name__ == "__main__":
if not settings.xprv_encryption_key:
raise SystemExit("XPRV_ENCRYPTION_KEY is not set")
raise SystemExit("XPRV_ENCRYPTION_KEY_FILE is not set, or its file is missing or empty")
sample = load_sample()
if sample.users or sample.rounds or sample.seeds:
# SEC-50: a new key cannot reproduce the addresses the database already holds.
raise SystemExit("the database already has users or rounds: a new key would not reproduce their addresses")
generate_master_key()
print(f"Master key written (encrypted) to {settings.master_key_path}")
+9
View File
@@ -25,6 +25,15 @@ def _throwaway_master_key(tmp_path, monkeypatch):
hd._account_key = None
@pytest.fixture(autouse=True)
def _registration_open(monkeypatch):
"""B-90: a real listener keeps registration closed until its restore-gap check ran;
only the tests about that check care, and they set it themselves."""
from app.electrum.listener import ElectrumListener
monkeypatch.setattr(ElectrumListener, "registration_open", True)
# B-74: the real powLimit needs ~2^32 hashes per header, so tests mine headers at a trivial
# target (`0x207fffff`) and this swaps in a limit that allows it. Done at import, not in a
# fixture, because some test modules mine headers while they are being collected. The
+16
View File
@@ -19,6 +19,12 @@ class FakeNetwork:
self.asked: list[str] = []
self.fanned_out: list[str] = []
self.proofs_asked: list[tuple[str, int]] = []
# B-82: what `verify_pool_outpoints` answers. Every bet output is present unless listed
# in `pool_absent`; `pool_unavailable` models a quorum that could not be reached.
self.pool_absent: set[tuple[str, int]] = set()
self.pool_unavailable = False
self.verified_scripthashes: list[str] = []
self.verified_outpoints: list[list[tuple[str, int, int]]] = []
async def transaction_known(self, txid: str) -> bool | None:
self.asked.append(txid)
@@ -31,6 +37,16 @@ class FakeNetwork:
self.proofs_asked.append((txid, height))
return self.proven
async def verify_pool_outpoints(self, scripthash, outpoints):
self.verified_scripthashes.append(scripthash)
self.verified_outpoints.append(list(outpoints))
hook = getattr(self, "on_verify", None)
if hook is not None:
await hook()
if self.pool_unavailable:
return None
return {(txid, vout): (txid, vout) not in self.pool_absent for txid, vout, _amount in outpoints}
MISSING_INPUTS = "bad-txns-inputs-missingorspent"
+183
View File
@@ -0,0 +1,183 @@
"""B-94 (SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12):
credential length bounds, the password-change brake and audit, the bug-report quota, paged
user list, structured errors everywhere and revalidated static files."""
import json
import pytest
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.config import settings
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
monkeypatch.setattr(settings, "admin_token", "admin-secret")
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
import app.db.models # noqa: F401
import app.wallet.hd as hd
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db import base as db_base
from app.db import session as db_session
hd._account_key = None
hd.generate_master_key()
db_base.engine = create_async_engine(settings.database_url)
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
async with db_base.engine.begin() as conn:
await conn.run_sync(db_base.Base.metadata.create_all)
from app.electrum.listener import ElectrumListener
from app.main import app
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
app.state.auth_rate_limiters = None
if hasattr(app.state, "auth_rate_limiters"):
del app.state.auth_rate_limiters # fresh throttle state per test
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
await db_base.engine.dispose()
ADMIN = {"X-Admin-Token": "admin-secret"}
async def _register(client, username="alice", password="original-password"):
resp = await client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 201
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
def _code(resp):
return resp.json()["detail"]["code"]
# --- field lengths (API-19, REG-12) and the 422 shape (API-15) ---------------------
async def test_login_fields_are_bounded_and_the_value_is_not_echoed(client):
resp = await client.post("/auth/login", json={"username": "a", "password": "secret-" + "x" * 300})
assert resp.status_code == 422
assert _code(resp) == "invalid_request"
errors = resp.json()["detail"]["params"]["errors"]
assert errors[0]["loc"] == ["body", "password"] and set(errors[0]) == {"loc", "msg", "type"}
assert "secret-" not in resp.text
async def test_change_password_fields_are_bounded(client):
headers = await _register(client)
resp = await client.post(
"/users/me/change-password",
headers=headers,
json={"current_password": "original-password", "new_password": "x" * 257},
)
assert resp.status_code == 422 and _code(resp) == "invalid_request"
# --- the password-change brake (SEC-54) and its audit (AUD-42) ---------------------
async def test_the_fifth_wrong_current_password_locks_even_the_right_one_out(client):
headers = await _register(client)
body = {"current_password": "wrong-password", "new_password": "brand-new-password"}
for _ in range(5):
assert (await client.post("/users/me/change-password", headers=headers, json=body)).status_code == 401
resp = await client.post("/users/me/change-password", headers=headers, json=body)
assert resp.status_code == 429 and _code(resp) == "rate_limited"
right = {"current_password": "original-password", "new_password": "brand-new-password"}
locked = await client.post("/users/me/change-password", headers=headers, json=right)
assert locked.status_code == 429 and locked.json()["detail"]["params"]["retry_after_seconds"] >= 1
async def test_a_successful_change_is_audited_and_clears_the_brake(client):
from app.db import base as db_base
from app.db.models import AuditLog
headers = await _register(client)
bad = {"current_password": "wrong-password", "new_password": "brand-new-password"}
for _ in range(3):
await client.post("/users/me/change-password", headers=headers, json=bad)
ok = {"current_password": "original-password", "new_password": "brand-new-password"}
assert (await client.post("/users/me/change-password", headers=headers, json=ok)).status_code == 200
async with db_base.AsyncSessionLocal() as session:
rows = (await session.execute(select(AuditLog).where(AuditLog.event_type == "password_changed"))).scalars().all()
assert len(rows) == 1 and json.loads(rows[0].payload_json) == {"user_id": rows[0].user_id}
assert "password" not in rows[0].payload_json.replace("user_id", "")
# --- bug reports (SEC-55) ----------------------------------------------------------
async def test_the_11th_bug_report_from_one_ip_waits_but_an_invalid_one_costs_nothing(client):
for _ in range(3):
assert (await client.post("/bug-reports", json={"description": " "})).status_code == 422
for i in range(10):
assert (await client.post("/bug-reports", json={"description": f"bug {i}"})).status_code == 201
resp = await client.post("/bug-reports", json={"description": "one too many"})
assert resp.status_code == 429 and _code(resp) == "rate_limited"
assert 3000 < resp.json()["detail"]["params"]["retry_after_seconds"] <= 3601
# --- paged user list (API-33) ------------------------------------------------------
async def test_admin_users_are_paged_by_id(client):
for i in range(5):
await _register(client, username=f"user{i}")
first = (await client.get("/admin/users", headers=ADMIN, params={"limit": 2})).json()
second = (await client.get("/admin/users", headers=ADMIN, params={"limit": 2, "after_id": first[-1]["id"]})).json()
last = (await client.get("/admin/users", headers=ADMIN, params={"limit": 2, "after_id": second[-1]["id"]})).json()
assert [u["username"] for u in first + second + last] == [f"user{i}" for i in range(5)]
assert len(last) == 1
assert (await client.get("/admin/users", headers=ADMIN, params={"limit": 501})).status_code == 422
# --- structured errors everywhere (API-16) -----------------------------------------
async def test_admin_errors_are_structured(client):
wrong = await client.get("/admin/users", headers={"X-Admin-Token": "nope"})
assert wrong.status_code == 403 and _code(wrong) == "invalid_admin_token"
missing = await client.post("/admin/users/999/reset-password", headers=ADMIN)
assert missing.status_code == 404 and _code(missing) == "user_not_found"
report = await client.post("/admin/bug-reports/999/status", headers=ADMIN, json={"status": "read"})
assert report.status_code == 404 and _code(report) == "bug_report_not_found"
async def test_framework_errors_are_structured(client):
absent = await client.get("/users/me")
assert absent.status_code == 401 and _code(absent) == "not_authenticated"
assert absent.headers["www-authenticate"] == "Bearer"
assert _code(await client.get("/no/such/path")) == "not_found"
wrong_method = await client.put("/rounds/current")
assert _code(wrong_method) in {"method_not_allowed", "not_found"}
assert (await client.get("/users/me", headers={"Authorization": "Bearer junk"})).json()["detail"]["code"] == "session_expired"
async def test_the_sse_cap_answers_in_the_structured_shape(client, monkeypatch):
from app.api.routes import rounds
from app.rounds.events import RoundEventCapacityError
def full(_ip):
raise RoundEventCapacityError()
monkeypatch.setattr(rounds.broadcaster, "subscribe", full)
resp = await client.get("/rounds/stream")
assert resp.status_code == 503 and _code(resp) == "too_many_streams"
# --- static files (API-09) ---------------------------------------------------------
async def test_static_files_are_revalidated_on_every_use(client):
resp = await client.get("/app.js")
assert resp.status_code == 200 and resp.headers["cache-control"] == "no-cache"
assert resp.headers.get("etag")
+94
View File
@@ -265,6 +265,8 @@ async def test_config_update_without_changes_logs_nothing(client):
{"rbf_timeout_seconds": 1},
{"draw_confirmations": 0}, # B-73: a draw with no confirmations is a draw on the tip
{"draw_confirmations": 21},
{"deposit_confirmations": 0}, # B-82: a deposit credited with no confirmation at all
{"deposit_confirmations": 101},
],
)
async def test_config_rejects_unusable_values(client, payload):
@@ -466,3 +468,95 @@ async def test_config_still_accepts_a_payable_change_and_unrelated_edits(client)
assert resp.status_code == 200
# an unrelated edit is never blocked by the payability check
assert (await client.put("/admin/config", headers=headers, json={"draw_animation_seconds": 5})).status_code == 200
async def test_deposit_confirmations_is_configurable_between_1_and_100(client): # B-82 (DAT-62, API-31)
headers = {"X-Admin-Token": "test-admin-token"}
assert (await client.get("/admin/config", headers=headers)).json()["deposit_confirmations"] == 6
for value in (1, 100, 12):
resp = await client.put("/admin/config", headers=headers, json={"deposit_confirmations": value})
assert resp.status_code == 200 and resp.json()["deposit_confirmations"] == value
audit = (await client.get("/admin/audit-log", headers=headers)).json()
changes = [e["payload"]["deposit_confirmations"] for e in audit if e["event_type"] == "config_updated"]
assert {"from": 100, "to": 12} in changes
async def test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain(client): # B-82 (DAT-68, API-32)
from datetime import datetime, timezone
from sqlalchemy import select
from app.db import base as db_base
from app.db.models import AuditLog, Round
headers = {"X-Admin-Token": "test-admin-token"}
async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported
await conn.run_sync(db_base.Base.metadata.create_all)
async with db_base.AsyncSessionLocal() as session:
session.add(Round(status="paying_out", payout_blocked_at=datetime.now(timezone.utc)))
session.add(Round(status="closed"))
await session.commit()
async with db_base.AsyncSessionLocal() as session:
from app.rounds.pause import pause_automatically
await pause_automatically(session, "payout_blocked")
await session.commit()
assert (await client.get("/admin/config", headers=headers)).json()["paused_reason"] == "payout_blocked"
listed = (await client.get("/admin/rounds", headers=headers)).json() # API-36: the mark is visible
assert sorted(r["payout_blocked_at"] is not None for r in listed) == [False, True]
resp = await client.post("/admin/resume", headers=headers)
assert resp.status_code == 200 and resp.json()["paused"] is False and resp.json()["paused_reason"] is None
async with db_base.AsyncSessionLocal() as session:
assert (await session.scalars(select(Round.payout_blocked_at))).all() == [None, None]
resumed = [e for e in (await session.scalars(select(AuditLog))).all() if e.event_type == "lottery_resumed"]
assert resumed and '"payout_blocked"' in resumed[0].payload_json
# --- B-95 (DAT-62, API-31): the cap on a round's pool ---------------------------------------------------------------
async def test_max_round_pool_sats_is_configurable_and_bounded(client):
headers = {"X-Admin-Token": "test-admin-token"}
assert (await client.get("/admin/config", headers=headers)).json()["max_round_pool_sats"] == 0
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
assert (await put({"max_round_pool_sats": 50_000_000_000})).json()["max_round_pool_sats"] == 50_000_000_000
assert (await put({"max_round_pool_sats": 0})).json()["max_round_pool_sats"] == 0 # 0 switches it off
assert (await put({"max_round_pool_sats": 4_000_000_000_000_000})).status_code == 200
assert (await put({"max_round_pool_sats": 4_000_000_000_000_001})).status_code == 422
assert (await put({"max_round_pool_sats": -1})).status_code == 422
async def test_a_cap_below_the_price_of_one_bet_is_refused_whichever_field_makes_it(client):
headers = {"X-Admin-Token": "test-admin-token"}
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
resp = await put({"max_round_pool_sats": 999_999_999}) # the bet costs 1_000_000_000
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "max_round_pool_below_bet_amount"
assert (await put({"max_round_pool_sats": 1_000_000_000})).status_code == 200 # exactly one bet fits
resp = await put({"bet_amount_sats": 2_000_000_000}) # now the bet would exceed the stored cap
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "max_round_pool_below_bet_amount"
assert (await put({"bet_amount_sats": 2_000_000_000, "max_round_pool_sats": 6_000_000_000})).status_code == 200
assert (await client.get("/admin/config", headers=headers)).json()["bet_amount_sats"] == 2_000_000_000
async def test_a_fee_rate_is_checked_against_the_bet_amount_of_the_round_in_progress(client):
"""B-95 with RND-40: the round in progress is paid with the cost it opened with, which can be
lower than the one configured now, so the fee rate has to leave that one payable too."""
from datetime import datetime, timezone
from app.db import base as db_base
from app.db.models import Round
headers = {"X-Admin-Token": "test-admin-token"}
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported
await conn.run_sync(db_base.Base.metadata.create_all)
assert (await put({"bet_amount_sats": 100_000_000})).status_code == 200
async with db_base.AsyncSessionLocal() as session:
session.add(Round(status="open", opened_at=datetime.now(timezone.utc), bet_amount_sats=50_000)) # opened cheap
await session.commit()
# 14 sat/vB is fine for the configured 1 PLM (and for 100 PLM) but not for the 50_000 sat round
resp = await put({"fee_rate_sat_vb": 14})
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "config_not_payable"
assert (await put({"fee_rate_sat_vb": 10})).status_code == 200
+1
View File
@@ -366,6 +366,7 @@ async def _flagged_user(session_factory) -> int:
def _reinstate_listener(session_factory, others: dict[str, _UnspentServer], active_entries):
listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, _ENDPOINTS)
listener.tip_height = 1000 # B-82: far past every credit threshold, coinbase maturity included
listener.client = _ActiveClient(active_entries)
return listener
+70
View File
@@ -0,0 +1,70 @@
"""B-91 (SEC-51, SEC-52, SEC-53): non-root, pinned images and dependencies, the key in a file."""
import re
from pathlib import Path
import pytest
from app.config import ConfigError, Settings, validate_runtime_secrets
ROOT = Path(__file__).resolve().parents[2]
def _read(name):
return (ROOT / name).read_text()
def test_the_dockerfile_pins_its_base_image_and_runs_as_a_non_root_user():
dockerfile = _read("Dockerfile")
assert re.search(r"^FROM python:3\.12-slim@sha256:[0-9a-f]{64}$", dockerfile, re.M)
assert re.search(r"^USER 1000:1000$", dockerfile, re.M)
assert "--require-hashes" in dockerfile and "umask 077" in dockerfile
def test_every_locked_requirement_carries_a_hash():
lock = _read("requirements.lock")
pinned = re.findall(r"^([A-Za-z0-9_.\-]+==\S+) \\$", lock, re.M)
assert len(pinned) > 20
blocks = re.split(r"\n(?=[A-Za-z0-9_.\-]+==)", lock)[1:]
assert all("--hash=sha256:" in block for block in blocks)
def test_compose_pins_caddy_and_keeps_the_key_out_of_the_environment():
compose = _read("docker-compose.yml")
assert re.search(r"image: caddy:2-alpine@sha256:[0-9a-f]{64}", compose)
assert "XPRV_ENCRYPTION_KEY_FILE=/run/secrets/xprv_encryption_key" in compose
assert "file: ./secrets/xprv_encryption.key" in compose
assert not re.search(r"XPRV_ENCRYPTION_KEY=", compose)
assert "data-permissions" in compose and "service_completed_successfully" in compose
def test_the_example_env_points_at_a_file_not_a_key():
example = _read(".env.example")
assert re.search(r"^XPRV_ENCRYPTION_KEY_FILE=", example, re.M)
assert not re.search(r"^XPRV_ENCRYPTION_KEY=", example, re.M)
@pytest.mark.parametrize("ignore", [".gitignore", ".dockerignore"])
def test_the_secrets_folder_is_kept_out_of_git_and_the_image(ignore):
assert "secrets/" in _read(ignore).split()
def _settings(**overrides):
return Settings(_env_file=None, jwt_secret="x" * 32, **overrides)
def test_the_key_is_read_from_its_file(tmp_path):
key_file = tmp_path / "k"
key_file.write_text("a-fernet-key\n")
config = _settings(xprv_encryption_key_file=str(key_file))
assert config.xprv_encryption_key == "a-fernet-key"
validate_runtime_secrets(config)
@pytest.mark.parametrize("content", [None, " \n"])
def test_a_missing_or_empty_key_file_stops_the_server_naming_the_file(tmp_path, content):
key_file = tmp_path / "k"
if content is not None:
key_file.write_text(content)
with pytest.raises(ConfigError, match="XPRV_ENCRYPTION_KEY_FILE .*is missing or empty"):
validate_runtime_secrets(_settings(xprv_encryption_key_file=str(key_file)))
+312
View File
@@ -0,0 +1,312 @@
"""B-82 (DEP-03, DEP-04, DEP-12, DEP-14, ELC-17, DAT-10, DAT-62, API-31): a deposit is
credited at `deposit_confirmations` (default 6), a coinbase output at its maturity of 120, and
only an output of a platform transaction at 1 — the deposit being the one input a user can
double-spend with a private chain.
The real listener is driven here: a fake active connection, fake "other" servers for the
quorum, real transactions so the coinbase check reads (and hash-checks) an actual raw tx.
"""
import pytest
from embit import script
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from sqlalchemy import select
from app.db.models import AuditLog, PendingTransaction, PendingTransactionTxid, RoundConfig, User, UtxoEvent
from app.deposits.service import (
COINBASE_MATURITY,
DepositPolicy,
classify_new_deposits,
find_new_credit_candidates,
)
from app.electrum.listener import ElectrumListener
from app.wallet.balance import compute_pending_balance
from tests.unit.test_electrum_listener import _ENDPOINTS, session_factory # noqa: F401 (fixture)
_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
_OUT = script.Script.from_address(_ADDRESS)
def _plain_tx(seed: int = 1) -> Transaction:
return Transaction(
vin=[TransactionInput(bytes([seed]) * 32, 0)], vout=[TransactionOutput(1_000_000_000, _OUT)]
)
def _coinbase_tx() -> Transaction:
return Transaction(vin=[TransactionInput(bytes(32), 0xFFFFFFFF)], vout=[TransactionOutput(1_000_000_000, _OUT)])
def _entry(tx: Transaction, height: int, value: int = 1_000_000_000) -> dict:
return {"tx_hash": tx.txid().hex(), "tx_pos": 0, "height": height, "value": value}
class _Active:
"""The listener's active connection: the address' coins, and raw transactions by txid."""
def __init__(self, entries, raws: dict[str, str] | None = None):
self.entries = entries
self.raws = raws or {}
self.asked_for: list[str] = []
async def listunspent(self, scripthash):
return self.entries
async def get_transaction(self, txid, verbose=False):
self.asked_for.append(txid)
return self.raws[txid]
class _Other:
def __init__(self, entries):
self._entries = entries
async def connect(self):
pass
async def listunspent(self, scripthash):
return self._entries
async def close(self):
pass
async def _user(session_factory, *, deposit_confirmations: int | None = None) -> int:
async with session_factory() as session:
if deposit_confirmations is not None:
session.add(RoundConfig(fee_address="", deposit_confirmations=deposit_confirmations))
user = User(username="alice", password_hash="x", derivation_index=0, address=_ADDRESS)
session.add(user)
await session.commit()
return user.id
def _listener(session_factory, active: _Active, others_entries, *, tip: int) -> ElectrumListener:
others = {host: _Other(others_entries[host]) for host in ("second.example", "third.example")}
listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, _ENDPOINTS)
listener.client = active
listener.tip_height = tip
return listener
async def _balance(session_factory, user_id: int) -> int:
async with session_factory() as session:
return (await session.get(User, user_id)).cached_balance_sats
def _same(entries):
return {"second.example": entries, "third.example": entries}
# --- the thresholds (DEP-03) -----------------------------------------------------------------------
async def test_a_deposit_is_credited_exactly_at_six_confirmations(session_factory):
user_id = await _user(session_factory)
tx = _plain_tx()
entry = _entry(tx, height=100) # confirmations = tip - 100 + 1
active = _Active([entry], {tx.txid().hex(): tx.serialize().hex()})
at_five = _listener(session_factory, active, _same([entry]), tip=104) # 5 confirmations
await at_five.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
assert at_five.pending_deposit_sats(user_id) == 1_000_000_000 # waiting, and shown as such
at_six = _listener(session_factory, active, _same([entry]), tip=105) # 6 confirmations
await at_six.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 1_000_000_000
assert at_six.pending_deposit_sats(user_id) == 0
async def test_deposit_confirmations_is_read_from_the_configuration(session_factory):
user_id = await _user(session_factory, deposit_confirmations=2)
tx = _plain_tx()
entry = _entry(tx, height=100)
active = _Active([entry], {tx.txid().hex(): tx.serialize().hex()})
await _listener(session_factory, active, _same([entry]), tip=100).refresh_user(user_id, "sh") # 1 conf
assert await _balance(session_factory, user_id) == 0
await _listener(session_factory, active, _same([entry]), tip=101).refresh_user(user_id, "sh") # 2 confs
assert await _balance(session_factory, user_id) == 1_000_000_000
async def test_an_immature_coinbase_is_not_credited_until_it_has_120_confirmations(session_factory):
user_id = await _user(session_factory)
tx = _coinbase_tx()
entry = _entry(tx, height=100)
txid = tx.txid().hex()
active = _Active([entry], {txid: tx.serialize().hex()})
# plenty for a deposit (50 confirmations), not for a coinbase
immature = _listener(session_factory, active, _same([entry]), tip=149)
await immature.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
assert immature.pending_deposit_sats(user_id) == 1_000_000_000
assert txid in active.asked_for # it was recognised as a coinbase from the raw transaction
one_short = _listener(session_factory, active, _same([entry]), tip=100 + COINBASE_MATURITY - 2)
await one_short.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
mature = _listener(session_factory, active, _same([entry]), tip=100 + COINBASE_MATURITY - 1)
await mature.refresh_user(user_id, "sh") # exactly 120 confirmations
assert await _balance(session_factory, user_id) == 1_000_000_000
async def test_a_coinbase_is_not_mistaken_for_a_plain_deposit_when_the_server_cannot_say(session_factory):
"""If the raw transaction cannot be fetched, or hashes to something else than the txid,
the output waits — a server cannot make an immature coinbase look mature by lying."""
user_id = await _user(session_factory)
tx, other = _coinbase_tx(), _plain_tx(9)
entry = _entry(tx, height=100)
missing = _Active([entry], {}) # KeyError on get_transaction
await _listener(session_factory, missing, _same([entry]), tip=160).refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
swapped = _Active([entry], {tx.txid().hex(): other.serialize().hex()}) # a different transaction
listener = _listener(session_factory, swapped, _same([entry]), tip=160)
assert await listener.is_coinbase(tx.txid().hex()) is None
await listener.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
async def test_an_output_of_a_platform_transaction_is_credited_at_one_confirmation(session_factory):
"""The change of a bet, a withdrawal or a payout: the platform built the transaction, so
nobody can double-spend its inputs — including a version replaced by an RBF bump."""
user_id = await _user(session_factory)
current, replaced = _plain_tx(2), _plain_tx(3)
async with session_factory() as session:
row = PendingTransaction(
kind="bet", user_id=user_id, current_txid=current.txid().hex(), fee_rate_sat_vb=1,
raw_tx_hex=current.serialize().hex(), status="pending",
)
session.add(row)
await session.flush()
session.add(PendingTransactionTxid(pending_transaction_id=row.id, txid=replaced.txid().hex(), raw_hex="00"))
await session.commit()
entries = [_entry(current, height=100), _entry(replaced, height=100)]
active = _Active(entries, {})
await _listener(session_factory, active, _same(entries), tip=100).refresh_user(user_id, "sh") # 1 conf
assert active.asked_for == [] # no coinbase lookup either: a platform output is not a deposit
assert await _balance(session_factory, user_id) == 2_000_000_000
async def test_dust_is_never_a_waiting_deposit(session_factory):
user_id = await _user(session_factory)
tx = _plain_tx()
entry = _entry(tx, height=100, value=293)
listener = _listener(session_factory, _Active([entry]), _same([entry]), tip=100)
await listener.refresh_user(user_id, "sh")
assert listener.pending_deposit_sats(user_id) == 0 and await _balance(session_factory, user_id) == 0
# --- the quorum counts confirmations on our tip (ELC-17, DEP-04) -------------------------------------
async def test_the_other_servers_must_place_the_coin_deep_enough_too(session_factory):
"""Our active server says 6 confirmations; two others put the same coin one block higher
(5 confirmations on our tip): the credit is withheld, not lost."""
user_id = await _user(session_factory)
tx = _plain_tx()
raws = {tx.txid().hex(): tx.serialize().hex()}
ours = _entry(tx, height=100)
shallower = _entry(tx, height=101)
listener = _listener(session_factory, _Active([ours], raws), _same([shallower]), tip=105)
await listener.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
agreeing = _listener(session_factory, _Active([ours], raws), _same([ours]), tip=105)
await agreeing.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 1_000_000_000
async def test_corroborate_utxo_credit_compares_heights_on_our_tip(session_factory):
listener = _listener(session_factory, _Active([]), _same([{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 5}]), tip=105)
assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5, required_confirmations=6) is True
assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5, required_confirmations=7) is False
# without a requirement only "confirmed" matters, as before
assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5) is True
# --- classification and the displayed balance (DEP-12 (b), DEP-14) -----------------------------------
async def test_classify_new_deposits_splits_credit_from_waiting():
async def never(_txid):
raise AssertionError("no coinbase lookup expected")
entries = [
{"tx_hash": "01" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # platform, 1 conf
{"tx_hash": "02" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # deposit, 1 conf: waits
{"tx_hash": "03" * 32, "tx_pos": 0, "height": 96, "value": 1_000}, # deposit, 5 confs: waits
{"tx_hash": "04" * 32, "tx_pos": 0, "height": 500, "value": 1_000}, # not on our chain yet: waits
]
policy = DepositPolicy(deposit_confirmations=6, platform_txids={"01" * 32})
credit, waiting = await classify_new_deposits(entries, 100, policy, never)
assert [(e["tx_hash"], required) for e, required in credit] == [("01" * 32, 1)]
assert [e["tx_hash"] for e in waiting] == ["02" * 32, "03" * 32, "04" * 32]
async def test_the_displayed_balance_includes_waiting_deposits_but_the_spendable_one_does_not(session_factory):
user_id = await _user(session_factory)
async with session_factory() as session:
user = await session.get(User, user_id)
assert await compute_pending_balance(session, user) == (0, False)
shown, has_pending = await compute_pending_balance(session, user, pending_deposit_sats=7_000)
assert (shown, has_pending) == (7_000, True)
assert user.cached_balance_sats == 0 # DEP-13: never spendable until credited
async def test_a_waiting_deposit_that_vanishes_stops_being_shown(session_factory):
"""DEP-14: the set is replaced at every refresh, so a deposit dropped by a reorganisation
is no longer shown."""
user_id = await _user(session_factory)
tx = _plain_tx()
entry = _entry(tx, height=100)
listener = _listener(session_factory, _Active([entry]), _same([entry]), tip=100)
await listener.refresh_user(user_id, "sh")
assert listener.pending_deposit_sats(user_id) == 1_000_000_000
listener.client = _Active([]) # gone from the chain
await listener.refresh_user(user_id, "sh")
assert listener.pending_deposit_sats(user_id) == 0
async def test_new_blocks_refresh_the_users_that_have_a_waiting_deposit(session_factory, monkeypatch):
"""No scripthash notification announces that a deposit matured (its history did not
change), so each block refreshes only the users with a waiting deposit, one task at a time."""
import asyncio
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
listener.client = object()
listener._scripthash_to_user = {"sh1": 1, "sh2": 2}
listener._pending_deposits = {1: [{"value": 5}]}
started: list[int] = []
release = asyncio.Event()
async def fake_refresh(user_id, scripthash):
started.append(user_id)
await release.wait()
monkeypatch.setattr(listener, "refresh_user", fake_refresh)
listener._refresh_waiting_deposits()
listener._refresh_waiting_deposits() # a second block while the first refresh is still running
await asyncio.sleep(0)
assert started == [1] # user 2 has nothing waiting; user 1 is not refreshed twice at once
release.set()
await asyncio.sleep(0.01)
listener._refresh_waiting_deposits()
await asyncio.sleep(0)
assert started == [1, 1]
async def test_find_new_credit_candidates_still_lists_every_new_confirmed_output(session_factory):
"""The read half is unchanged: thresholds are applied by classify_new_deposits, between
the read and the write (no session open across the coinbase lookup, B-18)."""
user_id = await _user(session_factory)
entries = [{"tx_hash": "05" * 32, "tx_pos": 0, "height": 100, "value": 1_000}]
async with session_factory() as session:
assert await find_new_credit_candidates(session, user_id, entries) == entries
assert (await session.scalars(select(UtxoEvent))).all() == []
assert (await session.scalars(select(AuditLog))).all() == []
+37
View File
@@ -325,12 +325,30 @@ class _PinWriter(FakeWriter):
self.closed = True
def _answer_features(reader, writer, result=None):
"""Make `writer` answer `server.features` the moment it is asked: a reply fed up front
would be read before its request is registered, and dropped."""
from app.wallet.plm_network import GENESIS_HASH
original = writer.write
def write(data):
original(data)
sent = json.loads(data.decode())
if sent["method"] == "server.features":
reply = {"genesis_hash": GENESIS_HASH} if result is None else result
reader.feed_data((json.dumps({"id": sent["id"], "result": reply}) + "\n").encode())
writer.write = write
async def _connect_with_pin(monkeypatch, pin, der):
import hashlib # noqa: F401
writer = _PinWriter(der)
reader = asyncio.StreamReader()
reader.feed_data(b'{"id": 1, "result": ["fake", "1.4"]}\n')
_answer_features(reader, writer)
async def fake_open_connection(host, port, **kwargs):
return reader, writer
@@ -413,3 +431,22 @@ async def test_only_a_server_error_object_is_an_explicit_refusal():
silent._read_task.cancel()
finally:
client_module._REQUEST_TIMEOUT_SECONDS = original
async def test_a_server_on_another_chain_is_refused_at_the_handshake(monkeypatch):
"""ELC-30: Bitcoin's genesis (or no genesis at all) fails like an unreachable server."""
import pytest
bitcoin = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26e"
for result in ({"genesis_hash": bitcoin}, {}):
writer = FakeWriter()
reader = asyncio.StreamReader()
reader.feed_data(b'{"id": 1, "result": ["fake", "1.4"]}\n')
_answer_features(reader, writer, result)
async def fake_open_connection(host, port, reader=reader, writer=writer, **kwargs):
return reader, writer
monkeypatch.setattr(asyncio, "open_connection", fake_open_connection)
with pytest.raises(ElectrumError, match="not on the PLM chain"):
await ElectrumClient("localhost", 1).connect()
+4
View File
@@ -405,6 +405,7 @@ async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(sessio
}
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
listener.client = _ActiveClient(_UNRELATED_ENTRY) # our own connection no longer sees the UTXO either
await listener.refresh_user(user_id, "scripthash")
@@ -431,6 +432,7 @@ async def test_refresh_user_does_not_mark_when_corroboration_fails(session_facto
{"first.example": [], "second.example": still_there, "third.example": still_there}
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
listener.client = _ActiveClient(_UNRELATED_ENTRY)
await listener.refresh_user(user_id, "scripthash")
@@ -719,6 +721,7 @@ async def test_refresh_user_does_not_credit_a_utxo_only_our_own_server_reports(s
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
# Our own connection reports the tracked UTXO plus a phantom one nobody else has.
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
listener.client = _ActiveClient(
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
)
@@ -742,6 +745,7 @@ async def test_refresh_user_credits_once_the_others_corroborate(session_factory)
{"first.example": [], "second.example": _PHANTOM, "third.example": _PHANTOM}
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
listener.client = _ActiveClient(
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
)
+111
View File
@@ -0,0 +1,111 @@
"""B-89 (SEC-49, SEC-50, SEC-09): the master key must be the one that produced the database."""
import hashlib
import os
import sqlite3
import stat
import pytest
from cryptography.fernet import Fernet
from embit import script
from app.config import ConfigError, settings
from app.wallet import hd
from app.wallet.keystore import encrypt_round_seed
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.verify import load_sample, sample_problems, verify_master_key
def _database(tmp_path, monkeypatch, users=(), rounds=()):
"""A file DB with just the columns the check reads."""
path = tmp_path / "check.db"
db = sqlite3.connect(path)
db.execute("CREATE TABLE users (derivation_index INTEGER, address TEXT)")
db.execute(
"CREATE TABLE rounds (id INTEGER, pool_address TEXT, server_seed_enc TEXT, server_seed TEXT, server_seed_hash TEXT)"
)
db.executemany("INSERT INTO users VALUES (?, ?)", users)
db.executemany("INSERT INTO rounds VALUES (?, ?, ?, ?, ?)", rounds)
db.commit()
db.close()
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{path}")
def _addr(path):
return script.p2wpkh(hd._load_account_key().derive(path).to_public()).address(network=PLM_MAINNET)
def test_a_fresh_instance_only_needs_the_key_to_decrypt(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/absent.db")
verify_master_key()
def test_the_matching_key_passes(tmp_path, monkeypatch):
_database(
tmp_path,
monkeypatch,
users=[(i, _addr(f"0/{i}")) for i in range(25)],
rounds=[(1, _addr("1/0"), None, None, None), (2, _addr("2/2"), None, None, None)],
)
verify_master_key()
def test_another_master_key_is_refused_without_printing_it(tmp_path, monkeypatch):
"""SEC-49: the rows came from a different xprv (the two-keys incident)."""
_database(tmp_path, monkeypatch, users=[(0, _addr("0/0")), (1, _addr("0/1"))])
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "other.xprv.enc"))
hd._account_key = None
hd.generate_master_key()
with pytest.raises(ConfigError, match="derivation index 0") as excinfo:
verify_master_key()
assert "xprv" not in str(excinfo.value).lower().replace("master key", "")
def test_only_the_edges_of_the_user_table_are_sampled(tmp_path, monkeypatch):
users = [(i, _addr(f"0/{i}")) for i in range(40)]
_database(tmp_path, monkeypatch, users=users)
assert [i for i, _ in load_sample().users] == list(range(10)) + list(range(30, 40))
def test_a_round_pool_address_must_be_its_own_or_the_reserve(tmp_path, monkeypatch):
_database(tmp_path, monkeypatch, rounds=[(5, _addr("2/9"), None, None, None)])
with pytest.raises(ConfigError, match="round 5"):
verify_master_key()
def test_a_wrong_encryption_key_is_refused_at_startup(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
hd._account_key = None
with pytest.raises(ConfigError, match="cannot be loaded"):
verify_master_key()
def test_an_unrevealed_secret_must_decrypt_and_match_its_hash(tmp_path, monkeypatch):
secret = "ab" * 32
good = (1, "", encrypt_round_seed(secret), None, hashlib.sha256(bytes.fromhex(secret)).hexdigest())
bad_hash = (2, "", encrypt_round_seed(secret), None, "00" * 32)
revealed = (3, "", "not-a-token", secret, "00" * 32) # already revealed: not checked
_database(tmp_path, monkeypatch, rounds=[good, bad_hash, revealed])
problems = sample_problems(hd._load_account_key(), load_sample(), settings.xprv_encryption_key)
assert problems == ["secret of round 2 does not match its published hash"]
def test_the_key_file_is_written_0600(tmp_path, monkeypatch):
path = tmp_path / "k.xprv.enc"
monkeypatch.setattr(settings, "master_key_path", str(path))
os.close(os.open(path, os.O_CREAT | os.O_WRONLY, 0o644)) # a leftover world-readable file
hd.generate_master_key(overwrite=True)
assert stat.S_IMODE(path.stat().st_mode) == 0o600
def test_the_import_script_refuses_an_xprv_that_does_not_match_the_database(tmp_path, monkeypatch):
"""SEC-05 / SEC-50: a Bitcoin-looking xprv passes the version check, the addresses do not."""
from embit.bip32 import HDKey
from app.wallet.verify import account_key
_database(tmp_path, monkeypatch, users=[(0, _addr("0/0"))])
other = HDKey.from_seed(b"\x09" * 32, version=PLM_MAINNET["xprv"]).to_base58(version=PLM_MAINNET["xprv"])
assert sample_problems(account_key(other), load_sample())
mine = hd._load_account_key()
assert sample_problems(mine, load_sample()) == []
+10
View File
@@ -203,3 +203,13 @@ def test_the_bump_reserve_column_exists_at_head_and_is_nullable(migrated_db): #
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
assert "bump_reserve_sats" in rounds and rounds["bump_reserve_sats"][3] == 0
assert _index_sql(migrated_db, "ix_rounds_single_active") is not None
def test_deposit_confirmations_and_payout_blocked_columns_exist_at_head(migrated_db): # B-82 (DAT-62, DAT-68)
with sqlite3.connect(migrated_db) as conn:
config = {r[1]: r for r in conn.execute("PRAGMA table_info(round_config)")}
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
assert config["deposit_confirmations"][3] == 1 and config["deposit_confirmations"][4] in ("6", "'6'")
assert "payout_blocked_at" in rounds and rounds["payout_blocked_at"][3] == 0
assert _index_sql(migrated_db, "ix_rounds_single_active") is not None
assert _index_sql(migrated_db, "ix_users_username_lower") is not None
+195
View File
@@ -0,0 +1,195 @@
"""B-92 (API-37, API-46, API-47, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29): readiness, Electrum
status, audit browsing, the registration row and the logs."""
import json
import logging
import sys
from datetime import datetime, timedelta, timezone
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from tests.unit.test_abuse_limits import ADMIN, _register, client # noqa: F401 (fixture + helper)
@pytest.fixture
def listener(client):
from app.main import app
return app.state.electrum_listener
# --- /health/ready -----------------------------------------------------------------
def _ready_app(listener, *, header_age, heartbeat_age, connected=True):
from types import SimpleNamespace
from app.main import app
listener.client = object() if connected else None
listener.last_header_age_seconds = lambda: header_age
app.state.scheduler = SimpleNamespace(heartbeat_age_seconds=lambda: heartbeat_age)
async def test_ready_only_when_connected_fresh_and_alive(client, listener):
_ready_app(listener, header_age=100, heartbeat_age=5)
ok = await client.get("/health/ready")
assert ok.status_code == 200 and ok.json() == {
"status": "ready",
"checks": {"electrum_connected": True, "last_header_age_seconds": 100, "scheduler_heartbeat_age_seconds": 5},
}
for kwargs in (
{"header_age": 100, "heartbeat_age": 5, "connected": False},
{"header_age": 1800, "heartbeat_age": 5},
{"header_age": None, "heartbeat_age": 5},
{"header_age": 100, "heartbeat_age": 30},
{"header_age": 100, "heartbeat_age": None},
):
_ready_app(listener, **kwargs)
bad = await client.get("/health/ready")
assert bad.status_code == 503 and bad.json()["status"] == "not_ready", kwargs
async def test_a_360_second_gap_between_blocks_is_not_unreadiness(client, listener):
"""With 120 s blocks, 360 s of silence happens ~5% of the time by chance."""
_ready_app(listener, header_age=400, heartbeat_age=5)
assert (await client.get("/health/ready")).status_code == 200
def test_the_scheduler_beats_on_every_tick(monkeypatch):
import asyncio
from app.rounds.scheduler import RoundScheduler
scheduler = RoundScheduler(None, None)
assert scheduler.heartbeat_age_seconds() is None
async def one_tick():
async def fake_tick():
raise asyncio.CancelledError
scheduler._tick = fake_tick
with pytest.raises(asyncio.CancelledError):
await scheduler.run()
asyncio.run(one_tick())
assert scheduler.heartbeat_age_seconds() == 0
# --- /admin/electrum ---------------------------------------------------------------
async def test_the_electrum_status_endpoint(client, listener):
from app.electrum.client import ElectrumEndpoint
listener._endpoints = [ElectrumEndpoint("a.example", 50002, True), ElectrumEndpoint("b.example", 50002, True)]
listener.client = object()
listener.tip_height = 538900
listener.last_rotation = {"reason": "stalled_tip", "from": "x:1", "at": "2026-10-18T00:00:00+00:00"}
body = (await client.get("/admin/electrum", headers=ADMIN)).json()
assert body["connected"] is True and body["endpoint"] == str(listener.current_endpoint)
assert body["tip_height"] == 538900 and body["fallback_count"] == 1 and body["quorum_enforced"] is True
assert body["last_rotation"]["reason"] == "stalled_tip"
listener.client = None
assert (await client.get("/admin/electrum", headers=ADMIN)).json()["endpoint"] is None
assert (await client.get("/admin/electrum")).status_code == 403
async def test_the_listener_records_why_it_moved_to_another_server():
from app.electrum.listener import ServerStalledError, _rotation_reason
from app.rounds.draw import HeaderValidationError
assert _rotation_reason(ServerStalledError("x")) == "stalled_tip"
assert _rotation_reason(HeaderValidationError("x")) == "invalid_header"
assert _rotation_reason(RuntimeError("x")) == "error"
# --- /admin/audit-log ---------------------------------------------------------------
async def _seed_audit(rows):
from app.db import base as db_base
from app.db.models import AuditLog
async with db_base.AsyncSessionLocal() as session:
for event, user_id, round_id, at in rows:
session.add(AuditLog(event_type=event, payload_json="{}", user_id=user_id, round_id=round_id, created_at=at))
await session.commit()
async def test_the_audit_log_is_filtered_and_paged(client):
now = datetime(2026, 10, 1, 12, tzinfo=timezone.utc)
await _seed_audit(
[(f"event_{i % 3}", i % 2, 10 + i % 2, now + timedelta(hours=i)) for i in range(12)]
)
async def ids(**params):
r = await client.get("/admin/audit-log", headers=ADMIN, params=params)
assert r.status_code == 200, r.text
return [e["id"] for e in r.json()]
everything = await ids(limit=500)
assert everything == sorted(everything, reverse=True) and len(everything) == 12
first = await ids(limit=5)
assert first == everything[:5]
assert await ids(limit=5, before_id=first[-1]) == everything[5:10]
r = await client.get("/admin/audit-log", headers=ADMIN, params=[("event_type", "event_0"), ("event_type", "event_1")])
assert {e["event_type"] for e in r.json()} == {"event_0", "event_1"}
assert await ids(event_type="nope") == []
assert len(await ids(round_id=10, user_id=0)) == 6
window = await ids(**{"from": "2026-10-01T14:00:00+00:00", "to": "2026-10-01T17:00:00+00:00"})
assert len(window) == 3 # 14:00, 15:00, 16:00 — `to` is exclusive
for bad in ({"from": "2026-10-01T14:00:00"}, {"before_id": "x"}, {"to": "yesterday"}):
r = await client.get("/admin/audit-log", headers=ADMIN, params=bad)
assert r.status_code == 422 and r.json()["detail"]["code"] == "invalid_request"
# --- user_registered -------------------------------------------------------------------
async def test_a_registration_writes_user_registered_in_its_transaction(client):
from app.db import base as db_base
from app.db.models import AuditLog, User
await _register(client, username="carol")
async with db_base.AsyncSessionLocal() as session:
user = (await session.scalars(select(User))).one()
row = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "user_registered"))).one()
assert row.user_id == user.id and row.round_id is None
assert json.loads(row.payload_json) == {"derivation_index": 0, "address": user.address}
assert "carol" not in row.payload_json
# --- logs and proxy -----------------------------------------------------------------------
def test_the_app_logs_also_go_to_stdout(monkeypatch, tmp_path):
from app import logging_config
root = logging.getLogger()
before = list(root.handlers)
monkeypatch.setattr(logging_config, "LOG_DIR", tmp_path)
monkeypatch.setattr(logging_config, "LOG_FILE", tmp_path / "app.log")
try:
logging_config.setup_logging()
added = [h for h in root.handlers if h not in before]
assert any(isinstance(h, logging.StreamHandler) and h.stream is sys.stdout for h in added)
finally:
for h in [h for h in root.handlers if h not in before]:
root.removeHandler(h)
h.close()
def test_uvicorn_trusts_only_the_proxy_and_the_container_is_checked():
from pathlib import Path
root = Path(__file__).resolve().parents[2]
dockerfile, compose = (root / "Dockerfile").read_text(), (root / "docker-compose.yml").read_text()
assert "--proxy-headers" in dockerfile and "--forwarded-allow-ips" in dockerfile
assert "FORWARDED_ALLOW_IPS=172.28.0.10" in compose and "ipv4_address: 172.28.0.10" in compose
assert "subnet: 172.28.0.0/24" in compose and "/health/ready" in compose
+319
View File
@@ -0,0 +1,319 @@
"""B-82 (RND-22, RND-39, DAT-11, DAT-67, DAT-68, ELC-27, AUD-44, AUD-45): the coins of a round
are verified with the quorum before a payout is built.
A user can bet and then — with a private chain, or just by being unlucky in a reorganisation —
get the deposit that funded the bet erased. The bet leaves the chain but its participant stayed
`confirmed`, so the payout looked for coins that no longer existed and failed "insufficient pool
UTXOs" for ever, with every other player's money stuck. Now the bets that are gone leave the
round, the 70/30 split is computed on what is really there, and a winner whose own bet is gone
holds the payout and pauses the lottery.
"""
import pytest
from embit.transaction import Transaction
from sqlalchemy import select
from app.config import MIN_QUORUM_RESPONSES
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User
from app.electrum.client import ElectrumEndpoint
from app.electrum.listener import ElectrumListener
from app.electrum.scripthash import address_to_scripthash
from app.rounds.scheduler import RoundScheduler
from app.rounds.service import payout_config_problem, winner_share
from app.wallet.hd import derive_pool_address
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import (
DUST_LIMIT_SATS,
MAX_PARTICIPANTS_PER_ROUND,
MAX_TX_INPUTS,
estimate_vsize,
payout_bump_reserve,
)
from tests.unit.test_scheduler import ( # noqa: F401 (fixture and helpers)
_FEE_ADDRESS,
_WINNER_ADDRESS,
FakePayoutClient,
FakePayoutListener,
_payout_bet_txid as _bet_txid,
_seed_paying_out_round,
payout_session_factory,
)
_BET = 999_000_000 # the net amount of a 10 PLM bet, roughly
def _scheduler(session_factory, listener=None):
listener = listener or FakePayoutListener(FakePayoutClient([]))
return RoundScheduler(session_factory, listener), listener
async def _state(session_factory):
async with session_factory() as session:
round_ = await session.get(Round, 1)
participants = {
p.bet_txid: p.status for p in (await session.scalars(select(RoundParticipant))).all()
}
events = [(e.event_type, e.payload_json) for e in (await session.scalars(select(AuditLog).order_by(AuditLog.id))).all()]
pending = (await session.scalars(select(PendingTransaction))).all()
config = await session.scalar(select(RoundConfig))
return round_, participants, events, pending, config
def _names(events):
return [name for name, _ in events]
# --- everything is there -------------------------------------------------------------------------------
async def test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else(payout_session_factory):
"""RND-22: other coins at the pool address are not touched — the active connection is not
even asked for the address' coins, and the inputs are the participants' `bet_txid:bet_vout`."""
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
stray = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 10, "value": 5_000_000_000}]
listener.client = FakePayoutClient(stray)
await scheduler._trigger_payout(1)
assert listener.client.broadcasted
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
assert sorted((v.txid.hex(), v.vout) for v in tx.vin) == sorted((_bet_txid(i), 0) for i in range(3))
assert listener.verified_scripthashes == [address_to_scripthash(derive_pool_address(1))]
assert [(t, v, a) for t, v, a in listener.verified_outpoints[0]] == [(_bet_txid(i), 0, _BET) for i in range(3)]
round_, participants, events, pending, _ = await _state(payout_session_factory)
assert set(participants.values()) == {"confirmed"} and round_.pool_amount_sats == 3 * _BET
assert "participant_reorged_out" not in _names(events) and "payout_blocked" not in _names(events)
# --- a bet that is gone (RND-39, DAT-67, AUD-45) ----------------------------------------------------------
async def test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there(payout_session_factory):
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET + 1, _BET + 2, _BET + 3])
scheduler, listener = _scheduler(payout_session_factory)
gone = (_bet_txid(2), 0) # a loser's bet, double-spent away
listener.pool_absent = {gone}
await scheduler._trigger_payout(1)
round_, participants, events, pending, _ = await _state(payout_session_factory)
remaining = _BET + (_BET + 1) + (_BET + 3)
assert participants[gone[0]] == "reorged_out"
assert [s for t, s in participants.items() if t != gone[0]] == ["confirmed"] * 3
assert round_.pool_amount_sats == remaining # DAT-67: rewritten, and only ever reduced
row = next(p for n, p in events if n == "participant_reorged_out")
assert f'"txid": "{gone[0]}"' in row and f'"pool_amount_sats": {remaining}' in row
# the transaction spends the three bets that are there, and splits exactly their sum
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
assert gone not in {(v.txid.hex(), v.vout) for v in tx.vin}
assert len(tx.vin) == 3
fee = estimate_vsize(3, 3)
reserve = payout_bump_reserve(3)
assert tx.vout[0].value == winner_share(remaining) - fee == round_.winner_amount_sats
assert tx.vout[1].value == remaining - winner_share(remaining) - reserve == round_.fee_amount_sats
assert tx.vout[2].value == reserve == round_.bump_reserve_sats
assert sum(o.value for o in tx.vout) + fee == remaining
async def test_several_bets_gone_at_once_each_get_a_row_in_one_transaction(payout_session_factory):
await _seed_paying_out_round(payout_session_factory, bets=[_BET] * 5)
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_absent = {(_bet_txid(1), 0), (_bet_txid(4), 0)}
await scheduler._trigger_payout(1)
round_, participants, events, _, _ = await _state(payout_session_factory)
assert sorted(t for t, s in participants.items() if s == "reorged_out") == sorted([_bet_txid(1), _bet_txid(4)])
assert _names(events).count("participant_reorged_out") == 2
assert round_.pool_amount_sats == 3 * _BET
# --- the winner's own bet is gone (DAT-68, AUD-44) -----------------------------------------------------------
async def test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery(payout_session_factory):
winner_id = await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_absent = {(_bet_txid(0), 0), (_bet_txid(2), 0)} # the winner's, and one more
await scheduler._trigger_payout(1)
round_, participants, events, pending, config = await _state(payout_session_factory)
assert listener.client.broadcasted == [] and pending == [] # nothing built, nothing sent
assert round_.payout_blocked_at is not None and round_.status == "paying_out"
assert (config.paused, config.paused_reason) == (True, "payout_blocked")
names = _names(events)
assert "payout_blocked" in names and "payout_failed" not in names # not a failure, not a retry clock
assert ("lottery_paused", '{"reason": "payout_blocked"}') in events
# the other missing bet is excluded now; the winner's stays `confirmed` so that it is
# verified again after the resume (OPS-30)
assert participants[_bet_txid(2)] == "reorged_out" and participants[_bet_txid(0)] == "confirmed"
blocked = next(p for n, p in events if n == "payout_blocked")
assert f'"txid": "{_bet_txid(0)}"' in blocked
async def test_a_held_payout_is_not_attempted_again_until_the_lottery_is_resumed(payout_session_factory):
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_absent = {(_bet_txid(0), 0)}
await scheduler._trigger_payout(1)
asked = len(listener.verified_scripthashes)
for _ in range(3): # three scheduler steps over the held round
await scheduler._retry_payout_if_due(1)
await scheduler._trigger_payout(1)
assert len(listener.verified_scripthashes) == asked # no verification, no build, no failure rows
_, _, events, pending, _ = await _state(payout_session_factory)
assert pending == [] and "payout_failed" not in _names(events)
@pytest.mark.parametrize("still_gone", [True, False])
async def test_after_the_resume_the_verification_repeats(payout_session_factory, still_gone):
"""DAT-68: the operator's resume clears the mark; the next attempt verifies again. If the
bet is still gone the payout is held and the lottery paused again (a new row); if it is
back, the payout goes out."""
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_absent = {(_bet_txid(0), 0)}
await scheduler._trigger_payout(1)
async with payout_session_factory() as session: # what POST /admin/resume does
round_ = await session.get(Round, 1)
round_.payout_blocked_at = None
(await session.scalar(select(RoundConfig))).paused = False
await session.commit()
if not still_gone:
listener.pool_absent = set()
await scheduler._trigger_payout(1)
round_, _, events, pending, config = await _state(payout_session_factory)
if still_gone:
assert _names(events).count("payout_blocked") == 2
assert round_.payout_blocked_at is not None and config.paused is True and pending == []
else:
assert round_.payout_blocked_at is None and len(pending) == 1 and "payout_sent" in _names(events)
# --- no quorum (RND-25, ELC-27) ------------------------------------------------------------------------------
async def test_without_a_quorum_nothing_changes_and_the_attempt_is_a_payout_failed(payout_session_factory):
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_unavailable = True
await scheduler._trigger_payout(1)
round_, participants, events, pending, config = await _state(payout_session_factory)
assert set(participants.values()) == {"confirmed"} and round_.pool_amount_sats == 2 * _BET
assert pending == [] and listener.client.broadcasted == []
assert [p for n, p in events if n == "payout_failed"] and "pool verification unavailable" in dict(events)["payout_failed"]
assert config.paused is False and round_.payout_blocked_at is None
# --- the extremes of RND-40, through the scheduler ----------------------------------------------------------------
async def test_a_round_of_one_participant_at_the_smallest_viable_bet_is_paid(payout_session_factory):
"""The smallest net bet RND-40 allows (the config's own bound) still yields a payout whose
three outputs are all at least dust."""
bet = next(b for b in range(10_000, 200_000) if payout_config_problem(b + estimate_vsize(MAX_TX_INPUTS, 2), 1) is None)
await _seed_paying_out_round(payout_session_factory, bets=[bet])
scheduler, listener = _scheduler(payout_session_factory)
await scheduler._trigger_payout(1)
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
assert len(tx.vin) == 1 and len(tx.vout) == 3
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
assert sum(o.value for o in tx.vout) + estimate_vsize(1, 3) == bet
async def test_a_full_round_is_paid_with_the_reserve_fee_and_dust_all_covered(payout_session_factory):
bet = 1_000_000_000 - estimate_vsize(MAX_TX_INPUTS, 2)
await _seed_paying_out_round(payout_session_factory, bets=[bet] * MAX_PARTICIPANTS_PER_ROUND)
scheduler, listener = _scheduler(payout_session_factory)
await scheduler._trigger_payout(1)
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
assert len(tx.vin) == MAX_PARTICIPANTS_PER_ROUND and len(tx.vout) == 3
assert tx.vout[2].value == payout_bump_reserve(MAX_PARTICIPANTS_PER_ROUND)
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
assert sum(o.value for o in tx.vout) + estimate_vsize(MAX_PARTICIPANTS_PER_ROUND, 3) == bet * MAX_PARTICIPANTS_PER_ROUND
# --- the quorum's verdict itself (ELC-27) -------------------------------------------------------------------------------
_ENDPOINTS = [
ElectrumEndpoint("first.example", 50002, True),
ElectrumEndpoint("second.example", 50002, True),
ElectrumEndpoint("third.example", 50002, True),
ElectrumEndpoint("fourth.example", 50002, True),
]
_OUTPOINT = ("aa" * 32, 0, 1_000)
class _Server:
def __init__(self, entries):
self._entries = entries
async def connect(self):
if self._entries is None:
raise ConnectionRefusedError("down")
async def listunspent(self, scripthash):
return self._entries
async def close(self):
pass
def _listener(answers: dict[str, object], session_factory=None):
servers = {host: _Server(entries) for host, entries in answers.items()}
return ElectrumListener(lambda endpoint: servers[endpoint.host], session_factory, _ENDPOINTS)
def _listed(value=1_000, height=50):
return [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": height, "value": value}]
async def test_a_strict_majority_decides_each_outpoint():
# first.example is the active endpoint (never asked); second, third, fourth vote
present = _listener({"second.example": _listed(), "third.example": _listed(), "fourth.example": []})
assert await present.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): True}
absent = _listener({"second.example": _listed(), "third.example": [], "fourth.example": []})
assert await absent.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
async def test_another_amount_counts_as_absent_and_a_mempool_coin_as_neither():
other_amount = _listener({h: _listed(value=999) for h in ("second.example", "third.example", "fourth.example")})
assert await other_amount.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
unconfirmed = _listener({h: _listed(height=0) for h in ("second.example", "third.example", "fourth.example")})
assert await unconfirmed.verify_pool_outpoints("sh", [_OUTPOINT]) is None # may be mined again: no verdict
async def test_too_few_answers_or_a_tie_conclude_nothing():
assert MIN_QUORUM_RESPONSES == 2
one_answer = _listener({"second.example": [], "third.example": None, "fourth.example": None})
assert await one_answer.verify_pool_outpoints("sh", [_OUTPOINT]) is None
tie = _listener({"second.example": _listed(), "third.example": [], "fourth.example": None})
assert await tie.verify_pool_outpoints("sh", [_OUTPOINT]) is None
# nothing at all: the active server is never a substitute for the quorum
nobody = _listener({h: None for h in ("second.example", "third.example", "fourth.example")})
assert await nobody.verify_pool_outpoints("sh", [_OUTPOINT]) is None
async def test_the_active_server_is_not_asked_and_cannot_vouch_for_a_bet():
class _Hostile(_Server):
async def listunspent(self, scripthash):
raise AssertionError("the active server must not be asked")
servers = {"first.example": _Hostile(_listed()), "second.example": _Server([]), "third.example": _Server([]), "fourth.example": _Server([])}
listener = ElectrumListener(lambda endpoint: servers[endpoint.host], None, _ENDPOINTS)
listener._endpoint_index = 0
listener.client = object()
assert await listener.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
+52
View File
@@ -279,3 +279,55 @@ def test_dust_sized_recipient_amount_is_refused():
change_address=change_address,
fee_rate_sat_vb=1,
)
# --- B-98 (BET-24): selection leaves a change output whenever the coins allow it ---
def _built(utxos, amount, reduce=False):
signer = _key(7)
from_script = script.p2wpkh(signer.to_public())
return build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=utxos,
to_address=script.p2wpkh(_key(8).to_public()).address(network=PLM_MAINNET),
amount_sats=amount,
change_address=from_script.address(network=PLM_MAINNET),
fee_rate_sat_vb=1,
reduce_amount_to_keep_change=reduce,
)
def test_a_bet_takes_a_second_coin_when_the_first_covers_it_exactly():
"""BET-24: 10 + 1 PLM and a 10 PLM bet — both coins, 1 PLM change, no refusal."""
utxos = [Utxo("aa" * 32, 0, 1_000_000_000), Utxo("bb" * 32, 0, 100_000_000)]
built = _built(utxos, 1_000_000_000)
assert built.change_sats == 100_000_000
assert len(Transaction.parse(bytes.fromhex(built.raw_hex)).vin) == 2
def test_a_withdrawal_uses_the_second_coin_instead_of_being_reduced():
"""BET-24 / WDR-09: the reduction only happens when no combination leaves change."""
utxos = [Utxo("aa" * 32, 0, 1_000_000_000), Utxo("bb" * 32, 0, 100_000_000)]
built = _built(utxos, 1_000_000_000, reduce=True)
assert built.change_sats == 100_000_000
assert built.recipient_sats == 1_000_000_000 - built.fee_sats
def test_with_no_coin_to_spare_the_old_outcome_stands():
"""BET-24: a lone coin equal to the bet is still refused / the withdrawal still reduced."""
from app.wallet.psbt_builder import DUST_LIMIT_SATS
utxos = [Utxo("aa" * 32, 0, 1_000_000_000)]
with pytest.raises(InsufficientFundsError) as excinfo:
_built(utxos, 1_000_000_000)
assert excinfo.value.code == "balance_leaves_no_change"
assert _built(utxos, 1_000_000_000, reduce=True).change_sats == DUST_LIMIT_SATS
def test_the_change_target_never_pushes_past_the_input_cap():
"""BET-24 / BET-08: capped at MAX_TX_INPUTS, a total that covers the amount is kept."""
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
selected, total = select_utxos(utxos, 100_000 * MAX_TX_INPUTS, min_change_sats=294)
assert len(selected) == MAX_TX_INPUTS and total == 100_000 * MAX_TX_INPUTS
+284
View File
@@ -0,0 +1,284 @@
"""B-87 (TX-11, TX-24, AUD-40, AUD-09): a bump never leaves a dust-sized output, and every bump
that cannot be made is visible from /admin.
`bump_fee` only required `change > fee delta`. A change at the 294 sat minimum (a full-balance
withdrawal) shrank to ~153 sat after the very first minimum bump, a replacement with a
sub-dust output is refused by relay policy, and the bumper retried it every 30 seconds for
ever with nothing but a log line to show for it.
"""
import json
from datetime import datetime, timedelta, timezone
import pytest
from embit import script
from embit.finalizer import finalize_psbt
from embit.psbt import PSBT
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from sqlalchemy import select
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User
from app.tx.broadcast import RbfBumper, RbfError, bump_fee
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import (
DUST_LIMIT_SATS,
MAX_FEE_RATE_SAT_VB,
RBF_SEQUENCE,
Utxo,
build_signed_transaction,
estimate_vsize,
)
from tests.unit.fake_network import FakeNetwork, rpc_error
from tests.unit.test_broadcast import FakeClient, _key, _seed_payout, session_factory # noqa: F401 (fixture)
_AMOUNT = 10_000_000
_DELTA = estimate_vsize(1, 2) # the first minimum bump of a one-input, two-output tx at 1 sat/vB: 141 sat
def _stale() -> datetime:
return datetime.now(timezone.utc) - timedelta(seconds=5000)
async def _pending_bet(session_factory, *, change: int, fee_rate: int = 1, username: str = "alice"):
"""A real bet whose change output is exactly `change` sats, in `pending`, due for a bump."""
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
address = derive_user_address(0)
utxo_txid = "11" * 32
built = build_signed_transaction(
signing_key=signer,
from_script=script.p2wpkh(signer.to_public()),
utxos=[Utxo(utxo_txid, 0, _AMOUNT + change)],
to_address=script.p2wpkh(_key(99).to_public()).address(network=PLM_MAINNET),
amount_sats=_AMOUNT,
change_address=address,
fee_rate_sat_vb=fee_rate,
)
assert built.change_sats == change
async with session_factory() as session:
user = User(username=username, password_hash="x", derivation_index=0, address=address)
session.add(user)
await session.commit()
row = PendingTransaction(
kind="bet", user_id=user.id, current_txid=built.txid, fee_rate_sat_vb=fee_rate,
raw_tx_hex=built.raw_hex, status="pending", broadcast_at=_stale(), last_broadcast_at=_stale(),
)
session.add(row)
await session.commit()
return row.id, built, FakeClient({utxo_txid: _AMOUNT + change})
def _outputs(raw_hex: str) -> list[int]:
return [o.value for o in Transaction.parse(bytes.fromhex(raw_hex)).vout]
# --- the change that would fall under dust is dropped into the fee (TX-11) ------------------------------
async def test_a_minimum_change_is_dropped_into_the_fee_by_the_first_bump(session_factory):
"""The case of the finding: a 294 sat change. The replacement carries one output (the
recipient's, untouched), pays the whole change as fee, and is the last one possible."""
pending_id, built, client = await _pending_bet(session_factory, change=DUST_LIMIT_SATS)
new_txid = await bump_fee(session_factory, client, pending_id)
assert new_txid is not None and new_txid != built.txid
old, new = _outputs(built.raw_hex), _outputs(client.broadcasted[0])
assert len(old) == 2 and len(new) == 1
assert new[0] == old[0] # the recipient is never touched
assert sum(old) - sum(new) == DUST_LIMIT_SATS # the whole change went to the fee
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
assert row.current_txid == new_txid and row.status == "pending"
assert row.fee_rate_sat_vb == (built.fee_sats + DUST_LIMIT_SATS) // estimate_vsize(1, 1)
# nothing is left to reduce: any further bump is impossible, with the reason to show for it
async with session_factory() as session:
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
await session.commit()
with pytest.raises(RbfError) as refused:
await bump_fee(session_factory, client, pending_id)
assert refused.value.reason == "change_too_small" and len(client.broadcasted) == 1
@pytest.mark.parametrize(
"change, kept",
[
pytest.param(DUST_LIMIT_SATS + _DELTA, True, id="leaves-exactly-dust-limit-and-is-kept"),
pytest.param(DUST_LIMIT_SATS + _DELTA - 1, False, id="one-sat-under-and-is-dropped"),
pytest.param(DUST_LIMIT_SATS + _DELTA + 1_000, True, id="plenty-and-is-kept"),
],
)
async def test_the_dust_boundary_of_what_a_bump_leaves(session_factory, change, kept):
pending_id, built, client = await _pending_bet(session_factory, change=change)
await bump_fee(session_factory, client, pending_id)
new = _outputs(client.broadcasted[0])
if kept:
assert len(new) == 2 and new[1] == change - _DELTA >= DUST_LIMIT_SATS
else:
assert len(new) == 1
assert sum(_outputs(built.raw_hex)) - sum(new) == change # all of it, not just the delta
async def test_a_change_that_cannot_even_pay_the_minimum_increase_cannot_be_bumped(session_factory):
"""Giving the whole output to the fee must still reach BIP125's minimum increase."""
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
address = derive_user_address(0)
legacy = Transaction(
vin=[TransactionInput(bytes.fromhex("22" * 32), 0, sequence=RBF_SEQUENCE)],
vout=[
TransactionOutput(9_999_000, script.Script.from_address(script.p2wpkh(_key(97).to_public()).address(network=PLM_MAINNET))),
TransactionOutput(_DELTA - 1, script.Script.from_address(address)),
],
)
async with session_factory() as session:
user = User(username="bob", password_hash="x", derivation_index=0, address=address)
session.add(user)
await session.commit()
row = PendingTransaction(
kind="bet", user_id=user.id, current_txid=legacy.txid().hex(), fee_rate_sat_vb=1,
raw_tx_hex=legacy.serialize().hex(), status="pending", broadcast_at=_stale(), last_broadcast_at=_stale(),
)
session.add(row)
await session.commit()
pending_id = row.id
client = FakeClient({"22" * 32: 10_000_000})
with pytest.raises(RbfError):
await bump_fee(session_factory, client, pending_id)
assert client.broadcasted == []
async def test_a_payouts_bump_reserve_is_dropped_the_same_way(session_factory):
"""TX-11 names the reserve too: when a bump would shrink it under dust it is dropped, and
the winner's and the fee address' outputs are still byte-for-byte what they were."""
built, pending_id, prevouts = await _seed_payout(session_factory, n=1, fee_rate=1)
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
row.fee_rate_sat_vb = 9 # the next target (10 sat/vB) leaves 172 sat of a 1 720 sat reserve
await session.commit()
client = FakeClient(prevouts)
await bump_fee(session_factory, client, pending_id)
old_tx = Transaction.parse(bytes.fromhex(built.raw_hex))
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
assert len(old_tx.vout) == 3 and len(new_tx.vout) == 2
assert new_tx.vout[0].serialize() == old_tx.vout[0].serialize()
assert new_tx.vout[1].serialize() == old_tx.vout[1].serialize()
assert sum(o.value for o in old_tx.vout) - sum(o.value for o in new_tx.vout) == old_tx.vout[2].value
# --- every refused bump is visible (TX-24, AUD-40, AUD-09) ------------------------------------------------------
def _bumper(session_factory, client, network=None):
return RbfBumper(session_factory, lambda: client, network=network or FakeNetwork())
async def _failures(session_factory) -> list[dict]:
async with session_factory() as session:
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "rbf_bump_failed"))).all()
return [dict(json.loads(r.payload_json), user_id=r.user_id, round_id=r.round_id) for r in rows]
async def test_a_change_too_small_bump_writes_one_rbf_bump_failed_row_per_txid_and_reason(session_factory):
pending_id, built, client = await _pending_bet(session_factory, change=DUST_LIMIT_SATS)
bumper = _bumper(session_factory, client)
await bumper._tick(client) # succeeds: the change is dropped
assert await _failures(session_factory) == []
async with session_factory() as session:
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
await session.commit()
for _ in range(3): # three 30 second rounds with the same impossible bump
await bumper._tick(client)
async with session_factory() as session:
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
await session.commit()
failures = await _failures(session_factory)
assert len(failures) == 1 # once, not once per tick
row = failures[0]
assert row["reason"] == "change_too_small" and row["kind"] == "bet"
assert row["pending_transaction_id"] == pending_id and row["user_id"] is not None and row["round_id"] is None
async with session_factory() as session:
pending = await session.get(PendingTransaction, pending_id)
assert row["txid"] == pending.current_txid != built.txid # the current txid, i.e. the one that cannot be bumped
assert pending.status == "pending" # the row itself is untouched (TX-24)
assert len(row["message"]) <= 200
async def test_the_maximum_fee_rate_is_reported(session_factory):
pending_id, built, client = await _pending_bet(session_factory, change=1_000_000, fee_rate=MAX_FEE_RATE_SAT_VB)
await _bumper(session_factory, client)._tick(client)
failures = await _failures(session_factory)
assert [f["reason"] for f in failures] == ["max_fee_rate"] and failures[0]["txid"] == built.txid
assert client.broadcasted == []
async def test_a_replacement_the_network_refuses_is_reported_and_leaves_the_row_alone(session_factory):
pending_id, built, client = await _pending_bet(session_factory, change=1_000_000)
async def refuse(raw):
raise rpc_error("min relay fee not met")
client.broadcast = refuse
bumper = _bumper(session_factory, client, FakeNetwork(known=False))
await bumper._tick(client)
failures = await _failures(session_factory)
assert [f["reason"] for f in failures] == ["replacement_rejected"]
assert "min relay fee not met" in failures[0]["message"]
async with session_factory() as session:
assert (await session.get(PendingTransaction, pending_id)).current_txid == built.txid
async def test_a_new_txid_or_a_new_reason_writes_a_new_row(session_factory):
"""AUD-09: the pair (current txid, reason) is what makes two failures the same one."""
pending_id, built, client = await _pending_bet(session_factory, change=1_000_000)
bumper = _bumper(session_factory, client, FakeNetwork(known=False))
async def refuse(raw):
raise rpc_error("min relay fee not met")
real_broadcast = client.broadcast
client.broadcast = refuse
for _ in range(2):
await bumper._tick(client)
async with session_factory() as session:
(await session.get(PendingTransaction, pending_id)).last_broadcast_at = _stale()
await session.commit()
assert [f["reason"] for f in await _failures(session_factory)] == ["replacement_rejected"] # same pair: once
client.broadcast = real_broadcast # the bump now succeeds ...
await bumper._tick(client)
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
assert row.current_txid != built.txid
row.fee_rate_sat_vb = MAX_FEE_RATE_SAT_VB # ... and the next one hits the ceiling: new txid, new reason
row.last_broadcast_at = _stale()
await session.commit()
await bumper._tick(client)
failures = await _failures(session_factory)
assert [f["reason"] for f in failures] == ["replacement_rejected", "max_fee_rate"]
assert failures[0]["txid"] == built.txid and failures[1]["txid"] != built.txid
async def test_a_payout_failure_carries_its_round(session_factory):
built, pending_id, prevouts = await _seed_payout(session_factory, n=1)
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
row.fee_rate_sat_vb = MAX_FEE_RATE_SAT_VB
row.last_broadcast_at = _stale()
await session.commit()
client = FakeClient(prevouts)
await _bumper(session_factory, client)._tick(client)
failures = await _failures(session_factory)
assert len(failures) == 1 and failures[0]["kind"] == "payout" and failures[0]["round_id"] == 1
assert failures[0]["user_id"] is None
+133
View File
@@ -0,0 +1,133 @@
"""B-90 (REG-15, OPS-27): a restored backup must not hand out derivation indexes that already
have history, and START_PAUSED starts the lottery paused."""
import json
import pytest
from sqlalchemy import select
from app.db.models import AuditLog, RoundConfig, User
from app.electrum.listener import ElectrumListener
from app.electrum.scripthash import address_to_scripthash
from app.wallet.hd import derive_user_address
from tests.unit.test_electrum_listener import _ENDPOINTS, session_factory # noqa: F401 (fixture)
class _Server:
"""Answers get_history: the given scripthashes have a transaction, the rest none."""
def __init__(self, used=(), fail=False):
self.used = set(used)
self.fail = fail
async def connect(self):
if self.fail:
raise ConnectionRefusedError("down")
async def request(self, method, params=None):
assert method == "blockchain.scripthash.get_history"
return [{"tx_hash": "aa" * 32, "height": 5}] if params[0] in self.used else []
async def close(self):
pass
def _listener(session_factory, active, others):
servers = {"second.example": others[0], "third.example": others[1]}
listener = ElectrumListener(lambda endpoint: servers[endpoint.host], session_factory, _ENDPOINTS)
listener.client = active
listener.registration_open = False
return listener
async def _users(session_factory, count):
async with session_factory() as session:
for i in range(count):
session.add(User(username=f"u{i}", password_hash="x", derivation_index=i, address=derive_user_address(i)))
await session.commit()
def _hash(index):
return address_to_scripthash(derive_user_address(index))
async def _audit(session_factory):
async with session_factory() as session:
return list((await session.execute(select(AuditLog).where(AuditLog.event_type == "restore_gap_detected"))).scalars())
async def test_registration_opens_when_the_next_20_indexes_are_unused(session_factory):
await _users(session_factory, 3)
listener = _listener(session_factory, _Server(), [_Server(), _Server()])
await listener._check_restore_gap()
assert listener.registration_open and await _audit(session_factory) == []
async def test_one_server_reporting_history_is_enough_to_keep_it_closed(session_factory):
"""REG-15: the checked window is highest + 1 … highest + 20; any server's word counts."""
await _users(session_factory, 3) # highest assigned index 2 -> checks 3..22
listener = _listener(session_factory, _Server(), [_Server(used={_hash(22)}), _Server()])
await listener._check_restore_gap()
assert not listener.registration_open
(row,) = await _audit(session_factory)
payload = json.loads(row.payload_json)
assert payload["highest_assigned_index"] == 2
assert payload["used"] == [{"derivation_index": 22, "address": derive_user_address(22)}]
async def test_an_index_just_past_the_window_is_not_looked_at(session_factory):
await _users(session_factory, 3)
listener = _listener(session_factory, _Server(used={_hash(23)}), [_Server(), _Server()])
await listener._check_restore_gap()
assert listener.registration_open
async def test_an_empty_database_checks_indexes_0_to_19(session_factory):
listener = _listener(session_factory, _Server(used={_hash(0)}), [_Server(), _Server()])
await listener._check_restore_gap()
(row,) = await _audit(session_factory)
assert json.loads(row.payload_json)["highest_assigned_index"] is None
async def test_without_the_active_servers_answer_nothing_is_decided(session_factory):
"""The check is simply tried again on the next session; registration stays closed."""
listener = _listener(session_factory, None, [_Server(), _Server()])
with pytest.raises(RuntimeError):
await listener._check_restore_gap()
assert not listener.registration_open and not listener._restore_checked
async def test_an_unreachable_quorum_server_does_not_block_a_clean_result(session_factory):
listener = _listener(session_factory, _Server(), [_Server(fail=True), _Server()])
await listener._check_restore_gap()
assert listener.registration_open
async def test_register_is_refused_while_the_check_has_not_passed(session_factory, monkeypatch):
from httpx import ASGITransport, AsyncClient
from app.main import app
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
monkeypatch.setattr(ElectrumListener, "registration_open", False)
app.state.electrum_listener = listener
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as client:
response = await client.post("/auth/register", json={"username": "alice", "password": "password123"})
assert response.status_code == 503
assert response.json()["detail"]["code"] == "registration_unavailable"
async def test_start_paused_pauses_as_the_operator_without_overwriting_an_automatic_reason(session_factory):
from app.rounds.pause import pause_automatically, pause_by_operator
async with session_factory() as session:
await pause_by_operator(session, "START_PAUSED")
await session.commit()
config = (await session.execute(select(RoundConfig))).scalar_one()
assert config.paused and config.paused_reason == "operator"
await pause_automatically(session, "reorg")
await pause_by_operator(session, "START_PAUSED")
await session.commit()
assert config.paused_reason == "reorg" # an automatic reason is the one that needs a look
events = (await session.execute(select(AuditLog).where(AuditLog.event_type == "lottery_paused"))).scalars().all()
assert [json.loads(e.payload_json)["reason"] for e in events] == ["operator", "reorg", "operator"]
+5 -1
View File
@@ -183,7 +183,11 @@ async def _payout(payout_session_factory, pool_address: str, round_id: int = 1):
client = _RecordingPayoutClient(
[{"tx_hash": "44" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS}]
)
await RoundScheduler(payout_session_factory, FakePayoutListener(client))._trigger_payout(round_id)
listener = FakePayoutListener(client)
await RoundScheduler(payout_session_factory, listener)._trigger_payout(round_id)
# B-82: a round with its own address asks the quorum (the listener), not the active
# connection, which is only asked for a round paid from the shared reserve address.
client.scripthashes = client.scripthashes + listener.verified_scripthashes
return client
+249
View File
@@ -0,0 +1,249 @@
"""B-95 (RND-05, RND-06, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-31): a round
runs by the parameters it opened with.
Only the duration and the cooldown were copied onto the round when it opened; the cost of a bet
and the fee address were read live, so an edit from /admin in the middle of a round gave the
players of that round different stakes for the same chance, and redirected the commission of
the round in progress. The cap on the pool is new.
"""
import sqlite3
from pathlib import Path
import pytest
from embit.transaction import Transaction
from sqlalchemy import select
from app.bets.service import BetError, place_bet
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User
from app.rounds.scheduler import RoundScheduler
from app.rounds.service import open_new_round_if_needed
from app.wallet.plm_network import PLM_MAINNET
from tests.unit.test_bets import FakeElectrumClient, _make_funded_user, session_factory # noqa: F401 (fixture)
from tests.unit.test_migrations import _alembic, _index_sql
from tests.unit.test_scheduler import FakePayoutClient, FakePayoutListener, _FEE_ADDRESS as _SEEDED_FEE_ADDRESS
_OTHER_FEE_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
async def _edit_config(session_factory, **values) -> None:
async with session_factory() as session:
config = (await session.scalars(select(RoundConfig))).one()
for name, value in values.items():
setattr(config, name, value)
await session.commit()
async def _open_round(session_factory) -> Round:
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
return round_
# --- the copy (RND-05, DAT-61) ---------------------------------------------------------------------------------------
async def test_a_round_copies_cost_fee_address_and_cap_when_it_opens(session_factory):
await _edit_config(session_factory, bet_amount_sats=700_000_000, max_round_pool_sats=5_000_000_000)
round_ = await _open_round(session_factory)
assert (round_.bet_amount_sats, round_.fee_address, round_.max_round_pool_sats) == (
700_000_000,
_SEEDED_FEE_ADDRESS,
5_000_000_000,
)
assert round_.draw_confirmations == 3 and round_.duration_seconds == 600 # what was already copied
async def test_an_edit_from_admin_never_reaches_the_round_in_progress_but_the_next_one(session_factory):
first = await _open_round(session_factory)
await _edit_config(
session_factory, bet_amount_sats=2_000_000_000, fee_address=_OTHER_FEE_ADDRESS, max_round_pool_sats=9_000_000_000
)
async with session_factory() as session:
again = await session.get(Round, first.id)
assert (again.bet_amount_sats, again.fee_address, again.max_round_pool_sats) == (
1_000_000_000,
_SEEDED_FEE_ADDRESS,
0,
)
again.status = "closed"
again.closed_at = None # no cooldown to wait for
await session.commit()
second = await _open_round(session_factory)
assert (second.bet_amount_sats, second.fee_address, second.max_round_pool_sats) == (
2_000_000_000,
_OTHER_FEE_ADDRESS,
9_000_000_000,
)
# --- the cost (BET-21, DAT-26) -----------------------------------------------------------------------------------------
@pytest.mark.parametrize("edited", [3_000_000_000, 500_000_000], ids=["raised", "lowered"])
async def test_every_bet_of_a_round_pays_the_roundd_cost_whatever_is_edited_meanwhile(session_factory, edited):
first = await _make_funded_user(session_factory, 0, 5_000_000_000)
second = await _make_funded_user(session_factory, 1, 5_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
await place_bet(session, client, await session.get(User, first))
await _edit_config(session_factory, bet_amount_sats=edited)
async with session_factory() as session:
await place_bet(session, client, await session.get(User, second))
amounts = []
for raw in client.broadcasted:
tx = Transaction.parse(bytes.fromhex(raw))
amounts.append(tx.vout[0].value)
# both pay the same stake (cost 10 PLM minus the same network fee), not the edited one
assert amounts[0] == amounts[1]
assert 1_000_000_000 - 10_000 < amounts[0] <= 1_000_000_000
async with session_factory() as session:
stakes = (await session.scalars(select(RoundParticipant.bet_amount_sats))).all()
assert len(set(stakes)) == 1
# --- the commission goes where the round said (RND-06) ---------------------------------------------------------------
async def test_the_payout_pays_the_fee_address_the_round_opened_with(payout_session_factory):
from app.wallet.hd import derive_pool_address
from tests.unit.test_scheduler import _seed_paying_out_round
edited_to = derive_pool_address(4242) # a valid address that is neither the winner's nor the seeded one
await _seed_paying_out_round(payout_session_factory)
async with payout_session_factory() as session:
(await session.scalars(select(RoundConfig))).one().fee_address = edited_to # edited mid-round
await session.commit()
client = FakePayoutClient([])
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._trigger_payout(1)
tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
assert tx.vout[1].script_pubkey.address(network=PLM_MAINNET) == _SEEDED_FEE_ADDRESS
assert edited_to not in {o.script_pubkey.address(network=PLM_MAINNET) for o in tx.vout}
async def test_clearing_the_fee_address_mid_round_does_not_stop_its_payout(payout_session_factory):
from tests.unit.test_scheduler import _seed_paying_out_round
await _seed_paying_out_round(payout_session_factory)
async with payout_session_factory() as session:
(await session.scalars(select(RoundConfig))).one().fee_address = "" # the operator slip of B-66
await session.commit()
client = FakePayoutClient([])
await RoundScheduler(payout_session_factory, FakePayoutListener(client))._trigger_payout(1)
assert client.broadcasted and not [
e for e in (await _events(payout_session_factory)) if e == "payout_failed"
]
async def _events(session_factory) -> list[str]:
async with session_factory() as session:
return list((await session.scalars(select(AuditLog.event_type))).all())
# the fixture of the payout tests (the in-memory database with a master key)
from tests.unit.test_scheduler import payout_session_factory # noqa: E402,F401
# --- the cap on the pool (BET-20, DAT-27) ------------------------------------------------------------------------------------
async def _bet(session_factory, user_id: int, client=None):
async with session_factory() as session:
return await place_bet(session, client or FakeElectrumClient(), await session.get(User, user_id))
async def test_a_bet_is_refused_once_the_pool_has_reached_the_cap_and_only_then(session_factory):
# one bet nets ~ 999.9k sat: a cap of 1.5 bets lets exactly two bets in (the second starts under it)
await _edit_config(session_factory, max_round_pool_sats=1_500_000_000)
users = [await _make_funded_user(session_factory, i, 2_000_000_000) for i in range(3)]
await _bet(session_factory, users[0]) # pool 0 < cap
await _bet(session_factory, users[1]) # pool ~1.0e9 < 1.5e9: accepted, takes the pool past the cap
with pytest.raises(BetError) as refused:
await _bet(session_factory, users[2]) # pool ~2.0e9 >= cap
assert refused.value.code == "round_full" and refused.value.params["max_round_pool_sats"] == 1_500_000_000
async with session_factory() as session: # nothing moved for the refused player
assert (await session.scalars(select(PendingTransaction.user_id))).all().count(users[2]) == 0
assert len((await session.scalars(select(RoundParticipant))).all()) == 2
from app.db.models import UtxoEvent
refused_coins = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == users[2]))).all()
assert refused_coins and all(coin.spent_txid is None for coin in refused_coins)
async def test_a_pool_exactly_at_the_cap_has_reached_it(session_factory):
""""Reached" is `>=`: a pool that equals the cap is full."""
first = await _make_funded_user(session_factory, 0, 2_000_000_000)
second = await _make_funded_user(session_factory, 1, 2_000_000_000)
await _bet(session_factory, first)
async with session_factory() as session:
net = (await session.scalars(select(RoundParticipant.bet_amount_sats))).one()
(await session.scalars(select(Round))).one().max_round_pool_sats = net # the round's own cap, set to the pool
await session.commit()
with pytest.raises(BetError) as refused:
await _bet(session_factory, second)
assert refused.value.code == "round_full"
async def test_the_cap_counts_bets_still_in_flight(session_factory):
await _edit_config(session_factory, max_round_pool_sats=900_000_000)
first = await _make_funded_user(session_factory, 0, 2_000_000_000)
second = await _make_funded_user(session_factory, 1, 2_000_000_000)
await _bet(session_factory, first) # `broadcast`, not confirmed
async with session_factory() as session:
assert (await session.scalars(select(RoundParticipant.status))).all() == ["broadcast"]
with pytest.raises(BetError) as refused:
await _bet(session_factory, second)
assert refused.value.code == "round_full"
async def test_without_a_cap_nothing_limits_the_pool_but_the_participants(session_factory):
users = [await _make_funded_user(session_factory, i, 2_000_000_000) for i in range(4)]
for user_id in users:
await _bet(session_factory, user_id)
async with session_factory() as session:
assert len((await session.scalars(select(RoundParticipant))).all()) == 4
async def test_raising_or_lowering_the_cap_mid_round_does_not_change_that_round(session_factory):
await _edit_config(session_factory, max_round_pool_sats=1_500_000_000)
users = [await _make_funded_user(session_factory, i, 2_000_000_000) for i in range(4)]
await _bet(session_factory, users[0])
await _edit_config(session_factory, max_round_pool_sats=100_000_000_000) # raised: this round keeps 1.5e9
await _bet(session_factory, users[1])
with pytest.raises(BetError):
await _bet(session_factory, users[2])
await _edit_config(session_factory, max_round_pool_sats=1) # lowered below everything: no effect either
async with session_factory() as session:
assert (await session.scalars(select(Round.max_round_pool_sats))).all() == [1_500_000_000]
# --- the existing rounds at the migration (DAT-55) -------------------------------------------------------------------------
def test_the_migration_fills_existing_rounds_with_the_current_configuration(tmp_path):
db_path = tmp_path / "old.db"
_alembic(db_path, "upgrade", "f2c64a8d1e93")
with sqlite3.connect(db_path) as conn:
conn.execute(
"INSERT INTO round_config (fee_address, bet_amount_sats, round_duration_seconds, round_cooldown_seconds, "
"fee_rate_sat_vb, rbf_timeout_seconds, draw_animation_seconds, paused, deposit_confirmations, "
"draw_confirmations, updated_at) "
f"VALUES ('{_OTHER_FEE_ADDRESS}', 750000000, 600, 30, 1, 900, 20, 0, 6, 3, '2026-01-01 00:00:00')"
)
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closed', '2026-01-01 00:00:00')")
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('paying_out', '2026-01-02 00:00:00')")
_alembic(db_path, "upgrade", "head")
with sqlite3.connect(db_path) as conn:
rows = conn.execute("SELECT bet_amount_sats, fee_address, max_round_pool_sats FROM rounds").fetchall()
cap = conn.execute("SELECT max_round_pool_sats FROM round_config").fetchone()[0]
assert rows == [(750_000_000, _OTHER_FEE_ADDRESS, 0)] * 2 and cap == 0
assert _index_sql(Path(db_path), "ix_rounds_single_active") is not None
assert _index_sql(Path(db_path), "ix_users_username_lower") is not None
+24
View File
@@ -316,3 +316,27 @@ async def test_lottery_configured_is_false_when_the_amounts_could_not_pay_a_roun
(await session.scalars(select(RoundConfig))).one().bet_amount_sats = 1_000_000_000
await session.commit()
assert (await ac.get("/rounds/current")).json()["lottery_configured"] is True
async def test_current_round_shows_the_round_cost_while_active_and_the_configured_one_otherwise(client): # B-95 (API-21)
from datetime import datetime, timezone
from sqlalchemy import select
from app.db.models import Round, RoundConfig
ac, session_factory = client
async with session_factory() as session:
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
session.add(Round(id=70, status="open", opened_at=datetime.now(timezone.utc), bet_amount_sats=1_000_000_000))
await session.commit()
async with session_factory() as session:
(await session.scalars(select(RoundConfig))).one().bet_amount_sats = 4_000_000_000 # edited mid-round
await session.commit()
assert (await ac.get("/rounds/current")).json()["bet_amount_sats"] == 1_000_000_000 # the round's own
async with session_factory() as session:
(await session.get(Round, 70)).status = "closed"
await session.commit()
assert (await ac.get("/rounds/current")).json()["bet_amount_sats"] == 4_000_000_000 # what the next one will cost
+36 -14
View File
@@ -120,7 +120,15 @@ async def payout_session_factory(tmp_path, monkeypatch):
hd._account_key = None
async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int:
def _payout_bet_txid(index: int) -> str:
return f"{index + 0x33:064x}"
async def _seed_paying_out_round(session_factory, round_id: int = 1, bets: list[int] | None = None) -> int:
"""A round in `paying_out` whose confirmed participants — the first one is the winner —
each placed a bet of the given net amount (B-82: the payout spends exactly those bet
outputs, `bet_txid:0`, so the pool is their sum)."""
bets = bets if bets is not None else [_POOL_AMOUNT_SATS]
async with session_factory() as session:
winner = User(username="winner", password_hash="x", derivation_index=0, address=_WINNER_ADDRESS)
session.add(winner)
@@ -130,11 +138,28 @@ async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int:
Round(
id=round_id,
status="paying_out",
pool_amount_sats=_POOL_AMOUNT_SATS,
pool_amount_sats=sum(bets),
fee_address=_FEE_ADDRESS, # B-95: the round's own, copied at open
winner_user_id=winner.id,
pool_address=derive_pool_address(round_id), # B-80
)
)
await session.flush()
for index, amount in enumerate(bets):
user = winner
if index:
user = User(
username=f"bettor{index}", password_hash="x", derivation_index=index,
address=derive_pool_address(7000 + index),
)
session.add(user)
await session.flush()
session.add(
RoundParticipant(
round_id=round_id, user_id=user.id, bet_amount_sats=amount, bet_txid=_payout_bet_txid(index),
bet_vout=0, status="confirmed", draw_position=index,
)
)
await session.commit()
return winner.id
@@ -206,18 +231,14 @@ async def test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_in
so a round past MAX_TX_INPUTS participants could not be paid at all — the build
failed with too_many_inputs, the round stayed "paying_out" retrying every 60s,
and no new round could ever open behind it. It must now broadcast normally."""
await _seed_paying_out_round(payout_session_factory)
participants = MAX_TX_INPUTS + 1
bet_sats = _POOL_AMOUNT_SATS // participants
entries = [
{"tx_hash": f"{i:064x}", "tx_pos": 0, "height": 10, "value": bet_sats}
for i in range(participants)
]
# The pool's total must cover the round's recorded pool_amount_sats, exactly as
# on-chain: integer division above leaves a remainder, so top the last one up.
entries[-1]["value"] += _POOL_AMOUNT_SATS - bet_sats * participants
client = FakePayoutClient(entries)
bets = [bet_sats] * participants
# The pool's total is the round's recorded pool_amount_sats: integer division above
# leaves a remainder, so top the last one up.
bets[-1] += _POOL_AMOUNT_SATS - bet_sats * participants
await _seed_paying_out_round(payout_session_factory, bets=bets)
client = FakePayoutClient([])
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._trigger_payout(1)
@@ -347,8 +368,9 @@ async def test_trigger_payout_logs_a_failure_when_not_connected(payout_session_f
async def test_trigger_payout_logs_a_failure_when_fee_address_missing(payout_session_factory):
winner_id = await _seed_paying_out_round(payout_session_factory)
async with payout_session_factory() as session:
config = (await session.scalars(select(RoundConfig))).one()
config.fee_address = ""
# B-95 (RND-06): the payout reads the round's own address, so only an empty one on the
# round (a database edited by hand — the API never lets it be emptied) blocks it.
(await session.get(Round, 1)).fee_address = ""
await session.commit()
entries = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
+5 -1
View File
@@ -671,7 +671,11 @@ def _payout_scheduler(session_factory, client):
from app.rounds.scheduler import RoundScheduler
from tests.unit.test_scheduler import FakePayoutListener
return RoundScheduler(session_factory, FakePayoutListener(client))
listener = FakePayoutListener(client)
# B-82: the network phase of a payout is the quorum's verification of the round's coins,
# so that is where a test injects "the lock is lost while the network is being used".
listener.on_verify = getattr(client, "_hook_listunspent", None)
return RoundScheduler(session_factory, listener)
class _PayoutClient:
+257
View File
@@ -0,0 +1,257 @@
"""B-96: the smaller state inconsistencies — WDR-15, BET-07 (pending-covered bet), TX-16, TX-18,
TX-27, TX-29/AUD-46, DEP-15/DEP-16 and BET-02/WDR-01."""
import asyncio
import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from sqlalchemy import select
from app.bets.service import BetError, place_bet
from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
from app.rounds.events import broadcaster
from app.rounds.scheduler import CLOSING_STALL_THRESHOLD_SECONDS, RoundScheduler
from app.tx.reconcile import reconcile_once
from app.withdrawals.service import WithdrawalError, request_withdrawal
from tests.unit.fake_network import FakeNetwork
from tests.unit.test_reconcile import KnownTxClient, _seed_bet
from tests.unit.test_reconcile import session_factory as reconcile_session_factory # noqa: F401 (fixture)
from tests.unit.test_scheduler import FakeListener, _seed_closing_round
from tests.unit.test_scheduler import session_factory as scheduler_session_factory # noqa: F401 (fixture)
from tests.unit.test_withdrawals import (
BET_AMOUNT_SATS,
EXTERNAL_ADDRESS,
FakeElectrumClient,
_make_funded_user,
)
from tests.unit.test_withdrawals import session_factory as wallet_session_factory # noqa: F401 (fixture)
# --- WDR-15 and the pending-covered bet ---------------------------------------------
async def test_a_reduced_full_balance_withdrawal_is_checked_against_the_minimum(wallet_session_factory):
"""WDR-15: moving the minimum from a one-coin balance leaves a dust limit behind, which puts
the amount that actually goes out under the minimum."""
session_factory = wallet_session_factory
user_id = await _make_funded_user(session_factory, 50, BET_AMOUNT_SATS)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError) as excinfo:
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS)
assert excinfo.value.code == "amount_below_minimum"
assert excinfo.value.params == {"minimum_sats": BET_AMOUNT_SATS}
assert client.broadcasted == []
async def test_a_bet_covered_only_by_unconfirmed_change_says_wait_not_insufficient(wallet_session_factory):
"""BET-07 / DEP-13: the balance on screen covers the bet, the confirmed one does not."""
session_factory = wallet_session_factory
user_id = await _make_funded_user(session_factory, 51, 3_000_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
await request_withdrawal(session, FakeElectrumClient(), user, EXTERNAL_ADDRESS, 1_200_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
assert user.cached_balance_sats == 0
with pytest.raises(BetError) as excinfo:
await place_bet(session, FakeElectrumClient(), user)
assert excinfo.value.code == "balance_pending_confirmation"
assert excinfo.value.params["pending_sats"] > 0
async def test_a_bet_with_no_money_at_all_is_still_insufficient(wallet_session_factory):
session_factory = wallet_session_factory
user_id = await _make_funded_user(session_factory, 52, 100_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError) as excinfo:
await place_bet(session, FakeElectrumClient(), user)
assert excinfo.value.code == "insufficient_balance"
# --- TX-16 / TX-18 / TX-27 -----------------------------------------------------------
async def _events(session_factory):
async with session_factory() as session:
return [e.event_type for e in (await session.scalars(select(AuditLog).order_by(AuditLog.id))).all()]
async def test_a_recovered_bet_gets_the_event_its_send_would_have_written(reconcile_session_factory):
"""TX-16 / AUD-30: pending_tx_recovered alone left a recovered bet out of the audit trail."""
session_factory = reconcile_session_factory
await _seed_bet(session_factory, pending_status="building", participant_status="building", age_seconds=300)
await reconcile_once(session_factory, KnownTxClient("betxid"))
assert sorted(await _events(session_factory)) == ["bet_placed", "pending_tx_recovered"]
async with session_factory() as session:
placed = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "bet_placed"))).one()
assert json.loads(placed.payload_json) == {"txid": "betxid", "amount_sats": 999_000_000}
assert placed.user_id is not None and placed.round_id == 1
async def test_a_recovered_withdrawal_and_payout_get_theirs_too(reconcile_session_factory):
from app.tx.reconcile import _promote
session_factory = reconcile_session_factory
async with session_factory() as session:
withdrawal = Withdrawal(
user_id=1, external_address=EXTERNAL_ADDRESS, amount_requested_sats=5, amount_sent_sats=4, txid="w", status="building"
)
round_ = Round(status="paying_out", winner_user_id=7, winner_amount_sats=700, fee_amount_sats=300)
session.add_all([withdrawal, round_])
await session.flush()
w_row = PendingTransaction(kind="withdrawal", withdrawal_id=withdrawal.id, user_id=1, current_txid="w", fee_rate_sat_vb=1, raw_tx_hex="", status="building")
p_row = PendingTransaction(kind="payout", round_id=round_.id, current_txid="p", fee_rate_sat_vb=1, raw_tx_hex="", status="building")
session.add_all([w_row, p_row])
await session.flush()
await _promote(session, w_row)
await _promote(session, p_row)
await session.commit()
rows = {e.event_type: e for e in (await session.scalars(select(AuditLog))).all()}
assert json.loads(rows["withdrawal_sent"].payload_json) == {
"txid": "w", "amount_sent_sats": 4, "external_address": EXTERNAL_ADDRESS,
}
assert json.loads(rows["payout_sent"].payload_json) == {"txid": "p", "winner_sats": 700, "commission_sats": 300}
assert rows["payout_sent"].user_id == 7
async def test_an_abandoned_payout_leaves_no_amounts_behind(reconcile_session_factory):
"""TX-18 / DAT-22."""
from app.tx.reconcile import _abandon
session_factory = reconcile_session_factory
async with session_factory() as session:
round_ = Round(
status="paying_out", payout_txid="p", winner_amount_sats=700, fee_amount_sats=300, bump_reserve_sats=50
)
session.add(round_)
await session.flush()
row = PendingTransaction(kind="payout", round_id=round_.id, current_txid="p", fee_rate_sat_vb=1, raw_tx_hex="", status="pending")
session.add(row)
await session.flush()
await _abandon(session, row, "no server knows it")
await session.commit()
await session.refresh(round_)
assert (round_.payout_txid, round_.winner_amount_sats, round_.fee_amount_sats, round_.bump_reserve_sats) == (
None, None, None, None,
)
async def test_the_reconciler_only_pings_the_dashboards_for_rows_it_changed(reconcile_session_factory, monkeypatch):
"""TX-27: a row found present and already `pending` is examined, not changed."""
session_factory = reconcile_session_factory
await _seed_bet(session_factory, pending_status="pending", participant_status="broadcast", age_seconds=30 * 3600)
pings = []
monkeypatch.setattr(broadcaster, "publish", lambda: pings.append(1))
assert await reconcile_once(session_factory, KnownTxClient("betxid")) == 0
assert pings == []
# --- TX-29 / AUD-46 ------------------------------------------------------------------
async def _stalled_rows(session_factory):
async with session_factory() as session:
return list(
(await session.scalars(select(AuditLog).where(AuditLog.event_type == "closing_stalled").order_by(AuditLog.id))).all()
)
async def test_closing_stalled_is_written_past_the_threshold_and_repeats_every_30_minutes(scheduler_session_factory):
session_factory = scheduler_session_factory
round_id = await _seed_closing_round(session_factory, n=2)
async with session_factory() as session:
for participant in (await session.scalars(select(RoundParticipant))).all():
participant.status = "broadcast"
await session.commit()
scheduler = RoundScheduler(session_factory, FakeListener())
now = datetime.now(timezone.utc)
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS - 60))
assert await _stalled_rows(session_factory) == [] # not long enough yet
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 60))
(row,) = await _stalled_rows(session_factory)
payload = json.loads(row.payload_json)
assert payload["in_flight"] == 2 and len(payload["txids"]) == 2 and payload["seconds_past_deadline"] >= 1860
assert row.round_id == round_id and row.user_id is None
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 120))
assert len(await _stalled_rows(session_factory)) == 1 # the last row is the clock: not again yet
async with session_factory() as session:
last = await session.get(AuditLog, row.id)
last.created_at = last.created_at - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 1)
await session.commit()
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=2 * CLOSING_STALL_THRESHOLD_SECONDS))
assert len(await _stalled_rows(session_factory)) == 2
# --- DEP-15 / DEP-16 / BET-02 / WDR-01 ------------------------------------------------
async def test_refreshes_of_one_user_run_one_at_a_time_and_other_users_in_parallel(scheduler_session_factory):
from app.electrum.listener import ElectrumListener
listener = ElectrumListener(lambda endpoint: None, scheduler_session_factory)
running, peak, order = {}, {}, []
async def fake_locked(user_id, scripthash):
running[user_id] = running.get(user_id, 0) + 1
peak[user_id] = max(peak.get(user_id, 0), running[user_id])
order.append((user_id, scripthash))
await asyncio.sleep(0.01)
running[user_id] -= 1
listener._refresh_user_locked = fake_locked
await asyncio.gather(
listener.refresh_user(1, "a"), listener.refresh_user(1, "b"), listener.refresh_user(2, "c")
)
assert peak == {1: 1, 2: 1}
assert [s for u, s in order if u == 1] == ["a", "b"] # in order of arrival
async def test_one_users_failed_refresh_does_not_end_the_session(scheduler_session_factory):
from app.electrum.listener import ElectrumListener
listener = ElectrumListener(lambda endpoint: None, scheduler_session_factory)
listener._scripthash_to_user = {"s1": 1, "s2": 2}
calls = []
async def fake_refresh(user_id, scripthash):
calls.append(user_id)
if user_id == 1:
raise RuntimeError("duplicate txid/vout")
listener.refresh_user = fake_refresh
queue = asyncio.Queue()
for item in (("s1", None), ("s2", None)):
queue.put_nowait(item)
task = asyncio.create_task(listener._consume_scripthash(queue))
await asyncio.sleep(0.05)
assert calls == [1, 2] and not task.done()
task.cancel()
async def test_the_connection_is_read_after_the_user_lock_is_taken():
"""BET-02 / WDR-01: a request queued on the lock across a reconnection must use the
connection that exists when it gets the lock, not the one it saw on arrival."""
from contextlib import asynccontextmanager
from app.api.routes import bets as bets_route
listener = SimpleNamespace(client="old")
@asynccontextmanager
async def acquire(_user_id):
listener.client = None # the connection dropped while this request waited
yield
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(electrum_listener=listener, user_locks=SimpleNamespace(acquire=acquire))))
with pytest.raises(Exception) as excinfo:
await bets_route.create_bet(request, SimpleNamespace(), SimpleNamespace(id=1), None)
assert excinfo.value.detail["code"] == "network_unavailable"
+13
View File
@@ -176,3 +176,16 @@ async def test_me_created_at_is_utc_stamped(client):
assert resp.status_code == 200
created_at = resp.json()["created_at"]
assert created_at.endswith("+00:00") or created_at.endswith("Z")
async def test_me_shows_waiting_deposits_in_the_pending_balance_only(client): # B-82 (DEP-12 (b), DEP-14)
token = await _register(client)
headers = {"Authorization": f"Bearer {token}"}
me = (await client.get("/users/me", headers=headers)).json()
assert (me["balance_sats"], me["pending_balance_sats"], me["has_pending"]) == (0, 0, False)
listener = client._transport.app.state.electrum_listener
listener._pending_deposits[me["id"]] = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 5, "value": 250_000_000}]
shown = (await client.get("/users/me", headers=headers)).json()
assert shown["balance_sats"] == 0 # not spendable
assert shown["pending_balance_sats"] == 250_000_000 and shown["has_pending"] is True