Document the 2026-09-28 specification review and its fixes (B-73..B-97)

Fourth full audit, run against the docs/ specification instead of the code
alone, found logical flaws rooted in PLM's small hashrate (~22 TH/s): a
minable draw, no distinction between "never broadcast" and "unknown if
broadcast", an Electrum client that degrades as the pool address's history
grows, and 1-confirmation deposits on a chain where a reorg is rentable.

The spec now describes the target behaviour for every finding, marked
*Stato:* da implementare (B-nn) with an *Oggi:* line for what the code does
now; docs/piano-correzioni.md records the design decisions and the
implementation order. No code changed — this is spec-only, per B-nn
convention when a bug reveals the spec itself was incomplete.

Also documents, as an accepted-but-open risk (LIM-24), that the optional
jackpot cap (max_round_pool_sats) isn't tied to the cost of a 51% attack —
deferred, not decided.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5 committed 2026-09-28 15:51:13 +02:00
1 parent 61efbb0e37
commit 88221b0dbd
15 files changed
+4524 -1172

No files matched your search

+333 -1
View File
@@ -1,4 +1,9 @@
# BUGS.md — audit of 2026-08-03
# 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-97, 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
@@ -65,3 +70,330 @@ only supported way to run the server (`docs/running-the-server.md` and README we
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.
---
# Audit of 2026-09-28 — specification review (B-73 … B-97)
Fourth audit, run against the new specification in `docs/` rather than the code
alone: four independent reviews (coin/chain mechanics, money state machine,
DevOps, spec cross-validation), with the critical claims re-verified in code.
The target behaviour of every entry below is now written into `docs/` as
requirements marked `*Stato:* da implementare (B-nn)`; the Italian overview, the
design decisions and the implementation order are in
[docs/piano-correzioni.md](docs/piano-correzioni.md). The code has **not** been
changed yet. Same convention as above: one commit per entry, with its own
regression test, spec + code + test together.
Design decisions taken with the owner on 2026-09-28 (they change existing
decisions, see `docs/decisioni-e-limiti.md`):
- **Draw by commit-reveal.** The winner must not be computable by anyone who
controls block production, since PLM's hashrate (~22 TH/s) is rentable.
- **Never release funds on doubt.** A money-moving row is rolled back only on proof
(an explicit rejection, or its inputs confirmed-spent elsewhere), never on a
timeout or on one server's silence.
- **One pool address per round**, so a round's money, history and payout are
isolated from every other round's.
- **Deposits need 6 confirmations** (configurable), everything else keeps 1: the
deposit is the only input a user can double-spend.
- **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-73 — The draw can be steered by whoever produces or relays blocks
The seed is the block hash alone, so a miner holding tickets can withhold blocks
until one makes them win, and the active Electrum server can pick which real block
is used (it only has to skip announcing one; corroboration checks that the block
exists, not that it is the first after closing). The participant order uses the
server-only `broadcast_at` with no tie-break, so the draw is not reproducible.
Fix (commit-reveal): at round open generate a 32-byte `server_seed`, store it,
publish only `server_seed_hash = sha256(server_seed)`. On entering `drawing`, fix
the participant list ordered by confirmed bet txid (ascending), publish
`participants_hash = sha256` of the `txid:vout:address` lines, and fix
`draw_height = tip_at_close + 1` (persisted, never moved). Wait until the chain has
`draw_confirmations` (round_config, default 3, snapshotted at open) blocks from
`draw_height`, fetch the header *at `draw_height`* from every server, require the
quorum to agree (on disagreement wait and re-ask for the same height). Seed =
`sha256(server_seed || block_hash || participants_hash)` as an integer, index = seed
mod n. Reveal `server_seed` once the winner is persisted, and expose the full proof
at `GET /rounds/{id}/proof`.
### B-74 — Headers are accepted without a minimum-difficulty check, and a jump skips the linkage check
`header_meets_its_own_target` never compares `bits` against the chain's powLimit, so
a header with `bits=0x207fffff` passes with no work. A multi-block jump is not
linked to the current tip. A fake header at a huge height locks `tip_height` (it
never regresses) and freezes every draw. Conversely an ordinary 1-block reorg kills
the session on every server until the next block.
Fix: reject `bits` easier than powLimit; on any advance of more than one block fetch
the intermediate headers (`blockchain.block.headers`) and validate the chain; on a
linkage mismatch walk back up to 10 headers to find the fork point and follow the
reorg instead of ending the session (reorg handling: B-85).
### B-75 — Replies over 64 KiB kill the connection; the pool's history grows past that
`asyncio.open_connection` uses the default 64 KiB `StreamReader` limit; a longer
reply line raises `ValueError` in `_read_loop` and closes the connection. The
confirmation poller and the reconciler fetch the pool address's full history every
cycle, which passes 64 KiB after ~800 transactions: from then on every 10s poll
drops the only connection and payouts never confirm. A few hundred dust outputs sent
to any public address trigger the same thing on purpose.
Fix: 16 MiB reader limit; an oversized or malformed reply is a request error, not a
dead connection; UTXOs below `DUST_LIMIT_SATS` are ignored for crediting and
selection; per-round pool addresses (B-80) keep every address's history bounded.
### B-76 — An ambiguous broadcast is rolled back as if refused
`place_bet`/`request_withdrawal` release everything on *any* broadcast exception,
including the 15s timeout and a drop after the write. The tx may have propagated: the
bet's sats land in the pool with no participant, the withdrawal leaves while its row
says `failed`.
Fix: only an explicit JSON-RPC rejection releases immediately (an "already known /
already in mempool" rejection is success). A timeout or disconnect leaves the rows
`building` for the reconciler and the API answers 202 with the row in `building`.
After the active server accepts, the raw tx is also sent to every fallback
(best effort).
### B-77 — Only the latest txid is tracked, and `bump_fee` broadcasts before it persists
The poller and the reconciler match `current_txid` only (`replaced_by_txid` keeps
one step and nothing reads it). If an earlier version of a bumped tx confirms, it is
never seen; after 6h the row is abandoned — a confirmed bet's participant deleted, a
sent withdrawal marked failed, a paid payout rebuilt. `bump_fee` broadcasts in phase
2 and persists in phase 3, unconditionally, so a crash or a `database is locked` in
between, or an abandon that ran meanwhile, desynchronises DB and chain.
Fix: a `pending_transaction_txids` table holding every txid (and raw tx) a row ever
had; a confirmation of *any* of them confirms the row and retargets every reference
to it. The replacement is written (as a candidate) before it is broadcast; phase 3
updates with `WHERE status='pending' AND current_txid=<old>` and treats zero rows as
a conflict to reconcile.
### B-78 — Abandonment rests on one server's silence
`reconcile_once` abandons when the active server's `get_history` lacks the txid —
after 120s for `building` rows, which a restart onto a different server makes
likely, and all at once after a forward clock jump. It re-checks status but not the
txid, so it races the bumper. A `failed` row is never looked at again.
Fix: abandon only on proof — (a) an input confirmed-spent by a different txid
(quorum), or (b) no server of the quorum knows any txid of the row *and* a
rebroadcast of the latest raw tx to all servers is explicitly refused for
missing/spent inputs. A fee-related refusal is not proof (bump instead). The re-check
includes `current_txid`. `failed` rows stay watched for 14 days; a late confirmation
writes `pending_tx_resurrected` and pauses the lottery for the operator.
### B-79 — A payout normally has no change output, so it cannot be fee-bumped
The pool normally holds exactly the round's bet outputs, the payout spends them all
for exactly `pool_amount_sats`, so there is no change and `bump_fee` can never help.
A payout stuck in a mempool is also never abandoned (the server still knows it): the
round stays in `paying_out` and no new round opens.
Fix: every payout carries a **bump reserve** output of
`max(DUST_LIMIT_SATS, estimated_vsize × 10 sat/vB)`, taken from the commission share
and paid to the pool reserve address (`m/84'/746'/0'/1/0`, the old pool address);
RBF shrinks that output. `rounds_can_open` refuses a configuration in which a
one-participant round could not cover winner fee + reserve + dust commission.
### B-80 — One pool address for every round
All bets of all rounds pay one address: its history grows forever (B-75), stray
money and other rounds' money mix with this round's, and a restored backup replays a
round against whatever the pool holds now.
Fix: each round gets its own pool address `m/84'/746'/0'/2/<round_id>`, stored on
the round (`rounds.pool_address`); bets pay it, the payout spends only its UTXOs.
Rounds opened before this change keep the old address. Withdrawals to any pool,
reserve or fee address are refused.
### B-81 — `drawing` wedges on any exception, not only on restart
The participant snapshot and `tip_at_close` live only in memory; `_tick` skips
`drawing`. Any exception during the wait (`database is locked` writing an audit row,
a malformed header) leaves the round in `drawing` forever.
Fix: persist `draw_height`, `participants_hash` and each participant's
`draw_position` when entering `drawing`; `_tick` re-enters the wait for a round in
`drawing`, idempotently. Together with B-73's fixed height and committed seed, a
resumed draw always yields the same winner.
### 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-83 — Confirmations and reinstatement trust the active server alone
`poll_once` marks a tx confirmed on one server's `height > 0`, and
`reinstate_reappeared_utxos` restores a UTXO on its word, so a hostile active server
can report a never-relayed payout as confirmed (winner unpaid) or a phantom bet.
Broadcasts go to the active server only.
Fix: a confirmation is accepted once the quorum returns a valid merkle proof
(`blockchain.transaction.get_merkle`) against a header we hold; reinstatement needs
the same quorum as a credit (B-59); broadcasts fan out (B-76).
### B-84 — The quorum can be one server, or none, silently
An empty `ELECTRUM_FALLBACK_SERVERS` (the `.env.example` default) disables every
corroboration; with fallbacks, the majority is over whoever answers, so one reachable
fallback decides. TLS certificates are not verified.
Fix: at least 2 fallbacks and at least 2 responders for every corroboration, or the
server refuses to start (override `ALLOW_NO_QUORUM=true`, development only); optional
certificate pinning per endpoint (`host:port:pin=<sha256>`).
### B-85 — Reorgs are neither followed nor surfaced
Nothing detects a reorg; a 1-block reorg is treated as a hostile server (B-74).
Fix: the listener keeps the last 10 headers; a fork is followed to its fork point, a
`chain_reorg_detected` audit row records depth and heights, and a depth ≥ 2 pauses
the lottery (`paused_reason = 'reorg'`) until the operator resumes it.
### B-86 — A connected but stalled server is never replaced
`ping` keeps succeeding while the server stops following the chain; nothing compares
tip progress with the other servers, and a restart goes back to the primary.
Fix: if the tip has not advanced for 360s and any other server reports a higher tip,
rotate. `GET /health/ready` and `/admin` expose connection state, endpoint and
last-header age (B-92).
### 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-88 — SQLite transactions are not what the code assumes
pysqlite's legacy mode issues `BEGIN` only at the first DML, so reads before it are
not isolated, the "write-snapshot conflict" handled in `place_bet` cannot happen, and
lock waits surface as `database is locked` after 5s in arbitrary places.
`synchronous=NORMAL` can lose the last commits on power loss (e.g. a registration,
reusing its derivation index).
Fix: `BEGIN IMMEDIATE` for every money-moving transaction, `synchronous=FULL`,
explicit handling of busy timeouts after a broadcast.
### 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
A deploy with an open SSE stream never shuts down gracefully (uvicorn waits, Docker
kills after 10s); an older image cannot start on a newer DB; images are untagged;
`SITE_ADDRESS` passed per command reverts to `localhost` on the next plain
`docker compose up`.
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`.
Fix: each as described, one regression test per item.
### B-97 — Migrations can silently drop the expression indexes
`batch_alter_table` on SQLite rebuilds the table and drops `ix_rounds_single_active`
and `ix_users_username_lower` with only a warning; autogenerate ignores them; no test
runs the migrations.
Fix: a test that runs `alembic upgrade head` on an empty DB and asserts both indexes
exist; any batch migration on those tables recreates them explicitly.
+1 -1
View File
@@ -12,7 +12,7 @@ All 10 stages of the original build order are code-complete and unit-tested —
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.
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) lives in the tracked [BUGS.md](BUGS.md), which lists only what is still **open** (B-71, B-72 at the time of writing); 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.
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-97: 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-97 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.
**[docs/](docs/) is the specification, and it is authoritative.** Every verifiable rule there has a stable requirement ID (`RND-04`, `BET-02`, …) with `*Codice:*`/`*Test:*` pointers to what implements it; the index is [docs/come-funziona.md](docs/come-funziona.md) and the ID convention and change workflow are in [docs/processo-di-modifica.md](docs/processo-di-modifica.md). A behaviour change starts in the spec: edit or add the requirement, implement it, cite the ID in the test docstring and the commit message, and ship spec + code + test in one commit. If this file and the spec disagree, the spec wins and this file is the one to fix. Before writing code, read the relevant spec documents and the "Architecture" section below.
+344 -106
View File
@@ -34,6 +34,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `POST` | `/withdrawals` | Bearer | [API-24](#post-withdrawals) |
| `GET` | `/rounds/current` | facoltativa | [API-25](#get-roundscurrent) |
| `GET` | `/rounds/stream` | nessuna | [API-26](#get-roundsstream) |
| `GET` | `/rounds/{round_id}/proof` | nessuna | [API-45](#get-roundsround_idproof) — *da implementare (B-73)* |
| `GET` | `/qr/{address}` | nessuna | [API-27](#get-qraddress) |
| `POST` | `/bug-reports` | facoltativa | [API-28](#post-bug-reports) |
| `GET` | `/bug-reports/mine` | Bearer | [API-29](#get-bug-reportsmine) |
@@ -49,7 +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` | `/health` | nessuna | [API-41](#get-health) |
| `GET` | `/health/ready` | nessuna | [API-46](#get-healthready) — *da implementare (B-92)* |
| `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) |
@@ -108,11 +111,15 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
illeggibile, scaduto o con firma errata, un utente non più esistente e un
token *superato* (emesso prima dell'ultimo cambio o reset della password)
rispondono tutti `401` con codice `session_expired`. L'**assenza** dell'header
(o uno schema diverso da `Bearer`) è invece intercettata da FastAPI prima del
codice applicativo: `401` con il corpo non strutturato
`{"detail": "Not authenticated"}` e header `WWW-Authenticate: Bearer`.
(o uno schema diverso da `Bearer`) risponde `401` con codice
`not_authenticated`, nel formato strutturato di [API-12], con header
`WWW-Authenticate: Bearer`. Il frontend tratta i due codici allo stesso
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 è
@@ -125,11 +132,13 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
- **[API-08]** Autenticazione **admin**: l'header `X-Admin-Token` è confrontato
a tempo costante (sui byte UTF-8, così un valore non ASCII non provoca un
`500`) con `ADMIN_TOKEN` del `.env`. Un `ADMIN_TOKEN` vuoto nega **tutto**,
anche un header vuoto. Il rifiuto è `403` con il corpo non strutturato
`{"detail": "invalid admin token"}`. Non c'è identità per singolo operatore
anche un header vuoto. Il rifiuto è `403` con codice `invalid_admin_token`,
nel formato strutturato di [API-12]. Non c'è identità per singolo operatore
(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
@@ -137,13 +146,19 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
- il flusso SSE risponde con `Cache-Control: no-store` e
`X-Accel-Buffering: no`;
- il QR risponde con `Cache-Control: private, max-age=86400, immutable`;
- le risposte JSON e i file statici serviti dal mount (`/app.js`,
`/style.css`, `/i18n.js`, `/index.html`…) **non** hanno `Cache-Control`:
i file statici portano solo `ETag`/`Last-Modified` per la validazione
condizionale.
- ogni file servito dal mount dei file statici (`/app.js`, `/style.css`,
`/i18n.js`, `/admin.js`, `/admin.css`, `/index.html`…) risponde con
`Cache-Control: no-cache`, oltre a `ETag`/`Last-Modified`: il browser può
tenerne una copia ma la rivalida a ogni uso, così dopo un aggiornamento
del server non esegue un `app.js` vecchio contro l'API nuova;
- le risposte JSON non hanno `Cache-Control`.
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`,
`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/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.
- **[API-10]** La documentazione automatica di FastAPI (`/docs`, `/redoc`,
`/openapi.json`) è **disattivata** a meno che `ENABLE_API_DOCS` non sia
@@ -154,7 +169,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), l'IP è l'**ultimo**
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**
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).
@@ -191,9 +207,20 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
- **[API-13]** Gli errori di `POST /bets` e `POST /withdrawals` sollevati dal
servizio rispondono `400`, **tranne** `broadcast_failed`, che risponde `502`:
è la rete ad aver rifiutato la transazione, non il chiamante.
`broadcast_failed` si usa **solo** per un rifiuto esplicito del server
(un errore JSON-RPC in risposta al broadcast), e significa che tutto è stato
annullato. Un esito **ambiguo** del broadcast (timeout, connessione caduta
dopo l'invio) non è un errore: la richiesta risponde `202 Accepted` con lo
stesso corpo del successo e `status: "building"`, perché la transazione può
essere già sulla rete e la riga resta al riconciliatore
([scommesse-e-prelievi.md](scommesse-e-prelievi.md) B-76). Un rifiuto
"già noto / già nel mempool" vale come successo (`201`).
*Codice:* `app/api/routes/bets.py:create_bet`,
`app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_bets.py`
*Stato:* da implementare (B-76) · *Oggi:* qualunque eccezione del broadcast,
compresi il timeout di 15 s e la caduta della connessione, annulla tutto e
risponde `502 broadcast_failed`.
- **[API-14]** Qualunque eccezione non gestita risponde `500` con lo stesso
formato strutturato, codice `internal_error`, messaggio
@@ -204,36 +231,47 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*Test:* `tests/unit/test_rounds_route.py`
- **[API-15]** Un corpo, un parametro di percorso o di query che non supera la
validazione risponde `422` nel formato **nativo** di FastAPI: `detail` è una
**lista** di oggetti `{type, loc, msg, input, ctx?}`, in inglese, uno per
campo invalido (anche il JSON malformato arriva così, con
`type: "json_invalid"`). Non c'è un `code`: il frontend riconosce la lista e
mostra il messaggio generico tradotto `error.invalid_request`.
*Codice:* FastAPI (nessun gestore personalizzato),
validazione risponde `422` nel formato strutturato di [API-12], con codice
`invalid_request`, `message` `"invalid request"` e in `params.errors` la
lista degli errori di validazione, uno per campo invalido, ciascuno
`{"loc", "msg", "type"}` in inglese (anche il JSON malformato arriva così,
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),
`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`.
- **[API-16]** Alcune risposte d'errore **non** seguono il formato strutturato
e hanno `detail` come semplice stringa inglese:
- **[API-16]** **Nessuna** risposta d'errore ha `detail` come semplice
stringa: anche gli errori generati dagli endpoint admin, dal flusso SSE e dal
framework seguono il formato strutturato di [API-12], con questi codici:
| Stato | `detail` | Dove |
|---|---|---|
| `401` | `"Not authenticated"` | header `Authorization` assente su un endpoint Bearer (FastAPI) |
| `403` | `"invalid admin token"` | ogni endpoint `/admin/*` |
| `404` | `"user not found"` | `GET /admin/users/{id}/privkey`, `POST /admin/users/{id}/reset-password` |
| `404` | `"bug report not found"` | `POST /admin/bug-reports/{id}/status` |
| `404` | `"Not Found"` | percorso inesistente (anche un metodo `GET` su un percorso che esiste solo in `POST`, perché lo intercetta il mount dei file statici) |
| `405` | `"Method Not Allowed"` | metodo diverso da `GET`/`HEAD` su un percorso non definito da un router |
| `503` | `"too many concurrent update streams"` | `GET /rounds/stream` oltre il tetto globale |
| Stato | Codice atteso | Dove | Oggi (`detail` stringa) |
|---|---|---|---|
| `401` | `not_authenticated` | header `Authorization` assente su un endpoint Bearer ([API-06]) | `"Not authenticated"` |
| `403` | `invalid_admin_token` | ogni endpoint `/admin/*` ([API-08]) | `"invalid admin token"` |
| `404` | `user_not_found` | `GET /admin/users/{id}/privkey`, `POST /admin/users/{id}/reset-password` | `"user not found"` |
| `404` | `bug_report_not_found` | `POST /admin/bug-reports/{id}/status` | `"bug report not found"` |
| `404` | `not_found` | percorso inesistente (anche un metodo `GET` su un percorso che esiste solo in `POST`, perché lo intercetta il mount dei file statici) | `"Not Found"` |
| `405` | `method_not_allowed` | metodo diverso da `GET`/`HEAD` su un percorso non definito da un router | `"Method Not Allowed"` |
| `503` | `too_many_streams` | `GET /rounds/stream` oltre il tetto globale | `"too many concurrent update streams"` |
Il frontend mostra la stringa così com'è.
I `404`/`405` del framework si ottengono con un gestore di
`StarletteHTTPException` che traduce lo stato nel codice della tabella.
*Codice:* `app/api/routes/admin.py:require_admin`,
`app/api/routes/admin.py:user_privkey`,
`app/api/routes/admin.py:reset_user_password`,
`app/api/routes/admin.py:update_bug_report_status`,
`app/api/routes/rounds.py:round_stream` ·
`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
@@ -267,27 +305,37 @@ 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).
assegnare un indice di derivazione libero); `503 registration_suspended`
(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*).
- **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
errore di validazione non la consumano). Dettagli in
[sicurezza.md](sicurezza.md).
- **Effetti:** inserisce l'utente con il nome così come digitato, il
prossimo indice di derivazione e l'indirizzo derivato; avvia (senza
attenderla, *best effort*) la sottoscrizione Electrum dell'indirizzo. Non
scrive audit e non notifica l'SSE. Il flusso completo è in
prossimo indice di derivazione e l'indirizzo derivato, e scrive
`user_registered` con indice e indirizzo nella stessa transazione; avvia
(senza attenderla, *best effort*) la sottoscrizione Electrum
dell'indirizzo. Non notifica l'SSE. Il flusso completo è in
[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
- **[API-19]** Apre una sessione.
- **Autenticazione:** nessuna.
- **Corpo:** `{"username": stringa, "password": stringa}`, senza vincoli di
lunghezza. Lo username è confrontato senza distinzione tra maiuscole e
minuscole.
- **Corpo:** `{"username": stringa, "password": stringa}`, ciascuno di al
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` —
@@ -332,20 +380,29 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
- **[API-21]** Cambio password self-service.
- **Autenticazione:** Bearer.
- **Corpo:** `{"current_password": stringa, "new_password": stringa}`.
- **Corpo:** `{"current_password": stringa, "new_password": stringa}`,
ciascuno di al massimo 256 caratteri.
- **Risposta `200`:** `{"access_token": <JWT>}` — un **nuovo** token per la
sessione corrente.
- **Errori**, nell'ordine in cui sono verificati: `401` di autenticazione;
`401 current_password_incorrect`; `400 password_too_short`
(`params.minimum` = 8). La lunghezza minima è verificata nel gestore (quindi
`400`, non `422` come alla registrazione) e **non** c'è una lunghezza
massima.
`422 invalid_request` (un campo oltre 256 caratteri); `429 rate_limited`
(`params.retry_after_seconds`); `401 current_password_incorrect`;
`400 password_too_short` (`params.minimum` = 8). La lunghezza minima è
verificata nel gestore (quindi `400`, non `422` come alla registrazione).
- **Limite di frequenza:** un contatore di fallimenti di
`current_password` per utente, con attesa esponenziale come quello del
login; il blocco è verificato prima dell'hash Argon2. Soglie in
[sicurezza.md](sicurezza.md) (B-94).
- **Effetti:** sostituisce l'hash e incrementa `User.token_version`: ogni
token emesso prima, compreso quello usato per questa richiesta, da quel
momento riceve `401 session_expired`. Non scrive audit e non notifica
l'SSE. Nessun limite di frequenza.
*Codice:* `app/api/routes/users.py:change_password` ·
momento riceve `401 session_expired`. Scrive `password_changed` nella
stessa transazione. Non notifica l'SSE.
*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
@@ -376,34 +433,46 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
[scommesse-e-prelievi.md](scommesse-e-prelievi.md).
- **Autenticazione:** Bearer.
- **Corpo:** nessuno.
- **Risposta `201`:**
- **Risposta `201`** (il server attivo ha accettato la transazione) o
**`202`** (esito del broadcast ambiguo, [API-13](#contratto-degli-errori)):
| Campo | Tipo | Significato |
|---|---|---|
| `round_id` | intero | round a cui la scommessa è iscritta |
| `bet_txid` | stringa | txid della transazione trasmessa |
| `bet_amount_sats` | intero | importo arrivato al montepremi, cioè il costo **meno** la commissione di rete |
| `status` | stringa | stato del partecipante, `"broadcast"` |
| `bet_txid` | stringa | txid della transazione (trasmessa con `201`, forse trasmessa con `202`) |
| `bet_amount_sats` | intero | importo che arriva al montepremi, cioè il costo del round **meno** la commissione di rete |
| `status` | stringa | stato del partecipante: `"broadcast"` con `201`, `"building"` con `202` |
- **Errori:** `503 network_unavailable` (nessuna connessione Electrum,
verificato **prima** di ogni altro controllo); poi, tutti `400` salvo
indicazione: `no_round_open`, `round_closing`, `already_betting`,
`round_full` (`params.max_participants`), `insufficient_balance`
(`params.required_sats`), `too_many_inputs` (`params.max_inputs`),
`balance_leaves_no_change` (`params.required_extra_sats`),
`amount_below_network_fee`, `amount_below_dust_limit`,
`502 broadcast_failed`. Il significato di ciascuno è nel
`round_full` (`params.max_participants`; anche quando il round ha
raggiunto `max_round_pool_sats`, B-95), `balance_pending_confirmation`
(`params.pending_sats`; il costo sarebbe coperto dal saldo comprensivo del
resto non ancora confermato, ma non da quello confermato, B-96),
`insufficient_balance` (`params.required_sats`), `too_many_inputs`
(`params.max_inputs`), `balance_leaves_no_change`
(`params.required_extra_sats`), `amount_below_network_fee`,
`amount_below_dust_limit`, `502 broadcast_failed` (solo rifiuto
esplicito). Il significato di ciascuno è nel
[catalogo](#catalogo-dei-codici-di-errore).
- **Serializzazione:** la richiesta tiene il blocco per-utente condiviso con
`POST /withdrawals` per tutta la costruzione e la trasmissione.
- **Effetti:** può **aprire un round** se nessuno è attivo e le condizioni
lo permettono; segna le monete spese, crea partecipante e transazione in
sospeso, trasmette. Audit `bet_placed` in caso di successo,
`bet_broadcast_failed` se la rete rifiuta; notifica SSE in entrambi i casi
(e all'apertura del round).
lo permettono (lo stesso controllo dello scheduler, fatto dentro la
richiesta); segna le monete spese, crea partecipante e transazione in
sospeso, trasmette al server attivo e poi, *best effort*, a tutti i
server di riserva. Audit `bet_placed` in caso di successo,
`bet_broadcast_failed` se la rete rifiuta; notifica SSE in tutti e tre i
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-76, B-95, B-96) · *Oggi:* ogni esito diverso
dal successo è `502 broadcast_failed` con tutto annullato; la trasmissione
va solo al server attivo; 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.
#### POST /withdrawals
@@ -417,31 +486,43 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `external_address` | stringa | verificato nel servizio (bech32 valido con prefisso `plm1`) |
| `amount_sats` | intero | nessun vincolo di schema; il minimo è verificato nel servizio |
- **Risposta `201`:**
- **Risposta `201`** (il server attivo ha accettato la transazione) o
**`202`** (esito del broadcast ambiguo, [API-13](#contratto-degli-errori)):
| Campo | Tipo | Significato |
|---|---|---|
| `txid` | stringa | txid della transazione trasmessa |
| `txid` | stringa | txid della transazione (trasmessa con `201`, forse trasmessa con `202`) |
| `amount_requested_sats` | intero | importo chiesto |
| `amount_sent_sats` | intero | importo che arriva al destinatario: richiesto meno commissione, e meno quanto serve a lasciare un resto minimo se si preleva tutto |
| `status` | stringa | stato del prelievo, `"broadcast"` |
| `status` | stringa | stato del prelievo: `"broadcast"` con `201`, `"building"` con `202` |
- **Errori:** `422`; `503 network_unavailable` (verificato **prima** della
validazione dell'indirizzo); poi `400`: `invalid_address`,
`withdrawal_to_own_address`, `amount_below_minimum`
(`params.minimum_sats` = costo attuale della scommessa),
`balance_pending_confirmation` (`params.pending_sats`),
`insufficient_balance` (`params.required_sats`), `too_many_inputs`
(`params.max_inputs`), `amount_below_network_fee`,
`amount_below_dust_limit`; `502 broadcast_failed`.
validazione dell'indirizzo, quindi un indirizzo invalido riceve `503`
finché la rete non torna); poi `400`: `invalid_address`,
`withdrawal_to_own_address`, `withdrawal_to_platform_address`
(destinazione uguale a un indirizzo di montepremi, di riserva o delle
commissioni, B-80), `amount_below_minimum` (`params.minimum_sats` = costo
attuale della scommessa; verificato anche sull'importo ridotto di un
prelievo dell'intero saldo, B-96), `balance_pending_confirmation`
(`params.pending_sats`), `insufficient_balance` (`params.required_sats`),
`too_many_inputs` (`params.max_inputs`), `amount_below_network_fee`,
`amount_below_dust_limit`; `502 broadcast_failed` (solo rifiuto
esplicito).
- **Serializzazione:** stesso blocco per-utente di `POST /bets`.
- **Effetti:** segna le monete spese, crea prelievo e transazione in
sospeso, trasmette. Audit `withdrawal_sent`, oppure
`withdrawal_broadcast_failed` con il prelievo lasciato in stato `failed`;
notifica SSE in entrambi i casi.
sospeso, trasmette al server attivo e poi, *best effort*, a tutti i
server di riserva. Audit `withdrawal_sent`, oppure
`withdrawal_broadcast_failed` con il prelievo lasciato in stato `failed`
per un rifiuto esplicito; con un esito ambiguo il prelievo resta
`building`. Notifica SSE in tutti e tre i casi.
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`,
`app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
*Stato:* da implementare (B-76, B-80, B-96) · *Oggi:* ogni esito diverso
dal successo è `502 broadcast_failed` con il prelievo `failed`; la
trasmissione va solo al server attivo; un prelievo verso l'indirizzo del
pool o delle commissioni è accettato; l'importo ridotto di un prelievo
dell'intero saldo non è riconfrontato con il minimo.
### Round
@@ -461,7 +542,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 della scommessa **attualmente configurato** (quello che pagherebbe una nuova scommessa) |
| `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* |
| `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 |
@@ -470,17 +551,27 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `winner_user_id` | intero o `null` | vincitore, valorizzato dall'estrazione in poi (`paying_out`) |
| `winner_amount_sats` | intero o `null` | importo al vincitore, valorizzato solo quando la transazione di pagamento è stata costruita |
| `draw_block_height` | intero o `null` | altezza del blocco usato per l'estrazione |
| `draw_block_hash` | stringa o `null` | hash di quel blocco (il seme dell'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` |
| `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 — *da implementare (B-73)* |
| `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` — *da implementare (B-73)* |
| `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` — *da implementare (B-73)* |
| `draw_confirmations` | intero o `null` | conferme richieste sul blocco di estrazione, fissate all'apertura del round — *da implementare (B-73, B-95)* |
| `pool_address` | stringa o `null` | indirizzo del montepremi del round, verificabile su un esploratore — *da implementare (B-80)* |
| `chain_tip_height` | intero o `null` | ultima altezza nota della catena; `null` finché non è nota |
| `lottery_paused` | booleano | pausa di manutenzione attiva |
| `lottery_paused` | booleano | lotteria in pausa, per qualunque motivo |
| `paused_reason` | stringa o `null` | `operator`, `reorg`, `payout_blocked`, `resurrected_tx`, oppure `null` se non in pausa ([modello-dati.md](modello-dati.md), DAT-69) — *da implementare (B-78, B-82, B-85)* |
| `lottery_configured` | booleano | falso finché manca configurazione indispensabile (oggi: `fee_address`), e quindi nessun round può aprirsi |
| `user_played` | booleano | vero se il chiamante autenticato ha una riga di partecipazione in questo round, in qualunque stato; sempre falso per un anonimo |
Il segreto del round (`server_seed`) **non** compare mai in questa
risposta: si legge solo da [API-45](#get-roundsround_idproof), dopo che il
vincitore è stato scritto.
Senza round attivo sono valorizzati solo `server_time`, `bet_amount_sats`,
`draw_animation_seconds`, `chain_tip_height`, `lottery_paused` e
`lottery_configured`; gli altri valgono `null`, `0` o `false`. Il
significato di dominio di ogni fase è in
`draw_animation_seconds`, `chain_tip_height`, `lottery_paused`,
`paused_reason` e `lottery_configured`; gli altri valgono `null`, `0` o
`false`. Il significato di dominio di ogni fase è in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
- **Errori:** nessuno previsto (un token invalido non è un errore,
[API-07](#convenzioni-generali)).
@@ -491,6 +582,9 @@ 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-73, B-78, B-80, B-82, B-85, B-95), per i campi
marcati · *Oggi:* quei campi non esistono, `bet_amount_sats` è sempre il
costo configurato, e `draw_block_hash` è il seme dell'estrazione.
#### GET /rounds/stream
@@ -509,14 +603,88 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
- **Chiusura:** la connessione di un client scomparso senza chiusura pulita
è rilevata entro 5 secondi. Oltre **5** flussi contemporanei dallo stesso
IP, l'apertura di un nuovo flusso **chiude il più vecchio** di quell'IP.
- **Errori:** `503` con `{"detail": "too many concurrent update streams"}`
(non strutturato) quando i flussi aperti in totale sono già **500**; il
client `EventSource` riprova da solo e intanto resta il polling.
- **Errori:** `503 too_many_streams` (formato strutturato,
[API-16](#contratto-degli-errori)) quando i flussi aperti in totale sono
già **500**; il client `EventSource` riprova da solo e intanto resta il
polling.
- **Chiusura all'arresto del server:** allo spegnimento ogni flusso aperto
viene chiuso dal server, così l'arresto non attende i client
([architettura.md](architettura.md) B-93).
- **Effetti:** nessuno.
- **Cosa non notifica:** `PUT /admin/config`, `POST /admin/pause` e
`POST /admin/resume` non pubblicano sul canale; `/` vede
`lottery_paused`/`paused_reason` e la nuova configurazione al polling
successivo di `GET /rounds/current`.
*Codice:* `app/api/routes/rounds.py:round_stream`,
`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
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.
#### GET /rounds/{round_id}/proof
- **[API-45]** La prova pubblica dell'estrazione di un round: tutto ciò che
serve a chiunque per ricalcolare il vincitore senza fidarsi del server.
L'algoritmo e la codifica esatta dei byte sono definiti in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73); qui è
specificata la forma della risposta.
- **Autenticazione:** nessuna.
- **Parametro di percorso:** `round_id` intero (non intero: `422`).
- **Risposta `200`**, per un round in qualunque stato; i campi non ancora
determinati valgono `null` (o lista vuota):
| Campo | Tipo | Valorizzato da | Significato |
|---|---|---|---|
| `round_id` | intero | sempre | — |
| `status` | stringa | sempre | stato del round |
| `algorithm` | stringa | sempre | `"commit-reveal-v1"` per i round aperti con B-73; `"block-hash-v0"` per quelli precedenti, per cui la prova è incompleta (vedi sotto) |
| `pool_address` | stringa | sempre | indirizzo del montepremi del round |
| `server_seed_hash` | stringa | apertura | `sha256(server_seed)`, in esadecimale |
| `server_seed` | stringa o `null` | **solo dopo** che il vincitore è stato scritto (`paying_out` e `closed`) | il segreto del round, 32 byte in esadecimale |
| `draw_height` | intero o `null` | ingresso in `drawing` | altezza del blocco di estrazione |
| `draw_confirmations` | intero | apertura | conferme richieste su quel blocco prima di leggerne l'hash |
| `participants` | lista | ingresso in `drawing` | elenco in ordine di `draw_position`: `{"draw_position", "txid", "vout", "address", "status"}`, dove `txid:vout` è l'output della scommessa verso `pool_address`, `address` l'indirizzo del partecipante (dove andrebbe la vincita) e `status` `confirmed` o `reorged_out` |
| `participants_hash` | stringa o `null` | ingresso in `drawing` | impronta dell'elenco `participants` |
| `draw_block_hash` | stringa o `null` | estrazione | hash del blocco a `draw_height` |
| `seed` | stringa o `null` | estrazione | il seme in decimale (`rounds.seed_int`) |
| `winner_index` | intero o `null` | estrazione | `seed mod` numero di elementi di `participants` |
| `winner_address` | stringa o `null` | estrazione | `participants[winner_index].address` |
- **Come si verifica** (round `commit-reveal-v1`):
1. prima della chiusura, annotare `server_seed_hash` da `GET /rounds/current`
o da questo endpoint;
2. dopo la rivelazione, controllare che `sha256(server_seed)` sia uguale a
quel valore: il segreto non è stato cambiato dopo l'apertura;
3. ricalcolare `participants_hash` da `participants`, e controllare su un
esploratore o un nodo che ogni `txid:vout` sia un output confermato
verso `pool_address`;
4. controllare su un esploratore o un nodo che il blocco a `draw_height`
abbia hash `draw_block_hash` e che la catena abbia almeno
`draw_confirmations` blocchi da lì;
5. ricalcolare `seed` da `server_seed`, `draw_block_hash` e
`participants_hash`, poi `winner_index = seed mod n` con `n` il numero
di elementi di `participants`, e confrontare `winner_address`.
Per un round `block-hash-v0` la risposta ha `server_seed_hash`,
`server_seed`, `draw_height` e `participants_hash` a `null` e
`participants` vuota, perché quei round non hanno registrato né un
impegno né l'ordine dei partecipanti: `seed` e `draw_block_hash` si
possono confrontare, il vincitore no.
- **Riservatezza:** `server_seed` non compare in nessuna risposta, di
nessun endpoint (admin compresi), finché `rounds.winner_user_id` è `NULL`
([modello-dati.md](modello-dati.md), DAT-64). Gli indirizzi dei
partecipanti sono già pubblici sulla catena: le scommesse li collegano a
`pool_address`.
- **Errori:** `404 round_not_found`; `422`.
- **Effetti:** nessuno.
*Codice:* `app/api/routes/rounds.py` (endpoint da scrivere),
`app/rounds/draw.py:draw_winner` · *Test:* —
*Stato:* da implementare (B-73) · *Oggi:* l'endpoint non esiste; l'unico
dato pubblico dell'estrazione è `draw_block_hash` in `GET /rounds/current`,
finché il round è attivo.
### QR
@@ -553,12 +721,19 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `contact` | stringa o `null` | facoltativo, al massimo 256 caratteri; spazi rimossi; una stringa vuota diventa `null` |
- **Risposta `201`:** `{"id": intero}`.
- **Errori:** `422`.
- **Errori:** `422`; `429 rate_limited` (`params.retry_after_seconds`).
- **Limite di frequenza:** una quota di segnalazioni per IP
([API-11](#convenzioni-generali)) in una finestra mobile, verificata prima
dell'inserimento e consumata solo da un inserimento riuscito, anche per un
chiamante autenticato; soglie in [sicurezza.md](sicurezza.md) (B-94).
- **Effetti:** inserisce la segnalazione in stato `open`. Nessun audit,
nessuna notifica SSE, nessun limite di frequenza.
nessuna notifica SSE.
*Codice:* `app/api/routes/bug_reports.py:create_bug_report`,
`app/api/routes/bug_reports.py:BugReportCreate` ·
`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
@@ -575,7 +750,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
### Amministrazione
Tutti gli endpoint `/admin/*` richiedono `X-Admin-Token`
([API-08](#convenzioni-generali)) e rispondono `403` senza di esso. Gli eventi
([API-08](#convenzioni-generali)) e rispondono `403 invalid_admin_token`
senza di esso. Nessuno restituisce `rounds.server_seed` prima che il
vincitore del round sia stato scritto ([API-45](#get-roundsround_idproof)). Gli eventi
di audit citati sono descritti in [audit-log.md](audit-log.md); il significato
dei parametri operativi in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) e
@@ -587,9 +764,13 @@ dei parametri operativi in
`round_config`, creata con i valori predefiniti se manca).
- **Risposta `200`:** `{"fee_address", "bet_amount_sats",
"round_duration_seconds", "round_cooldown_seconds", "fee_rate_sat_vb",
"rbf_timeout_seconds", "draw_animation_seconds", "paused"}`.
"rbf_timeout_seconds", "draw_animation_seconds", "deposit_confirmations",
"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-73, B-82, B-85, B-95) · *Oggi:* mancano
`deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats` e
`paused_reason`.
#### PUT /admin/config
@@ -606,41 +787,73 @@ dei parametri operativi in
| `fee_rate_sat_vb` | 1 – 10 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)* |
| `draw_confirmations` | 1 – 20 — *da implementare (B-73)* |
| `max_round_pool_sats` | `0` (disattivato), oppure da `bet_amount_sats` a 4 000 000 000 000 000 — *da implementare (B-95)* |
`paused` **non** è accettato qui (è ignorato come campo sconosciuto): la
I limiti sono quelli di [modello-dati.md](modello-dati.md) (tabella
`round_config`), che ne è il proprietario. `paused` e `paused_reason`
**non** sono accettati qui (sono ignorati come campi sconosciuti): la
pausa ha i suoi endpoint registrati nell'audit.
- **Risposta `200`:** la configurazione completa, come
[API-30](#get-adminconfig).
- **Errori:** `422` per qualunque vincolo violato (nessun campo viene
applicato).
- **Effetti:** scrive `config_updated` con i valori prima/dopo dei soli
campi davvero cambiati; nessuna scrittura se nulla cambia. Durata e pausa
tra round si applicano dal round successivo. Nessuna notifica SSE.
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.
*Codice:* `app/api/routes/admin.py:update_config`,
`app/api/routes/admin.py:RoundConfigUpdate` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-73, B-82, B-95), per i tre campi nuovi e la
copia all'apertura · *Oggi:* sono accettati solo i primi sette campi.
#### POST /admin/pause e POST /admin/resume
- **[API-32]** Attivano e disattivano la pausa di manutenzione: impedisce
l'apertura del **prossimo** round, il round in corso prosegue fino al
pagamento.
- **Corpo:** nessuno. **Risposta `200`:** la configurazione completa.
- **Effetti:** impostano `paused` a vero/falso e scrivono `lottery_paused` /
`lottery_resumed` a **ogni** chiamata, anche se lo stato era già quello.
Nessuna notifica SSE: `/` vede `lottery_paused` al polling successivo.
- **[API-32]** Attivano e disattivano la pausa: impedisce l'apertura del
**prossimo** round, il round in corso prosegue fino al pagamento (salvo un
pagamento trattenuto, vedi sotto). La semantica di `paused_reason` è in
[modello-dati.md](modello-dati.md) (DAT-69).
- **`POST /admin/pause` — corpo:** facoltativo, `{"note": stringa}` di al
massimo 256 caratteri, un promemoria libero dell'operatore che finisce solo
nel payload di `lottery_paused`. Imposta `paused = true` e
`paused_reason = 'operator'`, tranne quando è già attivo un motivo
automatico (`reorg`, `payout_blocked`, `resurrected_tx`), che resta.
- **`POST /admin/resume` — corpo:** nessuno. È l'unico modo di uscire da
**qualunque** pausa, anche automatica: imposta `paused = false`,
`paused_reason = NULL` e azzera `rounds.payout_blocked_at` del round
attivo, se valorizzato (il pagamento viene poi riverificato,
DAT-68). Il payload di `lottery_resumed` riporta il `paused_reason` che è
stato tolto, così il registro dice che cosa l'operatore ha accettato di
riprendere.
- **Risposta `200`** (entrambi): la configurazione completa, come
[API-30](#get-adminconfig), compreso `paused_reason`.
- **Errori:** `422` (nota oltre 256 caratteri).
- **Effetti:** scrivono `lottery_paused` / `lottery_resumed` a **ogni**
chiamata, anche se lo stato era già quello. Nessuna notifica SSE: `/`
vede `lottery_paused` al polling successivo.
*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-78, B-82, B-85) · *Oggi:* nessun corpo, nessun
`paused_reason`, e la pausa è solo manuale.
#### GET /admin/users
- **[API-33]** Elenco di **tutti** gli utenti, per `id` crescente, senza
paginazione né limite.
- **[API-33]** Elenco degli utenti, per `id` crescente, a pagine.
- **Query:** `limit` intero 1–500, predefinito 100; `after_id` intero
facoltativo: restituisce solo gli utenti con `id` maggiore. La pagina
successiva si chiede con `after_id` uguale all'`id` dell'ultimo elemento
ricevuto; una lista più corta di `limit` è l'ultima pagina. Fuori
intervallo: `422`.
- **Risposta `200`:** lista di `{"id", "username", "address",
"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
@@ -649,8 +862,9 @@ dei parametri operativi in
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
- **Parametro:** `user_id` intero.
- **Risposta `200`:** `{"address", "wif"}`.
- **Errori:** `404` `"user not found"` (non strutturato); `422` se `user_id`
non è un intero.
- **Errori:** `404 user_not_found` (oggi non strutturato, `"user not
found"`, [API-16](#contratto-degli-errori)); `422` se `user_id` non è un
intero.
- **Effetti:** scrive `admin_privkey_accessed` a ogni accesso.
*Codice:* `app/api/routes/admin.py:user_privkey` ·
*Test:* `tests/unit/test_admin.py`
@@ -660,7 +874,8 @@ dei parametri operativi in
- **[API-35]** Imposta una nuova password casuale per un utente bloccato.
- **Risposta `200`:** `{"username", "new_password"}` — la password (16
caratteri URL-safe) è mostrata **solo** in questa risposta.
- **Errori:** `404` `"user not found"`; `422`.
- **Errori:** `404 user_not_found` (oggi non strutturato,
[API-16](#contratto-degli-errori)); `422`.
- **Effetti:** sostituisce l'hash, incrementa `token_version` (ogni sessione
aperta dell'utente riceve `401 session_expired`), scrive
`admin_password_reset`.
@@ -674,19 +889,40 @@ dei parametri operativi in
- **Risposta `200`:** lista di `{"id", "status", "opened_at", "closed_at",
"draw_block_height", "draw_block_hash", "winner_user_id",
"winner_username", "pool_amount_sats", "winner_amount_sats",
"fee_amount_sats", "payout_txid"}`; i campi non ancora valorizzati sono
`null`.
"fee_amount_sats", "payout_txid", "pool_address", "server_seed_hash",
"draw_height", "bump_reserve_sats", "payout_blocked_at"}`; i campi non
ancora valorizzati sono `null`. `server_seed` non è tra i campi: la prova
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-73, B-79, B-80, B-82), per gli ultimi cinque
campi · *Oggi:* la lista si ferma a `payout_txid`.
#### GET /admin/audit-log
- **[API-37]** Registro di audit, dalla voce più recente.
- **Query:** `limit` intero 1–500, predefinito 200.
- **[API-37]** Registro di audit, dalla voce più recente, filtrabile e a
pagine.
- **Query**, tutti facoltativi e combinati in **and**:
| Parametro | Tipo | Filtro |
|---|---|---|
| `limit` | intero 1–500, predefinito 200 | numero massimo di voci |
| `before_id` | intero | solo voci con `id` minore: la pagina successiva si chiede con l'`id` dell'ultima voce ricevuta; una lista più corta di `limit` è l'ultima pagina |
| `event_type` | stringa, ripetibile | solo voci di uno dei tipi indicati, confrontati alla lettera (un tipo inesistente dà una lista vuota) |
| `round_id` | intero | solo voci con quel `round_id` |
| `user_id` | intero | solo voci con quel `user_id` |
| `from` | stringa ISO 8601 con offset | solo voci con `created_at` ≥ questo istante |
| `to` | stringa ISO 8601 con offset | solo voci con `created_at` < questo istante |
Un valore non interpretabile (un intero non valido, un istante senza
offset) risponde `422 invalid_request`.
- **Risposta `200`:** lista di `{"id", "event_type", "payload" (oggetto),
"user_id", "round_id", "created_at"}`.
"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.
#### GET /admin/pending-transactions
@@ -721,7 +957,9 @@ dei parametri operativi in
transizione è ammessa, anche all'indietro.
- **Risposta `200`:** la segnalazione aggiornata, come
[API-39](#get-adminbug-reports).
- **Errori:** `422` (stato sconosciuto); `404` `"bug report not found"`.
- **Errori:** `422` (stato sconosciuto); `404 bug_report_not_found` (oggi
non strutturato, `"bug report not found"`,
[API-16](#contratto-degli-errori)).
- **Effetti:** scrive `bug_report_status_changed` (con stato precedente e
nuovo) solo se lo stato cambia davvero.
*Codice:* `app/api/routes/admin.py:update_bug_report_status` ·
+271 -67
View File
@@ -19,29 +19,45 @@ 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"]
A -- "scrive" --> L["./data/logs/app.log<br/>(e stdout, B-92)"]
A -- "Electrum (TCP/SSL)<br/>una connessione attiva" --> E["Server Electrum PLM<br/>primario + riserve"]
```
- **[ARC-01]** Il server gira sempre in Docker, con lo stesso
`docker-compose.yml` in sviluppo e in produzione: due container, `app`
(questo codice) e `caddy` (reverse proxy con TLS automatico). Cambia solo
`SITE_ADDRESS`: non impostato vale `localhost` (certificato autofirmato
dalla CA interna di Caddy), impostato a un dominio produce un certificato
Let's Encrypt. Non esiste un modo supportato di lanciare `uvicorn` fuori da
Docker.
`SITE_ADDRESS`, che sta in `.env` ed è obbligatorio: il compose lo legge
come `${SITE_ADDRESS:?}` e si rifiuta di partire se manca. `localhost`
produce un certificato autofirmato dalla CA interna di Caddy, un dominio un
certificato Let's Encrypt. Non esiste un modo supportato di lanciare
`uvicorn` fuori da Docker.
*Codice:* `docker-compose.yml` · `Caddyfile` ·
*Test:* `tests/unit/test_docs_no_bare_uvicorn.py`
*Stato:* da implementare (B-93) · *Oggi:* `SITE_ADDRESS` si passa sul
comando e non impostato vale `localhost`, quindi un `docker compose up`
senza variabile riporta un server di produzione su `localhost`.
- **[ARC-02]** Il container `app` esegue **un solo processo** uvicorn (nessun
`--workers`), in ascolto su `0.0.0.0:8123`. Tutto lo stato in memoria
descritto in [Assunzioni di processo singolo](#assunzioni-di-processo-singolo)
dipende da questo.
*Codice:* `Dockerfile` (`CMD`)
`--workers`), in ascolto su `0.0.0.0:8123`, con `--proxy-headers` e
`--forwarded-allow-ips` limitato all'indirizzo di Caddy: la rete di compose
ha una sottorete fissa e Caddy un indirizzo IPv4 fisso in essa. Così il log
di accesso registra l'IP del client invece di quello di Caddy. Tutto lo
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.
- **[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
`exec`. Le migrazioni non si lanciano mai a mano.
`exec`. Le migrazioni non si lanciano mai a mano. Il processo gira come
utente non root, su un'immagine di base fissata per digest e con dipendenze
installate da un file di lock con gli hash
([sicurezza.md](sicurezza.md) (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 `>=`.
- **[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
@@ -54,8 +70,34 @@ flowchart LR
`./data/logs` → `/app/logs`. Il compose sovrascrive `DATABASE_URL` e
`MASTER_KEY_PATH` perché puntino a quei percorsi; tutte le altre variabili
arrivano da `.env` (`env_file`). Entrambi i container hanno
`restart: unless-stopped`.
`restart: unless-stopped`. La chiave di cifratura della master key è un
file a parte, `./secrets/xprv_encryption.key` (permessi `0600`, fuori da
`./data/` così un backup dei dati non la contiene), montato in sola lettura
come secret di compose in `/run/secrets/xprv_encryption_key` e indicato da
`XPRV_ENCRYPTION_KEY_FILE` ([ARC-30]). Le directory di `./data/` hanno
permessi `0700`, i file di chiave `0600`. Le immagini sono etichettate per
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.
- **[ARC-61]** **Salute del container.** Il container `app` ha un
`healthcheck` che interroga `http://127.0.0.1:8123/health/ready` ogni 30s
(timeout 5s, 3 tentativi, `start_period` 60s). `/health/ready` risponde 200
solo se la connessione Electrum è attiva, l'ultimo header è recente e lo
scheduler ha battuto da poco; le soglie sono di
[api.md](api.md#servizio) (B-92). Lo scheduler aggiorna il battito a ogni
giro **e** a ogni iterazione dell'attesa del blocco di estrazione, perché
quell'attesa gira dentro il giro ([ARC-27]) e dura più blocchi. Docker non
riavvia un container `unhealthy`: lo stato serve al monitoraggio
([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]).
### Database
@@ -67,12 +109,23 @@ flowchart LR
proprie sessioni.
*Codice:* `app/db/base.py` · `app/db/session.py:get_session`
- **[ARC-07]** A ogni nuova connessione SQLite vengono impostati
`journal_mode=WAL`, `synchronous=NORMAL` e `busy_timeout=5000` ms: in WAL
lettori e scrittori non si bloccano a vicenda, e uno scrittore che trova il
database occupato aspetta fino a 5 secondi invece di fallire subito. Con un
dialetto diverso da SQLite le pragma non vengono applicate.
`journal_mode=WAL`, `synchronous=FULL` e `busy_timeout=5000` ms: in WAL
lettori e scrittori non si bloccano a vicenda, uno scrittore che trova il
database occupato aspetta fino a 5 secondi invece di fallire subito, e
`FULL` fa sì che un commit riuscito sopravviva a una mancanza di corrente
(con `NORMAL` si potevano perdere gli ultimi commit, per esempio una
registrazione, riusandone poi l'indice di derivazione). Ogni transazione che
sposta denaro si apre con `BEGIN IMMEDIATE`, così le letture su cui decide
sono isolate e l'attesa del lock avviene all'inizio, non alla prima
scrittura; un `database is locked` dopo un invio è gestito esplicitamente
(la riga resta al riconciliatore). Con un dialetto diverso da SQLite le
pragma non vengono applicate.
*Codice:* `app/db/base.py:_register_sqlite_pragmas` ·
*Test:* `tests/unit/test_db_base.py`
*Stato:* da implementare (B-88) · *Oggi:* `synchronous=NORMAL`, e pysqlite
in modalità legacy emette `BEGIN` solo alla prima scrittura, quindi le
letture precedenti non sono isolate e l'attesa del lock può scattare in
punti qualsiasi.
- **[ARC-08]** Lo schema è definito in `app/db/models.py` ed evolve solo
tramite migrazioni Alembic in `migrations/versions/` (generate con
`alembic revision --autogenerate`); `migrations/env.py` prende l'URL del
@@ -85,9 +138,14 @@ flowchart LR
- **[ARC-09]** Tutti i log dell'applicazione e di uvicorn (`uvicorn.error`,
`uvicorn.access`) finiscono in `logs/app.log` (nel container
`/app/logs/app.log`, cioè `./data/logs/app.log` sull'host), a rotazione da
10 MB con 5 file di storico, livello `INFO`. La configurazione avviene
all'import di `app/main.py`, prima di qualunque altro modulo applicativo.
10 MB con 5 file di storico, livello `INFO`, **e anche su stdout**, così
`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.
## Mappa del codice
@@ -136,17 +194,17 @@ sono vuoti e non sono elencati.
|---|---|
| `app/wallet/plm_network.py` | Parametri della rete PLM per embit (`PLM_MAINNET`), coin type 746, `ACCOUNT_PATH` |
| `app/wallet/keystore.py` | Cifratura/decifratura Fernet della master xprv |
| `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i`) e del pool (`1/0`), export WIF; la chiave d'account decifrata resta in cache nel processo |
| `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i`) e del pool (`1/0`), export WIF; la chiave d'account decifrata resta in cache nel processo. *Da implementare:* indirizzo del montepremi per round (`2/<round_id>`), con `1/0` come indirizzo di riserva (B-80) |
| `app/wallet/address.py` | `is_valid_plm_address`: checksum bech32 + HRP `plm` |
| `app/wallet/balance.py` | `recompute_balance` (saldo confermato), `compute_pending_balance` (saldo mostrato) — vedi [registrazione-e-depositi.md](registrazione-e-depositi.md) |
| `app/wallet/psbt_builder.py` | Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti `MAX_TX_INPUTS`, `MAX_PAYOUT_TX_INPUTS`, `MAX_PARTICIPANTS_PER_ROUND`, `DUST_LIMIT_SATS` |
| `app/wallet/psbt_builder.py` | Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti `MAX_TX_INPUTS`, `MAX_PAYOUT_TX_INPUTS`, `MAX_PARTICIPANTS_PER_ROUND`, `DUST_LIMIT_SATS`. *Da implementare:* uscita di riserva per l'RBF sul pagamento (B-79), UTXO sotto `DUST_LIMIT_SATS` esclusi dalla selezione (B-75) |
### Rete Electrum
| File | Responsabilità |
|---|---|
| `app/electrum/client.py` | `ElectrumClient` (JSON-RPC, timeout per richiesta), `ElectrumEndpoint`, `parse_endpoints` |
| `app/electrum/listener.py` | `ElectrumListener`: l'unica connessione, rotazione, keepalive, validazione header, corroborazione, sottoscrizioni e accredito — vedi [connessione-electrum.md](connessione-electrum.md) |
| `app/electrum/client.py` | `ElectrumClient` (JSON-RPC, timeout per richiesta), `ElectrumEndpoint`, `parse_endpoints`. *Da implementare:* limite di lettura di 16 MiB per riga, risposta troppo grande o malformata come errore della richiesta e non della connessione (B-75); pin del certificato per endpoint (B-84) |
| `app/electrum/listener.py` | `ElectrumListener`: l'unica connessione, rotazione, keepalive, validazione header, corroborazione, sottoscrizioni e accredito — vedi [connessione-electrum.md](connessione-electrum.md). Il docstring della classe cita un metodo `add_address` che non esiste: l'indirizzo di un nuovo utente si aggiunge con `address_for_new_user` |
| `app/electrum/scripthash.py` | `address_to_scripthash`: scripthash Electrum di un indirizzo |
### Dominio
@@ -161,7 +219,7 @@ sono vuoti e non sono elencati.
| `app/withdrawals/confirmation.py` | Gestore di conferma per il tipo `withdrawal` |
| `app/rounds/service.py` | Round attivo, scadenza, accettazione scommesse, `rounds_can_open`, apertura del round, quota del vincitore |
| `app/rounds/scheduler.py` | `RoundScheduler`: chiusura, estrazione, pagamento e suoi tentativi |
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore |
| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore. *Da implementare:* seme commit-reveal e impronta dei partecipanti (B-73), confronto del bersaglio con `powLimit` (B-74) |
| `app/rounds/config.py` | `get_round_config`: la riga unica di `round_config`, creata se manca |
| `app/rounds/confirmation.py` | Gestore di conferma per il tipo `payout` |
| `app/rounds/events.py` | `RoundEventBroadcaster` e il singleton `broadcaster` (SSE) |
@@ -287,13 +345,20 @@ sequenceDiagram
U->>L: startup
L->>L: validate_runtime_secrets()
L->>L: parse_endpoints(...)
L->>L: quorum minimo (B-84, da implementare)
L->>L: verifica della master key (B-89, da implementare)
L->>L: START_PAUSED (B-90, da implementare)
L->>L: app.state.electrum_listener, app.state.user_locks
L->>L: create_task ×6
Note over L: il server serve richieste
Note over L: il server serve richieste<br/>(controllo dei buchi di indici alla prima connessione, B-90)
U->>L: shutdown
L->>L: cancel() dei 6 task, chiusura del client Electrum
L->>L: chiusura dei flussi SSE (B-93, da implementare)
L->>L: cancel() dei 6 task (e attesa della fine, B-93), chiusura del client Electrum
```
Il diagramma mostra il comportamento atteso; i passi marcati «da
implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
### All'import
- **[ARC-12]** `setup_logging()` è chiamata come prima istruzione applicativa
@@ -321,34 +386,76 @@ sequenceDiagram
*Codice:* `app/main.py:log_unhandled_exception` ·
*Test:* `tests/unit/test_rounds_route.py`
- **[ARC-17]** `GET /health` risponde `{"status": "ok"}` senza toccare
database né rete.
database né rete: dice solo che il processo risponde (*liveness*). Se il
servizio è davvero in grado di lavorare lo dice `GET /health/ready`
([ARC-61], [api.md](api.md#servizio) (B-92)).
*Codice:* `app/main.py:health`
- **[ARC-18]** Le quattro pagine HTML hanno route esplicite che servono il
file con `Cache-Control: no-store`: `/` → `index.html`, `/admin` →
`admin.html`, `/guida` → `guida.html`, `/report-bug` → `report-bug.html`.
Per ultimo, l'intera cartella `app/static/` è montata su `/` con
`StaticFiles(html=True)`: JS, CSS e ogni altro file vi sono serviti con il
loro nome (`/app.js`, `/i18n.js`, `/admin.css`…). I percorsi sono relativi
alla directory di lavoro, che nel container è `/app`.
loro nome (`/app.js`, `/i18n.js`, `/admin.css`…), con
`Cache-Control: no-cache` (il browser li riconvalida a ogni caricamento).
I percorsi sono relativi alla directory di lavoro, che nel container è
`/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.
### Nel lifespan
- **[ARC-19]** Come primo passo, `validate_runtime_secrets()` fa rifiutare
l'avvio (solleva `ConfigError`) se `JWT_SECRET` è più corto di 32
caratteri o `XPRV_ENCRYPTION_KEY` è vuota, riportando tutti i problemi
insieme. `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello resta
semplicemente chiuso. Il controllo sta nel lifespan e non in un validatore
di `Settings`, così import e test non hanno bisogno di segreti veri.
caratteri o la chiave di cifratura della master key è vuota (il file
indicato da `XPRV_ENCRYPTION_KEY_FILE` manca o è vuoto), riportando tutti i
problemi insieme. `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello
resta semplicemente chiuso. Il controllo sta nel lifespan e non in un
validatore di `Settings`, così import e test non hanno bisogno di segreti
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`; un
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`
(`host:porta[:notls][:pin=<sha256>]` separati da virgola, [ARC-30]); un
elenco di riserva malformato blocca l'avvio. L'ordine di rotazione viene
scritto nel log.
*Codice:* `app/electrum/client.py:parse_endpoints` ·
*Test:* `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-84) · *Oggi:* la sintassi è `host:porta[:notls]`,
senza pin del certificato.
- **[ARC-60]** **Controlli di avvio.** Dopo [ARC-19] e [ARC-20] e prima di
creare i task, il lifespan esegue nell'ordine, e ogni controllo fallito
impedisce di servire:
1. **quorum minimo**: almeno 2 server di riserva, salvo
`ALLOW_NO_QUORUM=true` (solo sviluppo, scritto nel log come
avvertimento) — regola in
[connessione-electrum.md](connessione-electrum.md#5-il-quorum) (B-84);
2. **master key**: la chiave viene decifrata e l'indirizzo di riserva del
montepremi e un campione degli indirizzi utente salvati vengono
ricalcolati e confrontati con il database — regola in
[sicurezza.md](sicurezza.md#regole-di-avvio) (B-89);
3. **avvio in pausa**: con `START_PAUSED=true` la lotteria viene messa in
pausa prima che lo scheduler parta — procedura in
[operativita.md](operativita.md#ripristino) (B-90).
Il controllo dei **buchi negli indici di derivazione** dopo un ripristino
(i 20 indici successivi al più alto assegnato, che non devono avere
cronologia in catena) ha bisogno della rete, quindi non può stare prima
dei task: gira alla prima connessione del listener, e finché non si è
concluso senza trovare cronologia la registrazione è rifiutata
([operativita.md](operativita.md#ripristino), B-90).
*Codice:* `app/main.py:lifespan` · `app/config.py:validate_runtime_secrets` ·
*Test:* —
*Stato:* da implementare (B-84, B-89, B-90) · *Oggi:* dopo i segreti e gli
endpoint 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
@@ -356,10 +463,17 @@ sequenceDiagram
*Codice:* `app/main.py:lifespan` · `app/auth/routes.py:_rate_limiters`
- **[ARC-22]** Il lifespan crea esattamente sei task asyncio, nell'ordine
`ElectrumListener`, `RoundScheduler`, `ConfirmationPoller`, `RbfBumper`,
`PendingTransactionReconciler`, `DepositReconciler`. Allo spegnimento
ciascun task riceve `cancel()` (senza attenderne la fine) e il client
Electrum, se presente, viene chiuso.
*Codice:* `app/main.py:lifespan` · *Test:* `tests/unit/test_code_comments.py`
`PendingTransactionReconciler`, `DepositReconciler`. Allo spegnimento, in
quest'ordine: ogni flusso SSE aperto viene chiuso (altrimenti uvicorn
aspetta le connessioni aperte e Docker lo uccide), ciascun task riceve
`cancel()` e il lifespan **ne attende la fine**, poi il client Electrum, se
presente, viene chiuso. Il compose dà al container `stop_grace_period: 30s`
per completare questa sequenza prima del `SIGKILL`.
*Codice:* `app/main.py:lifespan` · `app/rounds/events.py:RoundEventBroadcaster` · `docker-compose.yml` ·
*Test:* `tests/unit/test_code_comments.py`
*Stato:* da implementare (B-93) · *Oggi:* i task ricevono `cancel()` senza
attesa, i flussi SSE restano aperti, e con uno stream collegato Docker uccide
il container dopo i 10s predefiniti.
## Attività in background
@@ -414,24 +528,53 @@ sequenceDiagram
subito al server successivo; l'attesa crescente (1s raddoppiando fino a 30s)
scatta solo quando tutti i server hanno fallito nello stesso giro.
Rotazione, keepalive, validazione e corroborazione sono in
[connessione-electrum.md](connessione-electrum.md).
[connessione-electrum.md](connessione-electrum.md). Tre cambiamenti di
struttura sono attesi dal piano, con le regole in quel documento:
- il listener conserva gli ultimi 10 header ricevuti; un salto di più
blocchi scarica e valida gli header intermedi, e un header che si
concatena a uno di quelli conservati ma non alla punta è una
riorganizzazione da seguire, non la fine della sessione (B-74, B-85);
- una sessione connessa la cui punta non avanza da 360s, mentre un altro
server ne riporta una più alta, viene chiusa e si passa al server
successivo (B-86);
- il client legge righe fino a 16 MiB, e una risposta oltre il limite o
malformata fa fallire la sola richiesta, non la connessione (B-75).
*Codice:* `app/electrum/listener.py:ElectrumListener.run` ·
`app/electrum/listener.py:ElectrumListener._apply_header` ·
`app/electrum/client.py:ElectrumClient.connect` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-74, B-75, B-85, B-86) · *Oggi:* il listener
tiene solo la punta, chiude la sessione su ogni header che non si concatena,
non confronta la propria punta con gli altri server, e il client usa il
limite predefinito di 64 KiB di `asyncio.open_connection`, oltre il quale
cade l'intera connessione.
- **[ARC-27]** **`RoundScheduler`.** A ogni giro (5s) chiama
`open_new_round_if_needed` e poi avanza il round attivo. L'estrazione
(`_close_and_draw`, con l'attesa del blocco successivo) viene eseguita
**dentro** il giro: finché dura, lo scheduler non fa altro. Dopo un riavvio
un round rimasto in `drawing` non viene ripreso (limite accettato, vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md)). Logica del round in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
`open_new_round_if_needed` e poi avanza il round attivo, in qualunque stato
sia, `drawing` compreso: per un round in `drawing` il giro rientra
nell'attesa del blocco di estrazione a partire da ciò che è salvato sulla
riga del round (`draw_height`, `participants_hash`, posizioni dei
partecipanti), in modo idempotente. L'attesa **resta dentro il giro**:
finché dura, lo scheduler non fa altro, il che è corretto perché nessun
altro round può aprirsi finché questo non è `closed`. Un'eccezione durante
l'attesa termina il giro ([ARC-25]) e il giro successivo riparte dallo stato
salvato, con lo stesso esito; lo stesso vale dopo un riavvio. Logica del
round in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
`app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
*Test:* `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-81) · *Oggi:* `_tick` salta i round in
`drawing`; `tip_at_close` e i partecipanti vivono solo nella chiamata a
`_close_and_draw`, quindi un'eccezione o un riavvio lasciano il round in
`drawing` per sempre
([LIM-01](decisioni-e-limiti.md#robustezza-del-ciclo-di-vita)).
- **[ARC-28]** **`ConfirmationPoller`** (10s), **`RbfBumper`** (30s) e
**`PendingTransactionReconciler`** (al primo giro subito, poi ogni 120s)
lavorano sulla tabella `pending_transactions`; il riconciliatore parte
subito perché un crash durante un invio lascia proprio una riga `building`
da risolvere al riavvio. Regole in
lavorano sulla tabella `pending_transactions` (e, dal piano, sulla tabella
di tutti i txid di ogni riga, B-77); il riconciliatore parte subito perché
un crash durante un invio lascia proprio una riga `building` da risolvere
al riavvio. Regole in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
*Codice:* `app/tx/confirmation.py:ConfirmationPoller` ·
`app/tx/broadcast.py:RbfBumper` ·
@@ -459,8 +602,10 @@ La configurazione è divisa in due posti con regole diverse.
| `electrum_host` | `ELECTRUM_HOST` | `santantonio.sytes.net` | Server Electrum primario |
| `electrum_port` | `ELECTRUM_PORT` | `50002` | Porta del primario |
| `electrum_use_ssl` | `ELECTRUM_USE_SSL` | `True` | TLS verso il primario |
| `electrum_fallback_servers` | `ELECTRUM_FALLBACK_SERVERS` | `""` | Riserve, `host:porta[:notls]` separati da virgola |
| `xprv_encryption_key` | `XPRV_ENCRYPTION_KEY` | `""` | Chiave Fernet della master xprv (obbligatoria, [ARC-19]) |
| `electrum_fallback_servers` | `ELECTRUM_FALLBACK_SERVERS` | `""` | Riserve, `host:porta[:notls][:pin=<sha256>]` separati da virgola; almeno 2 ([ARC-60]) |
| `allow_no_quorum` | `ALLOW_NO_QUORUM` | `False` | Permette di partire con meno di 2 server di riserva; solo sviluppo ([ARC-60]) |
| `xprv_encryption_key_file` | `XPRV_ENCRYPTION_KEY_FILE` | `/run/secrets/xprv_encryption_key` | File con la chiave Fernet della master xprv (obbligatorio, [ARC-19]); sostituisce `XPRV_ENCRYPTION_KEY` |
| `start_paused` | `START_PAUSED` | `False` | Avvia con la lotteria in pausa, dopo un ripristino ([ARC-60]) |
| `master_key_path` | `MASTER_KEY_PATH` | `./master.xprv.enc` | File della master xprv cifrata (in Docker sovrascritto dal compose) |
| `jwt_secret` | `JWT_SECRET` | `""` | Chiave HS256 dei token (≥ 32 caratteri, [ARC-19]) |
| `jwt_algorithm` | `JWT_ALGORITHM` | `HS256` | Algoritmo JWT |
@@ -469,22 +614,35 @@ 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-84, B-90, B-91) · *Oggi:* `ALLOW_NO_QUORUM`,
`START_PAUSED` e `XPRV_ENCRYPTION_KEY_FILE` non esistono, la chiave Fernet è
la variabile `XPRV_ENCRYPTION_KEY` e la sintassi delle riserve non ha
`pin=`.
- **[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) stanno nell'unica riga della
tabella `round_config`, modificabile a caldo da `/admin` senza riavvio. I
valori iniziali sono i default delle colonne di `RoundConfig`, non
`app/config.py`. Se la riga manca, viene creata al primo accesso con
`fee_address` vuoto. Campi in [modello-dati.md](modello-dati.md), effetti
in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei
depositi `deposit_confirmations`, conferme del blocco di estrazione
`draw_confirmations`, tetto al montepremi `max_round_pool_sats`) stanno
nell'unica riga della tabella `round_config`, modificabile a caldo da
`/admin` senza riavvio. I valori iniziali sono i default delle colonne di
`RoundConfig`, non `app/config.py`. Se la riga manca, viene creata al primo
accesso con `fee_address` vuoto. Campi in [modello-dati.md](modello-dati.md),
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-73, B-82, B-85, B-95) · *Oggi:*
`deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats` e il
motivo della pausa non esistono.
- **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili:
la ripartizione 70/30, le conferme richieste (1), i limiti di input e di
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
partecipanti, le cadenze dei task. Si cambiano solo con una modifica al
codice (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
codice (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)). Le conferme
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)
@@ -551,7 +709,9 @@ 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`;
`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;
- 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`,
@@ -559,6 +719,8 @@ 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
@@ -636,11 +798,17 @@ flowchart LR
barra di stato (stato del round e altezza della catena). Le sezioni Utenti,
Round, Transazioni pendenti, Audit log e Bug report si caricano quando le
si apre e si ricaricano a ogni evento SSE (`update` o `open`) mentre sono
aperte; non hanno un polling periodico proprio. La sezione Parametri non
viene ricaricata dagli eventi.
aperte; non hanno un polling periodico proprio, quindi se il canale SSE è
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).
*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`
@@ -762,14 +930,50 @@ italiano.
`tests/unit/test_code_comments.py` ·
`tests/unit/test_caddyfile_security_headers.py` ·
`tests/unit/test_withdrawals.py`
- **[ARC-58]** Ciò che la suite **non** copre: nessun test contro una
connessione Electrum reale; il `lifespan` e l'avvio dei sei task; la route
SSE `round_stream` (è testato solo il broadcaster); `UserLocks` e la sua
acquisizione nelle route di scommessa e prelievo; il codice JavaScript dei
frontend; la parità delle chiavi tra le lingue di `i18n.js`. Le verifiche
dal vivo sono state manuali (`scripts/electrum_smoke_test.py` e transazioni
reali su mainnet; prelievo e RBF mai trasmessi dal vivo, vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
- **[ARC-58]** Ciò che la suite **non** copre oggi: nessun test contro una
connessione Electrum reale né contro guasti di rete realistici (ritardi,
cadute a metà richiesta, risposte enormi, catene divergenti); il `lifespan`
e l'avvio dei sei task; la route SSE `round_stream` (è testato solo il
broadcaster); `UserLocks` e la sua acquisizione nelle route di scommessa e
prelievo; il codice JavaScript dei frontend; la parità delle chiavi tra le
lingue di `i18n.js`; le migrazioni Alembic (lo schema dei test nasce da
`Base.metadata.create_all`). Le verifiche dal vivo sono state manuali
(`scripts/electrum_smoke_test.py` e transazioni reali su mainnet; prelievo e
RBF mai trasmessi dal vivo, vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
La revisione del 2026-09-28 ha osservato che nessuna delle sue falle
critiche sarebbe stata trovata da questa suite, che simula tutto: da qui
[ARC-62], [ARC-63] e [ARC-64].
- **[ARC-62]** **Test delle migrazioni.** Un test esegue `alembic upgrade head`
su un database vuoto e verifica che esistano gli indici su espressione
`ix_rounds_single_active` e `ix_users_username_lower`, che l'autogenerate
non vede e che `batch_alter_table` su SQLite elimina ricostruendo la
tabella con un solo avviso. Ogni migrazione batch su `rounds` o `users` li
ricrea esplicitamente.
*Codice:* `migrations/env.py` · `migrations/versions/` ·
*Test:* —
*Stato:* da implementare (B-97) · *Oggi:* nessun test esegue le migrazioni.
- **[ARC-63]** **Test di parità delle traduzioni.** Un test legge
`TRANSLATIONS` da `app/static/i18n.js` e fallisce se una delle sette lingue
ha un insieme di chiavi diverso da quello di `en` ([ARC-51]).
*Codice:* `app/static/i18n.js` ·
*Test:* —
*Stato:* da implementare (senza B-nn: chiude
[LIM-21](decisioni-e-limiti.md#prodotto-e-interfacce)) · *Oggi:* la parità è
verificata solo a mano.
- **[ARC-64]** **Strato di test di guasto e di integrazione**, in
`tests/integration/`, separato dalla suite unitaria: server Electrum
simulati a livello di socket che rispondono in ritardo, chiudono la
connessione dopo aver ricevuto un invio, mandano risposte oltre 16 MiB,
header a difficoltà minima o catene che si biforcano; più il database SQLite
su file con scrittori concorrenti. Non sostituisce la prova con denaro vero
e guasti provocati di
[operativita.md](operativita.md#prova-su-rete-reale), che resta obbligatoria
prima dell'apertura al pubblico.
*Codice:* `tests/integration/__init__.py` ·
*Test:* —
*Stato:* da implementare (fase 7 del
[piano](piano-correzioni.md#fase-7--prova-su-rete-reale)) · *Oggi:*
`tests/integration/` contiene solo `__init__.py`.
## Assunzioni di processo singolo
+445 -145
View File
@@ -2,7 +2,7 @@
Fase 4 del [funzionamento generale](come-funziona.md): come un round si apre,
si chiude, estrae un vincitore e lo paga. Tutto è guidato da `RoundScheduler`,
che si risveglia ogni 5 secondi.
che esegue un passo ogni 5 secondi.
I requisiti usano il prefisso `RND`, secondo la convenzione di
[processo-di-modifica.md](processo-di-modifica.md). Le colonne della tabella
@@ -10,25 +10,38 @@ I requisiti usano il prefisso `RND`, secondo la convenzione di
audit in [audit-log.md](audit-log.md), le attività in background in
[architettura.md](architettura.md).
- **[RND-01]** Lo scheduler esegue un passo ogni 5 secondi
(`_TICK_INTERVAL_SECONDS`). Un passo non fa nulla se non c'è connessione a
Electrum: senza rete nessun round si apre, si chiude o si paga. Un errore in
un passo viene registrato nei log e non ferma i passi successivi.
Questo documento descrive il comportamento **atteso** dopo la revisione del
2026-09-28. I requisiti che il codice non soddisfa ancora sono marcati
*Stato:* da implementare, con una riga *Oggi* che dice cosa fa il codice
adesso; l'ordine di implementazione è in
[piano-correzioni.md](piano-correzioni.md).
- **[RND-01]** Lo scheduler esegue un passo, poi attende
`_TICK_INTERVAL_SECONDS` (5s) prima del successivo. Un passo non fa nulla
se non c'è connessione a Electrum: senza rete nessun round si apre, si
chiude, estrae o si paga. Un errore in un passo viene registrato nei log e
il passo successivo riparte dallo stato salvato nel database, **in
qualunque stato sia il round**, `drawing` compreso ([RND-36]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler.run`, `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-81) · *Oggi:* un errore durante l'attesa del
blocco lascia il round in `drawing` per sempre, perché `_tick` non fa nulla
sui round in quello stato.
## Gli stati
Diagramma del **comportamento atteso**:
```mermaid
stateDiagram-v2
[*] --> open: nessun round attivo,<br/>pausa tra i round trascorsa,<br/>lotteria non in manutenzione,<br/>indirizzo commissioni impostato
[*] --> open: nessun round attivo,<br/>pausa tra i round trascorsa,<br/>lotteria non in pausa,<br/>configurazione pagabile
open --> closing: scade il tempo<br/>(al primo passo dopo la scadenza)
closing --> closing: scommesse ancora in volo
closing --> closing: scommesse ancora in volo,<br/>oppure altezza della catena non nota
closing --> closed: nessun partecipante confermato
closing --> drawing: tutte le scommesse confermate
drawing --> drawing: blocco non corroborato,<br/>si attende il successivo
drawing --> paying_out: nuovo blocco corroborato,<br/>vincitore scelto
paying_out --> paying_out: pagamento fallito o abbandonato,<br/>nuovo tentativo dopo 60s
closing --> drawing: tutte le scommesse confermate:<br/>partecipanti, participants_hash<br/>e draw_height salvati
drawing --> drawing: conferme insufficienti, oppure<br/>i server non concordano sull'header:<br/>si richiede la stessa altezza<br/>(anche dopo un errore o un riavvio)
drawing --> paying_out: header a draw_height concordato,<br/>vincitore salvato, server_seed rivelato
paying_out --> paying_out: pagamento fallito o abbandonato,<br/>nuovo tentativo dopo 60s;<br/>oppure bloccato (payout_blocked)
paying_out --> closed: pagamento confermato
closed --> [*]
```
@@ -37,8 +50,8 @@ stateDiagram-v2
|---|---|
| `open` | Accetta scommesse fino alla scadenza |
| `closing` | Scadenza passata: nessuna nuova scommessa, si aspettano quelle già inviate |
| `drawing` | Partecipanti fissati, si attende il blocco che sceglie il vincitore |
| `paying_out` | Vincitore noto, pagamento in costruzione, inviato o in attesa di conferma |
| `drawing` | Partecipanti e altezza del blocco fissati, si attendono le conferme del blocco che sceglie il vincitore |
| `paying_out` | Vincitore noto, pagamento in verifica, in costruzione, inviato o in attesa di conferma |
| `closed` | Concluso (con o senza vincitore) |
- **[RND-02]** Gli stati sono esattamente quelli della tabella e le sole
@@ -67,31 +80,85 @@ richiesta di scommessa (vedi
dal suo `closed_at`: per un round pagato è il momento della conferma del
pagamento, per un round chiuso senza partecipanti è il momento della
chiusura. Serve a lasciare ai giocatori il tempo di vedere il risultato;
- la lotteria **non è in manutenzione** (`POST /admin/pause`);
- la lotteria **non è in pausa**, né per manutenzione (`POST /admin/pause`)
né per una pausa automatica: riorganizzazione della catena profonda almeno
2 blocchi ([connessione-electrum.md](connessione-electrum.md) (B-85)),
pagamento bloccato ([RND-39]), transazione abbandonata poi confermata
([transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) (B-78)),
avvio con `START_PAUSED=true` ([operativita.md](operativita.md) (B-90)).
Il motivo è salvato in `paused_reason`
([modello-dati.md](modello-dati.md) (B-85)) e solo l'operatore toglie la
pausa;
- l'**indirizzo delle commissioni** (`fee_address`) è impostato (un valore
di soli spazi conta come vuoto). Senza, il pagamento non si potrebbe
costruire, quindi il round non viene nemmeno aperto invece di bloccarsi
più avanti con i soldi già nel montepremi.
di soli spazi conta come vuoto) e la configurazione corrente permette di
pagare anche un round con un solo partecipante ([RND-40]). Altrimenti il
pagamento non si potrebbe costruire, quindi il round non viene nemmeno
aperto invece di bloccarsi più avanti con i soldi già nel montepremi.
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:rounds_can_open` ·
*Test:* `tests/unit/test_rounds_service.py`
- **[RND-05]** All'apertura il round **fotografa** la durata
(`round_duration_seconds`, 600s predefiniti) e la pausa
(`round_cooldown_seconds`, 30s predefiniti) correnti. Scadenza del round e
pausa successiva usano sempre i valori fotografati: una modifica da
`/admin` vale dal round successivo, mai per quello in corso.
*Stato:* da implementare (B-85, B-79) · *Oggi:* l'unica pausa è quella
manuale (`paused`) e `rounds_can_open` controlla solo che `fee_address` non
sia vuoto.
- **[RND-05]** All'apertura il round **fotografa** i parametri correnti che lo
governano: durata (`round_duration_seconds`, 600s predefiniti), pausa
successiva (`round_cooldown_seconds`, 30s predefiniti), costo della
scommessa (`bet_amount_sats`), indirizzo delle commissioni (`fee_address`),
conferme richieste sul blocco di estrazione (`draw_confirmations`, 3
predefinite) e tetto al montepremi (`max_round_pool_sats`, 0 = nessun
tetto; il rifiuto delle scommesse oltre il tetto è in
[scommesse-e-prelievi.md](scommesse-e-prelievi.md#quando-è-accettata) (B-95)).
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`
- **[RND-06]** La pausa di manutenzione e l'indirizzo mancante bloccano solo
l'apertura del *prossimo* round: un round già in corso prosegue fino al
pagamento, anche se l'indirizzo viene cancellato nel frattempo.
*Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py` (solo durata e pausa)
*Stato:* da implementare (B-95, B-73) · *Oggi:* sono fotografate solo durata
e pausa; costo della scommessa e `fee_address` sono letti dalla
configurazione a ogni uso, `draw_confirmations` e `max_round_pool_sats` non
esistono.
- **[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
`fee_address` durante il round non ne cambia la destinazione della
commissione. L'API di amministrazione non permette di svuotare
`fee_address` (un valore vuoto non è un indirizzo PLM valido).
`GET /rounds/current` espone i due casi separatamente (`lottery_paused`,
`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` ·
*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`.
- **[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
segreto di 32 byte casuali (`server_seed`, da un generatore
crittograficamente sicuro), lo salva sul round e pubblica solo la sua
impronta `server_seed_hash = sha256(server_seed)`, esposta da
`GET /rounds/current` e dalla prova pubblica ([RND-37]) fin dall'apertura.
`server_seed` non è esposto da nessun endpoint, `/admin` compreso, finché il
vincitore non è salvato ([RND-18]).
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/api/routes/rounds.py:current_round` ·
*Test:* —
*Stato:* da implementare (B-73) · *Oggi:* il round non ha alcun segreto;
il seme dipende solo dall'hash del blocco.
- **[RND-35]** **Un indirizzo del montepremi per round**: all'apertura il
round riceve un proprio indirizzo `m/84'/746'/0'/2/<round_id>`, salvato in
`rounds.pool_address` ed esposto da `GET /rounds/current`. Le scommesse del
round pagano quell'indirizzo
([scommesse-e-prelievi.md](scommesse-e-prelievi.md#cosa-succede) (B-80)) e il
pagamento spende solo monete di quell'indirizzo ([RND-22]). I round aperti
prima di questa modifica mantengono l'indirizzo condiviso
`m/84'/746'/0'/1/0`, che diventa l'**indirizzo di riserva** ([RND-38]).
Il rifiuto dei prelievi verso indirizzi di montepremi, riserva o
commissioni è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo) (B-80).
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/wallet/hd.py:derive_pool_key` ·
*Test:* —
*Stato:* da implementare (B-80) · *Oggi:* tutti i round usano lo stesso
indirizzo `m/84'/746'/0'/1/0` (`derive_pool_key`).
## 1 · Round aperto
@@ -109,8 +176,8 @@ accettazione sono in
**confermate** — esattamente ciò che l'estrazione considererà e che il
pagamento potrà spendere. `jackpot_sats` è già il 70% del vincitore
(arrotondato per difetto) della somma degli importi netti, non l'intero
montepremi; resta un limite superiore, perché la commissione del pagamento
sarà dedotta da lì;
montepremi; resta un limite superiore, perché la commissione di rete del
pagamento sarà dedotta da lì;
- `pending_jackpot_sats` e `pending_participant_count`: gli stessi valori
includendo le scommesse ancora in volo (`building`, `broadcast`), come
totali e non come differenze; `has_pending_bets` dice se differiscono.
@@ -128,169 +195,345 @@ giocata, ma il montepremi pubblicizzato non supera mai quello che verrà pagato.
## 2 · Chiusura
- **[RND-11]** Al primo passo dello scheduler dopo la scadenza (quindi entro
circa 5 secondi) il round passa a `closing`, qualunque cosa stia accadendo:
si annota `closed_at` e si invia una notifica SSE. Le scommesse sono però
rifiutate già dall'istante della scadenza ([BET-04](scommesse-e-prelievi.md#quando-è-accettata)).
- **[RND-11]** Al primo passo dello scheduler che inizia dopo la scadenza (i
passi sono separati da `_TICK_INTERVAL_SECONDS` = 5s di attesa) il round
passa a `closing`, qualunque cosa stia accadendo: si annota `closed_at` e si
invia una notifica SSE. Le scommesse sono però rifiutate già dall'istante
della scadenza ([BET-04](scommesse-e-prelievi.md#quando-è-accettata)).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* `tests/unit/test_scheduler.py`
*Test:* `tests/unit/test_scheduler.py` (verifica solo lo stato finale; nessun test controlla `closed_at` né la notifica SSE)
- **[RND-12]** Poi il round **attende che ogni scommessa già inviata sia
confermata** (il "semaforo giallo"): finché esiste un partecipante in stato
`building` o `broadcast` il round resta `closing` e il controllo si ripete
al passo successivo. Il controllo viene ripetuto anche nella stessa sessione
del database in cui si fissa l'elenco dei partecipanti, così una scommessa
salvata tra i due controlli non può restare esclusa. Una scommessa partita
un attimo prima della scadenza resta quindi valida e non va persa. Ciò che
impedisce all'attesa di durare per sempre è il riconciliatore, che prima o
poi abbandona una scommessa che non conferma mai
([TX-17](transazioni-e-riconciliazione.md#riconciliazione)).
un attimo prima della scadenza resta quindi valida e non va persa.
**L'attesa non ha un limite di tempo**: finisce solo quando ogni scommessa
in volo è confermata oppure la sua riga viene abbandonata dal
riconciliatore, che la abbandona solo con una prova
([TX-17](transazioni-e-riconciliazione.md#riconciliazione), B-78). Una scommessa
che resta nel mempool non viene mai abbandonata: a farla confermare è
l'aumento della commissione
([TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)),
pagato dall'utente.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
*Test:* `tests/unit/test_scheduler.py` (solo il secondo controllo, in `_close_and_draw`; nessun test copre l'attesa in `_tick` con una scommessa in volo)
*Stato:* da implementare (B-78) · *Oggi:* l'attesa è già senza limite, ma
il riconciliatore abbandona una scommessa anche solo perché la cronologia
del server attivo non contiene il suo txid, senza altra prova.
- **[RND-13]** Quando non resta nulla in volo e **nessun partecipante è
confermato**, il round passa a `closed` senza vincitore, si scrive
`round_closed` (con `participants: 0`) e si invia una notifica SSE.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-14]** Con **almeno un partecipante confermato**, si fissa l'elenco dei
partecipanti confermati, ordinato per momento di invio della scommessa, si
somma il montepremi dai loro importi netti, e il round passa a `drawing`
annotando `drawing_started_at`; si invia una notifica SSE. L'altezza della
catena nota in quell'istante diventa il riferimento dell'estrazione.
- **[RND-14]** Con **almeno un partecipante confermato**, nella stessa
transazione del database che porta il round a `drawing`:
- si fissa l'elenco dei partecipanti confermati, **ordinato per txid della
scommessa** in ordine crescente (txid in esadecimale minuscolo, nell'ordine
mostrato dagli explorer; due scommesse non hanno mai lo stesso txid), e si
salva su ogni partecipante la sua posizione `draw_position`, da 0 a n−1;
- si calcola e si salva `participants_hash` = sha256 delle righe
`txid:vout:indirizzo`, una per partecipante nell'ordine di
`draw_position`, dove `vout` è l'uscita della scommessa verso l'indirizzo
del montepremi del round (in decimale) e `indirizzo` è quello
dell'utente; ogni riga termina con `\n`, testo UTF-8;
- si salva l'altezza del blocco di estrazione ([RND-33]);
- si somma il montepremi dagli importi netti e si annota
`drawing_started_at`.
Dopo il commit si invia una notifica SSE; da quel momento `participants_hash`
e `draw_height` sono pubblici ([RND-37]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
*Test:* — (nessun test porta `_close_and_draw` con partecipanti confermati fino a `drawing`)
*Stato:* da implementare (B-73, B-81) · *Oggi:* l'elenco è ordinato per
`broadcast_at` (che il pubblico non vede e che non ha spareggio) e resta
solo in memoria; non esistono `draw_position` né `participants_hash`.
- **[RND-33]** L'altezza del blocco di estrazione è fissata all'ingresso in
`drawing` come `draw_height = tip_at_close + 1`, dove `tip_at_close` è
l'altezza della catena nota in quell'istante, e non cambia più: né per una
conferma mancata, né per un errore, né per un riavvio.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* —
*Stato:* da implementare (B-73) · *Oggi:* si usa la punta vista al primo
controllo utile, che può essere un blocco successivo; dopo una
corroborazione fallita il riferimento avanza all'altezza rifiutata.
## 3 · Estrazione
L'estrazione usa come fonte di casualità l'**hash di un blocco confermato
dopo la chiusura**, un valore che nessuno conosceva mentre le scommesse erano
aperte.
L'estrazione combina tre valori che nessuno conosce tutti insieme prima che
sia troppo tardi per cambiarli:
- il **segreto del server** (`server_seed`), fissato all'apertura e noto solo
al server, ma impegnato pubblicamente con la sua impronta ([RND-34]): il
server non può cambiarlo dopo;
- l'**hash del blocco** all'altezza `draw_height`, che non esisteva quando
partecipanti e altezza sono stati fissati;
- l'**impronta dei partecipanti** (`participants_hash`), pubblicata prima che
quel blocco esista ([RND-14]).
Chi produce o trasmette i blocchi non conosce il segreto, quindi non sa quale
blocco farebbe vincere chi: non ha motivo di nasconderne o sceglierne uno.
L'operatore conosce il segreto ma non il blocco, e non può più cambiare né
il segreto né l'elenco. Diagramma del **comportamento atteso**:
```mermaid
flowchart TB
A["Altezza nota alla chiusura<br/>(se sconosciuta: la prima che si<br/>viene a sapere, draw_baseline_tip_unknown)"] --> B{"La punta della catena<br/>è più alta?<br/>(controllo ogni 5s)"}
A["Ingresso in drawing: salvati<br/>partecipanti ordinati per txid,<br/>participants_hash, draw_height = tip + 1"] --> B{"La catena ha draw_confirmations<br/>blocchi a partire da draw_height?<br/>(controllo a ogni passo)"}
B -- "No" --> B
B -- "Sì" --> C{"La maggioranza degli<br/>altri server conferma<br/>l'hash a quell'altezza?"}
C -- "No" --> D["Registrato in audit<br/>(draw_header_corroboration_failed),<br/>quell'altezza diventa il nuovo riferimento"] --> B
C -- "Sì" --> E["seme = hash come numero intero<br/>indice = seme mod numero partecipanti"]
E --> F["Vincitore = partecipante all'indice"]
B -- "Sì" --> C{"Tutti i server interrogati<br/>sull'header a draw_height:<br/>il quorum concorda?"}
C -- "No" --> D["Registrato in audit<br/>(draw_header_corroboration_failed);<br/>al passo successivo si richiede<br/>la stessa altezza"] --> B
C -- "Sì" --> E["seme = sha256(server_seed ‖ hash del blocco ‖ participants_hash)<br/>indice = seme mod numero partecipanti"]
E --> F["Vincitore = partecipante con draw_position = indice,<br/>salvato; poi server_seed rivelato"]
```
- **[RND-15]** L'estrazione attende che la punta della catena superi
l'altezza di riferimento, controllando ogni 5 secondi; il blocco candidato è
la punta nota in quel momento (di norma il primo blocco dopo la chiusura).
Per tutta l'attesa lo scheduler resta dentro l'estrazione e non esegue
altri passi.
- **[RND-15]** L'estrazione attende che il blocco a `draw_height` abbia
`draw_confirmations` conferme (il valore fotografato all'apertura,
[RND-05]), cioè che l'altezza della catena sia almeno
`draw_height + draw_confirmations − 1`. La condizione si controlla a ogni
passo dello scheduler, rileggendo dal database tutto ciò che serve
([RND-36]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-16]** **Blocco corroborato**: l'header ricevuto dal server Electrum
attivo è l'unica fonte di casualità, quindi un server malevolo che lo
falsificasse sceglierebbe il vincitore. Per questo, prima di usarlo, lo si fa
confermare dagli altri server
([ELC-15](connessione-electrum.md#5-il-quorum)). Se la conferma manca, si
scrive `draw_header_corroboration_failed` (altezza e hash riportato), il
blocco non viene mai usato e si attende un blocco ulteriore.
*Test:* `tests/unit/test_scheduler.py` (solo il comportamento attuale)
*Stato:* da implementare (B-73, B-81) · *Oggi:* si attende che la punta
superi l'altezza di riferimento e si usa la punta stessa, con una sola
conferma; per tutta l'attesa lo scheduler resta bloccato dentro
`_close_and_draw` e non esegue altri passi.
- **[RND-16]** **Header concordato**: raggiunte le conferme, l'header
all'altezza `draw_height` (e non la punta corrente) viene chiesto a tutti i
server configurati, attivo compreso, e lo si usa solo se il quorum concorda
sullo stesso hash, con il numero minimo di risposte fissato in
[connessione-electrum.md](connessione-electrum.md#5-il-quorum) (B-84). Se
il quorum non concorda o non risponde abbastanza, si scrive
`draw_header_corroboration_failed` (altezza e hash riportati) e al passo
successivo si richiede **la stessa altezza**: il round non passa mai a un
blocco diverso.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`, `app/electrum/listener.py:ElectrumListener.corroborate_header` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-17]** **Algoritmo**: seme = hash del blocco letto come numero
esadecimale, indice = seme modulo numero dei partecipanti, vincitore = il
partecipante in quella posizione dell'elenco ordinato per momento di invio.
**Probabilità uguale per tutti**: ogni partecipante ha una e una sola
posizione nell'elenco, indipendentemente dall'importo.
*Test:* `tests/unit/test_scheduler.py` (solo il comportamento attuale)
*Stato:* da implementare (B-73) · *Oggi:* si usa l'header della punta
ricevuto dal server attivo, chiedendo agli altri solo di confermarlo; se la
conferma manca, si attende un blocco ulteriore e si usa quello, così il
server attivo può scegliere quale blocco reale seminare tacendone uno.
- **[RND-17]** **Algoritmo**: seme = sha256 della concatenazione di
`server_seed` (32 byte), hash del blocco a `draw_height` (32 byte,
nell'ordine mostrato dagli explorer) e `participants_hash` (32 byte),
letto come intero senza segno big-endian; indice = seme modulo numero dei
partecipanti; vincitore = il partecipante con `draw_position` uguale
all'indice. **Probabilità uguale per tutti**: ogni partecipante ha una e
una sola posizione nell'elenco, indipendentemente dall'importo.
*Codice:* `app/rounds/draw.py:draw_winner`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_draw.py`
- **[RND-18]** **Verificabile**: il round salva altezza e hash del blocco
(`draw_block_height`, `draw_block_hash`), il seme (`seed_int`), il
vincitore e il montepremi (`pool_amount_sats`), passa a `paying_out`,
scrive `winner_drawn` (indirizzo del vincitore, montepremi, altezza, hash,
numero di partecipanti) e invia una notifica SSE. Chiunque, con l'hash del
blocco e l'elenco dei partecipanti, può rifare il calcolo. Il primo
tentativo di pagamento parte subito dopo.
*Test:* `tests/unit/test_draw.py` (solo `draw_winner`; nessun test lato scheduler)
*Stato:* da implementare (B-73) · *Oggi:* seme = hash del blocco letto come
numero esadecimale, applicato all'elenco ordinato per `broadcast_at`.
- **[RND-18]** **Verificabile**: in un'unica transazione del database il
round salva altezza e hash del blocco (`draw_block_height` = `draw_height`,
`draw_block_hash`), il seme (`seed_int`), il vincitore e il montepremi
(`pool_amount_sats`), passa a `paying_out` e scrive `winner_drawn`
(indirizzo del vincitore, montepremi, altezza, hash, numero di
partecipanti, `participants_hash`). Solo **dopo** quel commit `server_seed`
diventa pubblico ([RND-37]); si invia una notifica SSE. Da quel momento
chiunque, con i dati della prova pubblica e la catena, può rifare il calcolo
e ottenere lo stesso vincitore (vedi
[Come si verifica un'estrazione](#come-si-verifica-unestrazione)). Il primo
tentativo di pagamento parte subito dopo, nello stesso passo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-19]** **Altezza iniziale sconosciuta**: se alla chiusura l'altezza
della catena non era nota (valore 0), non si parte da zero (qualunque blocco
già esistente sarebbe "successivo", e quindi prevedibile): si attende il
primo header, se ne adotta l'altezza come riferimento, si scrive
`draw_baseline_tip_unknown` e si aspetta un blocco strettamente successivo.
In condizioni normali non accade, perché la connessione è resa disponibile
solo con l'altezza già nota ([ELC-07](connessione-electrum.md#3-caduta-rilevata)).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` ·
*Test:* `tests/unit/test_scheduler.py`
*Test:* — (nessun test porta `_close_and_draw` con partecipanti confermati fino a `paying_out`)
*Stato:* da implementare (B-73) · *Oggi:* il calcolo non è ripetibile da
terzi: l'ordine dei partecipanti è per `broadcast_at`, noto solo al server
e senza spareggio fra valori uguali.
- **[RND-19]** **Altezza sconosciuta alla chiusura**: se, quando il round
sarebbe pronto per `drawing`, l'altezza della catena non è nota (valore 0),
il round resta `closing` e il passaggio viene ritentato al passo
successivo, scrivendo `draw_baseline_tip_unknown` una volta per round:
`draw_height` si fissa solo su un'altezza realmente nota ([RND-33]). In
condizioni normali non accade, perché la connessione è resa disponibile solo
con l'altezza già nota ([ELC-07](connessione-electrum.md#3-caduta-rilevata)).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw`, `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` ·
*Test:* `tests/unit/test_scheduler.py` (solo il comportamento attuale)
*Stato:* da implementare (B-73) · *Oggi:* il round entra comunque in
`drawing`, attende il primo header, ne adotta l'altezza come riferimento,
scrive `draw_baseline_tip_unknown` e aspetta un blocco successivo.
- **[RND-20]** **Attesa lunga ma visibile**: l'attesa non ha un limite,
perché dipende davvero dall'arrivo di un blocco. Ogni 60 secondi se ne
registra l'avanzamento nei log; dopo 6 minuti (`_DRAW_STALL_THRESHOLD_SECONDS`
= 360s) viene scritto un evento `draw_stalled`, ripetuto ogni 6 minuti
finché l'attesa continua. Durante `drawing`, `GET /rounds/current` espone
`draw_waiting_since` (l'orario di inizio dell'attesa); negli altri stati il
campo è assente.
perché dipende davvero dall'arrivo dei blocchi. Ogni 60 secondi
(`_DRAW_PROGRESS_LOG_INTERVAL_SECONDS`) se ne registra l'avanzamento nei
log. Se per 360 secondi (`_DRAW_STALL_THRESHOLD_SECONDS`) la catena non
avanza mentre il round attende le conferme, oppure il quorum continua a non
concordare, viene scritto un evento `draw_stalled`, ripetuto ogni 360
secondi finché la situazione dura. Durante `drawing`, `GET /rounds/current`
espone `draw_waiting_since` (l'orario di inizio dell'attesa); negli altri
stati il campo è assente.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`, `app/api/routes/rounds.py:current_round` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_rounds_route.py`
*Stato:* da implementare (B-73) · *Oggi:* i 360 secondi si contano
dall'inizio dell'attesa; con 3 conferme richieste l'evento scatterebbe in
quasi ogni round.
- **[RND-36]** **Estrazione ripresa**: tutto ciò che l'estrazione usa
(`server_seed`, elenco con `draw_position`, `participants_hash`,
`draw_height`, `draw_confirmations`) è nel database dall'ingresso in
`drawing`. A ogni passo, per un round in `drawing`, lo scheduler rifà il
controllo di [RND-15] da quei valori, anche dopo un errore in un passo
precedente o un riavvio del server. La ripresa è idempotente: l'estrazione
ripresa dà sempre lo stesso vincitore, e un round già passato a
`paying_out` non viene estratto di nuovo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* —
*Stato:* da implementare (B-81) · *Oggi:* elenco dei partecipanti e altezza
di riferimento esistono solo in memoria e `_tick` salta i round in
`drawing`: dopo un errore o un riavvio il round resta in `drawing` per
sempre.
- **[RND-37]** **Prova pubblica**: `GET /rounds/{id}/proof`, senza
autenticazione, restituisce per un round: `server_seed_hash` e
`pool_address` (dall'apertura); dall'ingresso in `drawing` l'elenco dei
partecipanti (`draw_position`, txid, `vout`, indirizzo, importo netto),
`participants_hash`, `draw_height` e `draw_confirmations`; dal salvataggio
del vincitore `server_seed`, `draw_block_hash`, il seme, l'indice, il
vincitore e, quando esiste, il txid del pagamento. Un campo non ancora
disponibile è `null`. Il formato della risposta è in [api.md](api.md) (B-73).
*Codice:* `app/api/routes/rounds.py` ·
*Test:* —
*Stato:* da implementare (B-73) · *Oggi:* non esiste alcun endpoint di
prova; `GET /rounds/current` non espone partecipanti né dati
dell'estrazione, e i round chiusi non sono consultabili pubblicamente.
L'algoritmo è volutamente semplice ed è pensato per essere **sostituito**
(vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
### Come si verifica un'estrazione
Chiunque può controllare un round concluso con la sola prova pubblica e un
qualunque nodo o explorer PLM, senza fidarsi del server:
1. **Impegno.** Annotare `server_seed_hash` mentre il round è aperto (è
pubblico dall'apertura). Dopo la rivelazione, verificare che
`sha256(server_seed)` sia uguale a quel valore: il segreto usato è quello
fissato all'apertura.
2. **Partecipanti.** Per ogni riga dell'elenco verificare sulla catena che la
transazione `txid` esista, sia confermata, abbia l'uscita `vout` verso
`pool_address` con l'importo indicato e sia firmata dall'indirizzo
indicato. Poiché l'indirizzo del montepremi è del solo round ([RND-35]),
la sua cronologia mostra anche che nessuna scommessa confermata prima
della chiusura manca dall'elenco.
3. **Ordine.** Verificare che le righe siano in ordine crescente di txid e
che `draw_position` vada da 0 a n−1 senza salti.
4. **Impronta dei partecipanti.** Ricostruire le righe `txid:vout:indirizzo`
(ognuna terminata da `\n`), calcolarne lo sha256 e confrontarlo con
`participants_hash`. Chi ha osservato la prova durante `drawing` sa anche
che impronta e `draw_height` erano pubblici prima che il blocco esistesse.
5. **Blocco.** Verificare che `draw_block_hash` sia l'hash del blocco
all'altezza `draw_height` della catena principale.
6. **Calcolo.** Calcolare sha256 della concatenazione dei 32 byte di
`server_seed`, dei 32 byte dell'hash del blocco e dei 32 byte di
`participants_hash`; leggerlo come intero big-endian; l'indice è quel
numero modulo n. Il vincitore dichiarato deve essere la riga con quella
`draw_position`.
7. **Pagamento.** Verificare che la transazione di pagamento spenda le
scommesse del round e paghi l'indirizzo del vincitore
([RND-21], [RND-23]).
Il controllo non esclude un operatore che sia anche minatore: conoscendo il
segreto potrebbe scegliere quale blocco pubblicare a `draw_height`. È un
rischio accettato, descritto in
[decisioni-e-limiti.md](decisioni-e-limiti.md#round-ed-estrazione) (B-73).
L'algoritmo resta un componente **sostituibile** (vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
## 4 · Pagamento
Con il vincitore noto il round è in `paying_out` e si costruisce il
pagamento, firmato con la chiave del montepremi.
Con il vincitore noto il round è in `paying_out`: si verificano le monete del
round, si costruisce il pagamento e lo si firma con la chiave dell'indirizzo
del montepremi del round.
- **[RND-21]** **Ripartizione**: il vincitore riceve il 70% del montepremi
arrotondato per difetto, l'indirizzo delle commissioni il resto (30% più
l'eventuale resto dell'arrotondamento). La ripartizione è fissata nel
codice, non modificabile da `/admin`.
verificato ([RND-39]) arrotondato per difetto, meno la commissione di rete
([RND-23]); l'indirizzo delle commissioni riceve il resto (30% più
l'eventuale resto dell'arrotondamento) meno la riserva per l'RBF
([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`
- **[RND-22]** Il pagamento spende le monete **confermate** dell'indirizzo del
montepremi, lette dal server Electrum attivo ed escluse quelle già
impegnate da un altro pagamento non risolto ([RND-26]), scelte dalla più
grande fino a coprire il montepremi e al massimo 500
(`MAX_PAYOUT_TX_INPUTS`). Non abbastanza monete dà il motivo
`insufficient pool UTXOs`, troppe `too_many_inputs`.
*Stato:* da implementare (B-79, B-82) · *Oggi:* la ripartizione si calcola
su `pool_amount_sats` fissato all'estrazione e l'indirizzo delle
commissioni riceve l'intera quota del 30%.
- **[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
vengono toccate. Sono escluse quelle già impegnate da un altro pagamento
non risolto ([RND-26]); il limite è di 500 ingressi
(`MAX_PAYOUT_TX_INPUTS`), superiore al massimo di partecipanti per round
([BET-06](scommesse-e-prelievi.md#quando-è-accettata)). Troppi ingressi dà
il motivo `too_many_inputs`. Per i round aperti prima dell'indirizzo per
round si spendono le monete confermate dell'indirizzo condiviso, scelte
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`
- **[RND-23]** La transazione ha un'uscita verso l'indirizzo del vincitore, una
verso l'indirizzo delle commissioni e, se supera 294 sat, una di resto verso
il montepremi (un resto inferiore va alla commissione di rete). La
*Stato:* da implementare (B-80, B-82) · *Oggi:* spende le monete confermate
dell'indirizzo condiviso lette dal solo server attivo, scelte dalla più
grande fino a coprire il montepremi, qualunque sia la loro provenienza.
- **[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
superano quanto serve (solo per i round aperti prima dell'indirizzo per
round), il resto va all'indirizzo di riserva se è di almeno 294 sat
(`DUST_LIMIT_SATS`), altrimenti va alla commissione di rete. La
**commissione di rete** (stimata su tre uscite, a `fee_rate_sat_vb`) è
pagata dalla quota del vincitore, così il 30% resta intatto. Se la quota del
vincitore meno la commissione, o la quota delle commissioni, scende sotto
294 sat, il pagamento non si costruisce (`winner_share_below_network_fee`,
pagata dalla quota del vincitore, così la quota delle commissioni non la
paga. Se la quota del vincitore meno la commissione di rete, o la quota
delle commissioni meno la riserva, è inferiore a 294 sat, il pagamento non
si costruisce (`winner_share_below_network_fee`,
`commission_below_dust_limit`).
*Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_payout_builder.py`
*Test:* `tests/unit/test_payout_builder.py` (nessun test copre `commission_below_dust_limit`)
*Stato:* da implementare (B-79, B-95) · *Oggi:* la terza uscita è un resto
verso l'indirizzo del montepremi, presente solo se è di almeno 294 sat e
assente quando le monete spese sono esattamente le scommesse del round; non c'è riserva, la soglia di 294 sat si applica all'intera
quota delle commissioni e `fee_address` è letto dalla configurazione.
- **[RND-24]** **Prima si scrive, poi si invia**: prima dell'invio il round
registra importo del vincitore (`winner_amount_sats`), commissioni
(`fee_amount_sats`) e txid (`payout_txid`), e si salva una transazione in
(`fee_amount_sats`), riserva per l'RBF (`bump_reserve_sats`, [RND-38]) e
txid (`payout_txid`), e si salva una transazione in
corso di tipo `payout` in stato `building`. Se l'invio riesce, la
transazione passa a `pending` e si scrive `payout_sent`. Se fallisce, la
riga resta `building` e si scrive `payout_failed`: sarà il riconciliatore a
constatare che la rete non la conosce, abbandonarla e liberare il round per
un nuovo tentativo ([TX-18](transazioni-e-riconciliazione.md#pagamenti-del-round-riprovare-senza-pagare-due-volte)).
transazione passa a `pending` e si scrive `payout_sent`. Se non riesce, si
scrive `payout_failed` e la riga resta `building`: la risolve il
riconciliatore, secondo le regole di invio e di abbandono di
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#pagamenti-del-round-riprovare-senza-pagare-due-volte)
(TX-18, B-76, B-78); solo un abbandono libera il round per un nuovo
tentativo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-25]** Ogni tentativo che non arriva all'invio riuscito scrive un
evento `payout_failed` con il motivo: `electrum client not connected`,
*Stato:* da implementare (B-79) · *Oggi:* tutto come descritto, tranne
`bump_reserve_sats`, che non esiste.
- **[RND-25]** Ogni tentativo che si ferma prima di un invio riuscito scrive
un evento `payout_failed` con il motivo: `electrum client not connected`,
`no fee_address configured`, `winner user not found`,
`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`. A ogni passo lo scheduler riesamina un
round in `paying_out` e riprova **al massimo una volta ogni 60 secondi**
(`_PAYOUT_RETRY_INTERVAL_SECONDS`), misurati dall'ultimo `payout_failed` di
quel round; senza alcun `payout_failed` (ad esempio dopo un riavvio) riprova
subito.
di costruzione, `broadcast rejected`. Fanno eccezione due casi, che non
scrivono `payout_failed`: un pagamento già in volo per il round ([RND-26]),
che termina il tentativo senza scrivere nulla, e un pagamento bloccato
([RND-39]), che scrive `payout_blocked`. A ogni passo lo scheduler
riesamina un round in `paying_out` e riprova **al massimo una volta ogni 60
secondi** (`_PAYOUT_RETRY_INTERVAL_SECONDS`), misurati dall'ultimo
`payout_failed` di quel round; senza alcun `payout_failed` (ad esempio dopo
un riavvio, o mentre un pagamento è in volo) riprova al passo successivo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`, `app/rounds/scheduler.py:RoundScheduler._log_payout_failure` ·
*Test:* `tests/unit/test_scheduler.py`
*Test:* `tests/unit/test_scheduler.py` (verificati solo i motivi `electrum client not connected` e `no fee_address configured`; per l'invio fallito solo la presenza di `payout_failed`)
- **[RND-26]** Due protezioni impediscono di pagare due volte: non si
costruisce un nuovo pagamento se per il round ne esiste già uno `building` o
`pending` (il tentativo termina senza fare nulla), e le monete del
montepremi impegnate da un qualunque pagamento non risolto, anche di un
`pending` (il tentativo termina senza fare nulla e senza scrivere eventi),
e le monete impegnate da un qualunque pagamento non risolto, anche di un
round precedente, sono escluse dalla selezione.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
*Test:* `tests/unit/test_scheduler.py`
- **[RND-27]** Se il pagamento non conferma in tempo si applica l'RBF, pagato
dal resto del montepremi ([TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)).
riducendo l'uscita di riserva ([RND-38]); le uscite del vincitore e delle
commissioni non cambiano
([TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)).
*Codice:* `app/tx/broadcast.py:bump_fee`
*Stato:* da implementare (B-79) · *Oggi:* l'RBF può ridurre solo il resto
verso il montepremi, che non esiste quando le monete spese sono
esattamente le scommesse del round: un pagamento bloccato nel
mempool non si può accelerare, e poiché il server lo conosce non viene
nemmeno abbandonato, quindi il round resta in `paying_out` e nessun nuovo
round si apre.
- **[RND-28]** Quando il pagamento è confermato il round passa a `closed` e
`closed_at` viene riscritto con l'orario della conferma: da lì parte la
pausa prima del round successivo. Il vincitore si vede accreditare la
@@ -298,18 +541,77 @@ pagamento, firmato con la chiave del montepremi.
([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito)).
*Codice:* `app/rounds/confirmation.py:_on_payout_confirmed` ·
*Test:* `tests/unit/test_confirmation.py`
- **[RND-38]** **Riserva per l'RBF**: ogni pagamento ha un'uscita di riserva
verso l'indirizzo di riserva `m/84'/746'/0'/1/0` di valore
`max(DUST_LIMIT_SATS, estimate_vsize(ingressi, 3) × 10 sat/vB)`, dove 10
sat/vB è la costante `PAYOUT_BUMP_RESERVE_SAT_VB`. La riserva è tolta
dalla quota delle commissioni, mai da quella del vincitore; è ciò che
l'RBF riduce ([RND-27]).
*Codice:* `app/wallet/psbt_builder.py:build_payout_transaction`, `app/wallet/hd.py:derive_pool_key` ·
*Test:* —
*Stato:* da implementare (B-79) · *Oggi:* non esiste alcuna riserva.
- **[RND-39]** **Verifica prima del pagamento**: prima di costruire il
pagamento, le uscite delle scommesse dei partecipanti vengono verificate
all'indirizzo del montepremi del round con il quorum dei server
([connessione-electrum.md](connessione-electrum.md#5-il-quorum) (B-82)).
- Un partecipante la cui uscita il quorum dice **assente** (la scommessa è
sparita dalla catena, ad esempio per una riorganizzazione) passa a
`reorged_out` ([modello-dati.md](modello-dati.md) (B-82)) ed è escluso
dal montepremi; `pool_amount_sats` viene riscritto con la somma delle
uscite verificate e la ripartizione ([RND-21]) si calcola su quella.
- Se è assente la scommessa **del vincitore**, il pagamento non si
costruisce: si scrive `payout_blocked`, si annota il blocco sul round,
la lotteria va in pausa automatica ([RND-04]) e il round resta in
`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).
- 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.
- **[RND-40]** **Configurazione pagabile**: `rounds_can_open` rifiuta una
configurazione in cui un round con un solo partecipante non si potrebbe
pagare. Con `netto` = `bet_amount_sats − estimate_vsize(MAX_TX_INPUTS, 2) ×
fee_rate_sat_vb` (la scommessa più piccola che può arrivare al montepremi)
devono valere entrambe:
- `winner_share(netto) − estimate_vsize(1, 3) × fee_rate_sat_vb ≥
DUST_LIMIT_SATS`;
- `netto − winner_share(netto) − riserva ≥ DUST_LIMIT_SATS`, con la
riserva di [RND-38] calcolata su un ingresso.
Si usano i valori della configurazione al momento dell'apertura.
*Codice:* `app/rounds/service.py:rounds_can_open` ·
*Test:* —
*Stato:* da implementare (B-79) · *Oggi:* `rounds_can_open` controlla solo
che `fee_address` non sia vuoto.
## Tempi reali
Tra lo scadere del timer e l'arrivo della vincita ci sono **tre conferme
distinte**, ognuna in un blocco diverso:
Tra lo scadere del timer e l'arrivo della vincita ci sono **tre attese di
conferma distinte**:
1. l'ultima scommessa in volo (può essere già avvenuta prima della scadenza);
2. il blocco dell'estrazione, necessariamente successivo al punto 1;
3. il pagamento, costruito solo dopo il punto 2.
1. l'ultima scommessa in volo (può essere già confermata prima della
scadenza);
2. il blocco dell'estrazione a `draw_height`, il primo dopo il punto 1, più
i blocchi successivi fino a `draw_confirmations` conferme (3 predefinite,
quindi 3 blocchi);
3. il pagamento, costruito solo dopo il punto 2, che richiede un altro blocco.
Con blocchi da 120 secondi sono circa **2–4 minuti** nel caso migliore e
**4–6** nel peggiore, indipendentemente da `draw_animation_seconds`.
I blocchi PLM arrivano in media ogni 120 secondi, ma l'intervallo fra due
blocchi non ha un limite superiore: non esiste un "caso peggiore". A valori
medi, dalla conferma dell'ultima scommessa servono 3 blocchi per l'estrazione
e 1 per il pagamento, cioè 4 blocchi (8 minuti in media), indipendentemente
da `draw_animation_seconds`. I depositi richiedono a parte più conferme prima
di poter essere giocati
([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito) (B-82)).
## Cosa vede il giocatore
@@ -336,9 +638,7 @@ Due livelli indipendenti:
## Limite noto
- **[RND-32]** Se il server si riavvia mentre un round è in `drawing`,
l'attesa del blocco **non riprende da sola**: lo scheduler riprende i round
in `closing` e `paying_out`, ma su un round in `drawing` non fa nulla. È
l'ultimo prerequisito mancante per un funzionamento completamente non
presidiato (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`
- ~~[RND-32]~~ Assorbito da [RND-36]: nel comportamento atteso un round in
`drawing` riprende da solo dopo un riavvio o un errore. Finché B-81 non è
implementato il limite resta: lo scheduler riprende i round in `closing` e
`paying_out`, ma su un round in `drawing` non fa nulla.
+52 -11
View File
@@ -18,12 +18,22 @@ sezione rimanda al documento che la approfondisce.
| [operativita.md](operativita.md) | Backup, riavvii, procedure per gli incidenti |
| [decisioni-e-limiti.md](decisioni-e-limiti.md) | Decisioni di progetto con le loro motivazioni, limiti noti accettati |
| [storico-bug.md](storico-bug.md) | Tutti i bug B-nn trovati e corretti, con commit e test |
| [piano-correzioni.md](piano-correzioni.md) | Decisioni della revisione del 2026-09-28 e ordine in cui implementare le correzioni ancora aperte |
Questi documenti sono la **specifica** del software: ogni regola ha un
identificativo stabile (ad esempio `RND-11`) con il riferimento al codice che
la implementa. Come si usano per modificare il software è spiegato in
[processo-di-modifica.md](processo-di-modifica.md).
Dalla revisione del 2026-09-28 la specifica descrive il comportamento
**atteso**, che in alcuni punti è più avanti del codice. I requisiti che il
codice non soddisfa ancora sono marcati `*Stato:* da implementare (B-nn)` e
dicono in una riga cosa fa il codice oggi (`git grep "da implementare"` li
elenca tutti); anche questa panoramica descrive il comportamento atteso. Le
decisioni prese e l'ordine di implementazione sono in
[piano-correzioni.md](piano-correzioni.md), il dettaglio tecnico di ogni voce
in [BUGS.md](../BUGS.md).
Per installazione e avvio vedi [setup.md](setup.md) e
[running-the-server.md](running-the-server.md); per l'uso delle interfacce
[guida-utente.md](guida-utente.md) e [guida-admin.md](guida-admin.md).
@@ -32,8 +42,10 @@ Per installazione e avvio vedi [setup.md](setup.md) e
Gli utenti depositano PLM su un indirizzo personale custodito dal server,
comprano un biglietto a costo fisso per il round in corso, e allo scadere del
round l'hash del primo blocco successivo sceglie un vincitore, che riceve il
70% del montepremi; il 30% va alla piattaforma.
round un seme che combina un segreto su cui il server si è impegnato
all'apertura, l'hash di un blocco successivo alla chiusura e l'elenco dei
partecipanti sceglie un vincitore, che riceve il 70% del montepremi; il 30%
va alla piattaforma.
## Scelte di fondo
@@ -43,14 +55,30 @@ round l'hash del primo blocco successivo sceglie un vincitore, che riceve il
depositi, riceve le vincite e riceve il resto di ogni scommessa e prelievo.
- **Nessun nodo completo.** Tutto l'accesso alla rete passa dal protocollo
Electrum (vedi [connessione-electrum.md](connessione-electrum.md)).
- **1 conferma** per ogni tipo di transazione: depositi, scommesse, pagamenti,
prelievi.
- **6 conferme per i depositi, 1 per tutto il resto.** Il deposito è l'unico
ingresso che un utente può spendere due volte, perché scommesse, pagamenti
e prelievi li firma solo il server; le uscite coinbase richiedono 120
conferme (vedi [registrazione-e-depositi.md](registrazione-e-depositi.md#deposito)).
- **Estrazione con commit-reveal.** All'apertura il server si impegna su un
segreto pubblicandone l'impronta; alla chiusura si fissano elenco dei
partecipanti e altezza del blocco; il segreto viene rivelato dopo
l'estrazione. Chi produce i blocchi non può calcolare il vincitore e
l'operatore non può cambiare il segreto; chiunque può verificare il
risultato con la prova pubblica (vedi
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione)).
- **Un indirizzo del montepremi per ogni round**, così soldi e cronologia di
un round restano separati da quelli degli altri.
- **Nel dubbio non si liberano i fondi.** Una transazione viene annullata solo
con una prova, mai per un timeout o per il silenzio di un solo server (vedi
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md)).
- **Parametri operativi nel database**, modificabili a caldo da `/admin`
(costo della scommessa, durata del round, commissioni…). Nel file `.env`
restano solo segreti e collegamenti infrastrutturali.
## Le cinque fasi
Diagramma del **comportamento atteso**:
```mermaid
flowchart LR
subgraph REG["1 · Registrazione"]
@@ -60,7 +88,7 @@ flowchart LR
subgraph DEP["2 · Deposito"]
direction TB
B1["L'utente invia PLM<br/>al proprio indirizzo"] --> B2{"1 conferma e<br/>corroborato dagli<br/>altri server?"}
B1["L'utente invia PLM<br/>al proprio indirizzo"] --> B2{"6 conferme e<br/>corroborato dagli<br/>altri server?"}
B2 -- "No" --> B1b["Si riprova al prossimo<br/>aggiornamento"] --> B2
B2 -- "Sì" --> B3["Saldo accreditato"]
end
@@ -68,7 +96,7 @@ flowchart LR
subgraph PLAY["3 · Scommessa"]
direction TB
C1{"Saldo sufficiente,<br/>nessuna scommessa attiva,<br/>round aperto e non pieno?"}
C1 -- "Sì" --> C2["Transazione: indirizzo utente →<br/>montepremi, resto all'utente"]
C1 -- "Sì" --> C2["Transazione: indirizzo utente →<br/>montepremi del round, resto all'utente"]
C2 --> C3{"Confermata?"}
C3 -- "No, troppo tempo" --> C4["Commissione aumentata<br/>(RBF) e reinvio"] --> C3
C3 -- "Sì" --> C5["Partecipante del round"]
@@ -76,9 +104,14 @@ flowchart LR
subgraph DRAW["4 · Round ed estrazione"]
direction TB
D1["Scadenza del round"] --> D2["Si attendono le scommesse<br/>già in volo"]
D2 --> D3["Il primo blocco successivo<br/>sceglie il vincitore"]
D3 --> D4["70% al vincitore,<br/>30% alla piattaforma"]
D0["Apertura: indirizzo del montepremi<br/>del round, impronta del segreto pubblicata"] --> D1["Scadenza del round"]
D1 --> D2["Si attendono le scommesse<br/>già in volo"]
D2 --> D3["Si fissano partecipanti<br/>e altezza del blocco"]
D3 --> D3b["Blocco con 3 conferme, header<br/>concordato dal quorum:<br/>segreto + blocco + partecipanti<br/>scelgono il vincitore"]
D3b --> D3c["Segreto rivelato,<br/>prova pubblica"]
D3c --> D3d{"Scommesse ancora<br/>sulla catena?"}
D3d -- "Sì (quelle sparite<br/>sono escluse)" --> D4["70% al vincitore, 30% alla piattaforma<br/>meno la riserva per l'RBF"]
D3d -- "Sparita quella<br/>del vincitore" --> D5["Pagamento bloccato,<br/>lotteria in pausa"]
end
subgraph WITHDRAW["5 · Prelievo"]
@@ -139,7 +172,7 @@ solo polling.
| Parametro | Valore predefinito | Modificabile |
|---|---|---|
| Costo della scommessa | 10 PLM | da `/admin` |
| Costo della scommessa | 10 PLM | da `/admin`, vale dal round successivo (B-95) |
| Ripartizione del montepremi | 70% vincitore / 30% piattaforma | no, è nel codice |
| Durata del round | 600s | da `/admin`, vale dal round successivo |
| Pausa tra un round e l'altro | 30s | da `/admin`, vale dal round successivo |
@@ -148,4 +181,12 @@ solo polling.
| Prelievo minimo | pari al costo della scommessa | segue il costo |
| Deposito minimo | nessuno | — |
| Partecipanti massimi per round | 400 | no, è nel codice |
| Conferme richieste | 1 | no, è nel codice |
| Tetto al montepremi del round (`max_round_pool_sats`) | 0 = nessun tetto (B-95) | da `/admin`, vale dal round successivo |
| Conferme per i depositi (`deposit_confirmations`) | 6; 120 per le uscite coinbase (B-82) | da `/admin`; le 120 coinbase no, sono nel codice |
| Conferme sul blocco di estrazione (`draw_confirmations`) | 3 (B-73) | da `/admin`, vale dal round successivo |
| Conferme per scommesse, pagamenti, prelievi | 1 | no, è nel codice |
| Indirizzo del montepremi | uno per round, `m/84'/746'/0'/2/<id_round>` (B-80) | no, è nel codice |
| Riserva per l'RBF sul pagamento | `max(294 sat, dimensione stimata × 10 sat/vB)`, tolta dalla quota della piattaforma, verso `m/84'/746'/0'/1/0` (B-79) | no, è nel codice |
Un B-nn nella tabella indica un parametro del comportamento atteso non ancora
implementato (vedi [piano-correzioni.md](piano-correzioni.md)).
+267 -60
View File
@@ -11,6 +11,9 @@ I requisiti usano il prefisso `ELC`, secondo la convenzione di
[processo-di-modifica.md](processo-di-modifica.md). La configurazione
(`.env`) è descritta in [setup.md](setup.md), i rischi accettati in
[sicurezza.md](sicurezza.md) e [decisioni-e-limiti.md](decisioni-e-limiti.md).
Questo documento descrive il comportamento **atteso** dopo la revisione del
2026-09-28; i requisiti che il codice non soddisfa ancora sono marcati
*da implementare* (vedi [piano-correzioni.md](piano-correzioni.md)).
Le difese sono cinque.
@@ -18,16 +21,21 @@ Le difese sono cinque.
`ELECTRUM_HOST`/`ELECTRUM_PORT` è il server principale;
`ELECTRUM_FALLBACK_SERVERS` è un elenco di riserva nel formato
`host:porta[:notls]`, separato da virgole.
`host:porta[:<modo>]`, separato da virgole.
- **[ELC-01]** L'elenco dei server è il principale (con TLS secondo
`ELECTRUM_USE_SSL`) seguito da quelli di riserva. Ogni voce di riserva è
`host:porta` (TLS) oppure `host:porta:<modo>`, con `<modo>` tra `ssl`/`tls`
(TLS) e `notls`/`plain` (in chiaro); le voci vuote sono ignorate e i
duplicati eliminati mantenendo la prima posizione. Una voce malformata
blocca l'avvio subito, non durante il guasto in cui servirebbe.
`host:porta` (TLS), `host:porta:<modo>` con `<modo>` tra `ssl`/`tls` (TLS) e
`notls`/`plain` (in chiaro), oppure `host:porta:pin=<sha256>` (TLS con
certificato fissato, [ELC-03]); `<sha256>` sono 64 cifre esadecimali. Le
voci vuote sono ignorate e i duplicati eliminati mantenendo la prima
posizione. Una voce malformata blocca l'avvio subito, non durante il guasto
in cui servirebbe.
*Codice:* `app/electrum/client.py:parse_endpoints` ·
*Test:* `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-84), per la forma `pin=` · *Oggi:* il terzo
campo accetta solo `ssl`/`tls`/`notls`/`plain`; `pin=…` è una voce
malformata.
- **[ELC-02]** Dopo ogni sessione fallita o caduta si prova **subito** il
server successivo nell'elenco, a rotazione; l'attesa crescente (da 1s
raddoppiando fino a 30s) scatta solo dopo che ogni server ha fallito una
@@ -35,12 +43,29 @@ Le difese sono cinque.
alcun server configurato il listener si ferma con un errore nei log.
*Codice:* `app/electrum/listener.py:ElectrumListener.run` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[ELC-03]** Con TLS la connessione è cifrata ma il certificato del server
**non viene verificato** (i server Electrum usano spesso certificati
autofirmati): la fiducia non viene dal TLS ma dal quorum
([§ 5](#5-il-quorum)). Ogni connessione si apre con la stretta di mano
`server.version`.
*Codice:* `app/electrum/client.py:ElectrumClient.connect`
- **[ELC-03]** Con TLS la connessione è cifrata. Il certificato non è
verificato contro un'autorità (i server Electrum usano spesso certificati
autofirmati), ma può essere **fissato**: per una voce con `pin=<sha256>`
([ELC-01]), e per il server principale con `ELECTRUM_CERT_PIN`, lo SHA-256
del certificato del server in formato DER deve coincidere con il valore
configurato, altrimenti la connessione fallisce come un server
irraggiungibile e si passa al successivo ([ELC-02]). Senza pin la fiducia
non viene dal TLS ma dal quorum ([§ 5](#5-il-quorum)). Ogni connessione si
apre con la stretta di mano `server.version`.
*Codice:* `app/electrum/client.py:ElectrumClient.connect` · *Test:* —
*Stato:* da implementare (B-84) · *Oggi:* nessun certificato viene mai
verificato; non esiste un modo di fissarlo.
- **[ELC-19]** Il server **rifiuta di avviarsi** se, dopo l'eliminazione dei
duplicati, l'elenco contiene meno di **2 server di riserva** oltre al
principale: con meno di tre server il quorum di [ELC-14] non può mai avere
due risposte indipendenti da quella del server attivo. L'unica eccezione è
`ALLOW_NO_QUORUM=true`, **solo per lo sviluppo**: il server parte, scrive un
avviso nei log a ogni avvio, e le corroborazioni seguono la regola ridotta
di [ELC-14].
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` · *Test:* —
*Stato:* da implementare (B-84) · *Oggi:* un `ELECTRUM_FALLBACK_SERVERS`
vuoto (il valore di `.env.example`) è accettato in silenzio e disattiva
ogni corroborazione.
## 2. Richieste con limite di tempo
@@ -53,6 +78,19 @@ Le difese sono cinque.
conferme.
*Codice:* `app/electrum/client.py:ElectrumClient.request`, `app/electrum/client.py:ElectrumClient._read_loop` ·
*Test:* `tests/unit/test_electrum_client.py`
- **[ELC-20]** Una riga di risposta può essere lunga fino a **16 MiB**
(16 777 216 byte). Una riga più lunga, o una riga che non è JSON valido,
è un errore **della richiesta**, non della connessione: la riga viene
scartata fino al suo a capo e la connessione resta aperta. La richiesta a
cui rispondeva fallisce subito con un errore se il suo `id` si legge nei
primi o negli ultimi 256 byte della riga; altrimenti fallisce allo scadere
di [ELC-04].
*Codice:* `app/electrum/client.py:ElectrumClient.connect`, `app/electrum/client.py:ElectrumClient._read_loop` · *Test:* —
*Stato:* da implementare (B-75) · *Oggi:* il lettore ha il limite
predefinito di asyncio (64 KiB); una riga più lunga, o non JSON, interrompe
`_read_loop` e chiude l'unica connessione. La cronologia dell'indirizzo del
montepremi supera 64 KiB dopo 800 transazioni (stima di B-75), e da lì ogni controllo
delle conferme la fa cadere.
## 3. Caduta rilevata
@@ -61,12 +99,18 @@ Le difese sono cinque.
*Codice:* `app/electrum/listener.py:ElectrumListener._keepalive`
- **[ELC-06]** La chiusura della connessione è un evento osservato: la
sessione termina non appena la connessione si chiude, il ping fallisce o
l'elaborazione degli header o delle notifiche degli indirizzi si interrompe
con un errore. Il listener non resta quindi in attesa di notifiche che non
arriveranno mai mentre sembra ancora connesso; a sessione finita il client
non è più disponibile.
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once`, `app/electrum/client.py:ElectrumClient.wait_closed` ·
l'elaborazione degli header si interrompe con un errore. Il listener non
resta quindi in attesa di notifiche che non arriveranno mai mentre sembra
ancora connesso; a sessione finita il client non è più disponibile. Un
errore nell'aggiornamento di **un** utente dopo una notifica del suo
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.
- **[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
@@ -76,81 +120,229 @@ Le difese sono cinque.
scommesse e prelievi rispondono `network_unavailable`.
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[ELC-21]** **Server fermo.** Un server può restare connesso e rispondere
ai ping senza più seguire la catena. A ogni ping di [ELC-05], se l'ultimo
header accettato ha più di **360 secondi**, il listener chiede la punta
(`blockchain.headers.subscribe`) a ciascuno degli altri server configurati,
con i tempi di [ELC-14]; se almeno uno riporta un'altezza **maggiore** di
quella nota, la sessione termina e si passa al server successivo
nell'elenco ([ELC-02]). Se nessuno riporta un'altezza maggiore (la rete
non ha prodotto blocchi) la sessione continua.
*Codice:* `app/electrum/listener.py:ElectrumListener._keepalive` · *Test:* —
*Stato:* da implementare (B-86) · *Oggi:* nulla confronta l'avanzamento
della punta con gli altri server; un server fermo che risponde ai ping
resta il server attivo indefinitamente.
## 4. Header validati, non creduti
Ogni nuovo header di blocco — quello ricevuto all'iscrizione e ogni successivo
annunciato dal server — viene controllato prima di essere accettato.
annunciato dal server — viene controllato prima di essere accettato. Il
listener tiene gli **ultimi 10 header** accettati ([ELC-22]): è ciò che gli
permette di riconoscere una riorganizzazione e seguirla invece di scambiarla
per un server ostile.
```mermaid
flowchart TB
A["Header annunciato<br/>all'altezza H"] --> B{"Ha il contenuto<br/>binario?"}
B -- "No" --> I1["Ignorato (ELC-10)"]
B -- "Sì" --> C{"H rispetto alla<br/>punta T"}
C -- "H < T" --> I2["Ignorato (ELC-08)"]
C -- "H = T, stesso hash" --> I3["Ignorato (ELC-09)"]
C -- "H = T, hash diverso" --> F
C -- "H > T" --> D["Si scaricano gli header<br/>T+1 … H−1 (ELC-12)"]
D --> E{"80 byte, bits non più<br/>facili del powLimit, hash<br/>entro il bersaglio? (ELC-11)"}
E -- "No" --> X["HeaderValidationError:<br/>sessione chiusa,<br/>server successivo"]
E -- "Sì" --> G{"T+1 si aggancia<br/>alla punta T?"}
G -- "Sì, e la catena<br/>è collegata fino a H" --> OK["Punta = H<br/>finestra aggiornata (ELC-13)"]
G -- "No" --> F["Ricerca del punto di<br/>biforcazione negli ultimi<br/>10 header (ELC-23)"]
F -- "Trovato" --> R["Si segue il nuovo ramo<br/>chain_reorg_detected;<br/>pausa se profondità ≥ 2 (ELC-24)"]
F -- "Non trovato" --> X
```
*Comportamento atteso.* Oggi un salto di più blocchi non viene collegato, la
difficoltà non è confrontata con il powLimit, e un header che non si aggancia
chiude la sessione.
Tre casi vengono semplicemente **ignorati**, con un avviso nei log e senza
chiudere la sessione, perché non indicano un server ostile:
- **[ELC-08]** un header per un'altezza **inferiore** a quella nota: l'altezza
non torna mai indietro;
- **[ELC-09]** un header per l'altezza **già nota** (lo stesso blocco
riannunciato, una riorganizzazione in punta, o un server in disaccordo):
l'hash già adottato per quell'altezza non viene mai sostituito; se fosse
quello sbagliato, è la corroborazione dell'estrazione a scartarlo;
non torna mai indietro. Un server rimasto indietro non sposta la punta, e un
ramo più corto del nostro non viene seguito. Il rovescio va detto: se un
header falso a un'altezza molto alta venisse accettato, nessun header
onesto (più basso) lo sostituirebbe più; la punta resta bloccata fino al
riavvio del processo (l'altezza è solo in memoria). Con [ELC-11] e
[ELC-12] quell'header deve portarsi dietro una catena collegata di header
con lavoro valido; e comunque non sceglie il vincitore, perché il blocco di
estrazione è letto a un'altezza fissa e confermato dal quorum ([ELC-15]):
l'estrazione attende, e l'attesa è visibile (`draw_stalled`,
`GET /health/ready`, [ELC-29]).
- **[ELC-09]** un header per l'altezza **già nota** con lo **stesso** hash
(lo stesso blocco riannunciato). Un header per l'altezza già nota con un
hash **diverso** non è ignorato: è una riorganizzazione in punta e si
segue secondo [ELC-23].
*Stato:* da implementare (B-85), per il caso con hash diverso · *Oggi:*
anche un hash diverso è ignorato e quello già adottato non viene mai
sostituito.
- **[ELC-10]** un header privo del contenuto binario: non c'è nulla da
validare né da usare per l'estrazione.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` ·
*Test:* `tests/unit/test_electrum_listener.py`
Due controlli invece, se falliscono, **chiudono la sessione** e fanno passare
a un altro server: quell'header è l'unica fonte di casualità dell'estrazione,
e uno falsificato sceglierebbe il vincitore.
I controlli seguenti invece, se falliscono, **chiudono la sessione** e fanno
passare a un altro server.
- **[ELC-11]** L'header deve essere lungo 80 byte e il suo hash deve
rispettare la difficoltà che l'header stesso dichiara (campo `bits`).
- **[ELC-11]** L'header deve essere lungo 80 byte; il bersaglio che dichiara
(campo `bits`) non deve essere più facile del **powLimit** di PLM mainnet,
`00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff`
(in forma compatta `0x1d00ffff`, lo stesso di Bitcoin; fonte:
`palladiumcore/src/chainparams.cpp`, `consensus.powLimit` della rete
principale); e il suo hash deve rispettare quel bersaglio. Il controllo
toglie la contraffazione **gratuita**, non quella economica: anche al
powLimit un header costa in media 2³² hash, 43 µs per un ASIC da
100 TH/s. Ciò che protegge davvero l'estrazione è il quorum sul blocco
([ELC-15]).
*Codice:* `app/rounds/draw.py:header_meets_its_own_target`, `app/electrum/listener.py:ElectrumListener._apply_header` ·
*Test:* `tests/unit/test_draw.py`, `tests/unit/test_electrum_listener.py`
- **[ELC-12]** Un avanzamento di **un solo** blocco deve agganciarsi all'hash
del blocco corrente. Per un salto di più blocchi questo controllo non è
possibile senza la catena intera, e vale solo quello di difficoltà.
*Codice:* `app/rounds/draw.py:header_prev_hash`, `app/electrum/listener.py:ElectrumListener._apply_header` ·
*Stato:* da implementare (B-74), per il confronto con il powLimit ·
*Oggi:* si verifica solo che l'hash rispetti il bersaglio che l'header
stesso dichiara. Poiché è chi fabbrica l'header a scegliere `bits`, il
controllo non respinge un header mai minato: con `bits = 0x207fffff`
(bersaglio `0x7fffff · 2²³²`, appena sotto 2²⁵⁵) lo passa un header casuale con probabilità 1/2.
- **[ELC-12]** **Ogni** avanzamento della punta deve essere collegato alla
punta corrente. Un avanzamento di un blocco deve avere come `prev_hash`
l'hash della punta. Per un avanzamento di più blocchi, dalla punta `T` a
`H`, si scaricano dal server attivo gli header `T+1 … H−1`
(`blockchain.block.headers`, a blocchi di al più 2016 header per
richiesta); ciascuno deve superare [ELC-11] e agganciarsi al precedente, e
`H` all'ultimo. Un header scaricato che non supera [ELC-11], o una catena
scaricata spezzata, chiude la sessione. Se invece è il **primo** passo
(`T+1`, o `H` stesso per un avanzamento di un blocco) a non agganciarsi
alla punta, non si chiude la sessione: è una possibile riorganizzazione e
si segue [ELC-23].
*Codice:* `app/rounds/draw.py:header_prev_hash`, `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/client.py:ElectrumClient.request` ·
*Test:* `tests/unit/test_draw.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-74) · *Oggi:* il collegamento si verifica solo
per un avanzamento di un blocco; un salto di più blocchi è accettato con il
solo controllo di difficoltà. Un avanzamento di un blocco che non si
aggancia chiude la sessione: per questo una normale riorganizzazione di un
blocco (la nostra punta `N` diventa orfana) fa chiudere la sessione su
**ogni** server — ciascuno annuncia `N+1` sul nuovo ramo, che non si
aggancia alla nostra `N` — finché l'arrivo di `N+2` non rende l'avanzamento
un salto di due blocchi, accettato senza controllo di collegamento.
- **[ELC-13]** Un header accettato aggiorna insieme altezza e contenuto della
punta (mai uno senza l'altro). Ogni notifica di nuovi header invia una
notifica SSE, perché è proprio ciò che l'estrazione attende.
punta (mai uno senza l'altro) e la finestra di [ELC-22]. Ogni notifica di
nuovi header invia una notifica SSE, perché è proprio ciò che l'estrazione
attende.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/listener.py:ElectrumListener._consume_headers` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[ELC-22]** Il listener tiene in memoria gli **ultimi 10 header**
accettati (altezza → contenuto), punta compresa. Il primo header del
processo non ha nulla a cui agganciarsi ed è accettato con il solo
[ELC-11]; subito dopo si scaricano i 9 header precedenti, che devono
superare [ELC-11] ed essere collegati fra loro e al primo, altrimenti la
sessione si chiude. La finestra sopravvive alle riconnessioni, non ai
riavvii.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/electrum/listener.py:ElectrumListener._run_once` · *Test:* —
*Stato:* da implementare (B-85) · *Oggi:* si tiene solo la punta
(`tip_height`, `tip_header_hex`).
Il controllo di difficoltà respinge un header mai minato, ma non uno minato
davvero a una difficoltà dichiarata irrealisticamente bassa: per questo il
blocco dell'estrazione viene anche corroborato ([ELC-15]).
### Riorganizzazioni
- **[ELC-23]** Quando un header non si aggancia alla punta ([ELC-12]) o ne
sostituisce l'hash alla stessa altezza ([ELC-09]), il listener cerca il
**punto di biforcazione**: chiede al server attivo gli header delle altezze
della finestra, dalla punta verso il basso, fino a 10, e prende la più alta
altezza in cui l'hash del server coincide con il nostro. Se lo trova, il
ramo del server dal punto di biforcazione fino al nuovo header deve
superare [ELC-11] ed essere collegato; allora sostituisce la finestra sopra
il punto di biforcazione e il nuovo header diventa la punta. Se non lo
trova entro 10 header (una biforcazione più profonda, o un server su
un'altra catena), la sessione si chiude come per [ELC-11].
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · *Test:* —
*Stato:* da implementare (B-74, B-85) · *Oggi:* nessuna riorganizzazione è
rilevata né seguita; vedi l'*Oggi* di [ELC-12].
- **[ELC-24]** Ogni riorganizzazione seguita scrive l'evento
`chain_reorg_detected` con la profondità (numero dei nostri blocchi
sostituiti, cioè vecchia altezza della punta meno altezza del punto di
biforcazione), l'altezza del punto di biforcazione, la vecchia e la nuova
altezza della punta e i due hash di punta. Con profondità **≥ 2** la
lotteria va in pausa (`paused = true`, `paused_reason = 'reorg'`, vedi
[modello-dati.md](modello-dati.md) B-85) finché l'operatore non la
riprende: come ogni pausa, impedisce solo l'apertura del round successivo.
Con profondità 1 si scrive solo l'evento.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header`, `app/db/models.py:RoundConfig` · *Test:* —
*Stato:* da implementare (B-85) · *Oggi:* l'evento non esiste e nulla mette
in pausa la lotteria da solo.
## 5. Il quorum
Ogni decisione che sposta denaro viene confermata dagli **altri** server
configurati, mai da quello attivo (che è proprio quello che un attaccante
controllerebbe).
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ì: solo le tre verifiche [ELC-15]–[ELC-17] sono
corroborate. Le conferme delle transazioni (`app/tx/confirmation.py:poll_once`),
l'abbandono di una transazione (`app/tx/reconcile.py:reconcile_once`), il
reintegro di una moneta ricomparsa
(`app/deposits/service.py:reinstate_reappeared_utxos`) e la lettura delle
monete del montepremi per il pagamento
(`app/rounds/scheduler.py:RoundScheduler._trigger_payout`) si basano sul solo
server attivo; sono le righe [ELC-26], [ELC-28], [ELC-25] e [ELC-27].
- **[ELC-14]** La corroborazione interroga in parallelo tutti i server
configurati tranne quello attivo, ciascuno con una connessione nuova e un
limite di 10 secondi (`_CORROBORATION_TIMEOUT_SECONDS`) per collegarsi e 10
per rispondere. Passa se concorda la **maggioranza stretta dei server che
hanno risposto**; un server che non risponde o dà errore non conta.
- **Nessun server di riserva configurato** → la verifica passa. È il rischio
accettato di un `ELECTRUM_FALLBACK_SERVERS` vuoto: in produzione conviene
configurarne più d'uno (vedi [setup.md](setup.md)).
- **Nessuno risponde** → la verifica **fallisce**: una rete irraggiungibile
non prova nulla.
per rispondere. Un server che non risponde o dà errore non conta. Passa se
hanno risposto **almeno 2** server **e** concorda la **maggioranza stretta
di quelli che hanno risposto**; con meno di 2 risposte **fallisce**: una
rete irraggiungibile, o un solo server raggiungibile, non prova nulla.
Con `ALLOW_NO_QUORUM=true` ([ELC-19]) vale invece la regola ridotta: senza
altri server configurati la verifica passa, altrimenti basta la
maggioranza stretta delle risposte, anche di una sola.
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-84) · *Oggi:* vale per tutti la regola ridotta.
Senza server di riserva ogni verifica passa; con server di riserva la
maggioranza è calcolata su chi ha risposto, quindi **una risposta su una
decide**: se è raggiungibile un solo altro server, la sua parola basta.
Nessuna risposta fa fallire la verifica.
| ID | Prima di… | Si verifica che… |
|---|---|---|
| **[ELC-15]** | usare un blocco per l'estrazione | l'header che l'altro server restituisce per quell'altezza (`blockchain.block.header`) abbia lo stesso hash |
| **[ELC-15]** | usare un blocco per l'estrazione | l'header che ciascun server restituisce per l'altezza di estrazione (`blockchain.block.header`) abbia lo stesso hash; regole complete in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione) (B-73) |
| **[ELC-16]** | togliere dal saldo una moneta sparita | l'altro server non elenchi più la moneta tra quelle non spese |
| **[ELC-17]** | accreditare un deposito | l'altro server elenchi la moneta tra quelle non spese, con lo stesso importo e confermata (altezza maggiore di 0) |
| **[ELC-17]** | accreditare un deposito | l'altro server elenchi la moneta tra quelle non spese, con lo stesso importo, a un'altezza che le dà almeno le conferme richieste ([DEP-03](registrazione-e-depositi.md#deposito)) contate sulla nostra punta |
| **[ELC-25]** | reintegrare una moneta data per spesa fuori | l'altro server elenchi la moneta tra quelle non spese, con lo stesso importo e confermata (altezza maggiore di 0), come per un accredito |
| **[ELC-26]** | segnare confermata una transazione | l'altro server restituisca per il txid una prova merkle (`blockchain.transaction.get_merkle`) che, ricalcolata, dà la radice merkle dell'header di quell'altezza; l'header è quello della finestra ([ELC-22]) o, se più vecchio, quello su cui concorda il quorum come in [ELC-15]. Regole della conferma in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#conferma) (B-83) |
| **[ELC-27]** | costruire il pagamento di un round | l'altro server elenchi come non spese le monete delle scommesse del round all'indirizzo del suo montepremi; regole in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento) (B-82) |
| **[ELC-28]** | abbandonare una transazione | l'altro server riporti un suo ingresso speso in un blocco da un txid diverso, oppure non conosca nessuno dei suoi txid; regole in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#riconciliazione) (B-78) |
*Codice:* `app/electrum/listener.py:ElectrumListener.corroborate_header`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` ·
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit`,
`app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
*Test:* `tests/unit/test_electrum_listener.py`
Stato delle righe della tabella:
- [ELC-15] — *Stato:* da implementare (B-73) · *Oggi:* si corrobora l'header
del primo blocco che il server attivo ha annunciato dopo la chiusura,
chiedendo solo agli altri server; se la verifica fallisce si passa al blocco
successivo invece di richiedere la stessa altezza.
- [ELC-17] — *Stato:* da implementare (B-82) · *Oggi:* basta un'altezza
maggiore di 0 (1 conferma).
- [ELC-25] — *Stato:* da implementare (B-83) · *Oggi:* il reintegro si basa
sulla sola risposta del server attivo.
- [ELC-26] — *Stato:* da implementare (B-83) · *Oggi:* una transazione è
confermata quando la cronologia dell'indirizzo sul server attivo la riporta
con altezza maggiore di 0.
- [ELC-27] — *Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie
fra le monete che il server attivo elenca per il montepremi.
- [ELC-28] — *Stato:* da implementare (B-78) · *Oggi:* si abbandona quando la
cronologia dell'indirizzo sul server attivo non contiene il txid.
I saldi si muovono in entrambe le direzioni, quindi entrambe richiedono lo
stesso quorum. Le conseguenze di una verifica non riuscita:
@@ -158,14 +350,19 @@ stesso quorum. Le conseguenze di una verifica non riuscita:
riproposta al controllo successivo
([DEP-04](registrazione-e-depositi.md#deposito));
- una moneta sparita non verificata resta nel saldo
([DEP-07](registrazione-e-depositi.md#deposito));
- un blocco non verificato fa attendere il blocco seguente, e lo si registra
in audit (`draw_header_corroboration_failed`,
[RND-16](ciclo-di-vita-del-round.md#3--estrazione)).
([DEP-07](registrazione-e-depositi.md#deposito)), una moneta ricomparsa non
verificata resta fuori ([DEP-08](registrazione-e-depositi.md#deposito));
- una conferma non verificata lascia la transazione in attesa, e si riprova
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)).
Il reintegro di una moneta ricomparsa
([DEP-08](registrazione-e-depositi.md#deposito)) e la lettura delle monete del
montepremi per il pagamento si basano invece sul solo server attivo.
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
inviata anche a ogni server di riserva, al meglio. La regola è in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) (B-76,
B-83); oggi l'invio va al solo server attivo.
## Riconnessione
@@ -181,6 +378,16 @@ montepremi per il pagamento si basano invece sul solo server attivo.
*Codice:* `app/electrum/listener.py:ElectrumListener._subscribe_all_users`, `app/electrum/listener.py:ElectrumListener._run_once` ·
*Test:* `tests/unit/test_electrum_listener.py`
Lo stato della connessione e l'altezza corrente sono visibili nella barra di
stato della catena di `/` (da `chain_tip_height` di `GET /rounds/current`) e
nell'indicatore Electrum di `/admin`.
## Stato della connessione
- **[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
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.
+705 -183
View File
File diff suppressed because it is too large. Load diff
+667 -209
View File
File diff suppressed because it is too large. Load diff
+528 -134
View File
@@ -14,6 +14,12 @@ esatto è in [audit-log.md](audit-log.md). Il funzionamento interno che spiega
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) e
[connessione-electrum.md](connessione-electrum.md).
**Procedure e requisiti.** Le procedure (i passi numerati) descrivono cosa
fare **con il codice di oggi**; dove una correzione in corso le cambierà, una
nota "Dopo B-nn" dice come. I requisiti marcati *da implementare* descrivono
invece il comportamento atteso, non ancora presente (vedi
[processo-di-modifica.md](processo-di-modifica.md#requisiti-non-ancora-implementati)).
## Topologia
```mermaid
@@ -38,18 +44,35 @@ 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
`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`
dovranno allora rispettare quel proprietario.
- **[OPS-02]** All'avvio il container `app` esegue, in quest'ordine: controllo
che il file della master key esista (altrimenti esce con errore e un
suggerimento del comando da lanciare), `alembic upgrade head`, avvio del
server. Durante l'avvio dell'applicazione `validate_runtime_secrets` rifiuta
di partire se `JWT_SECRET` è più corto di 32 caratteri o
`XPRV_ENCRYPTION_KEY` è vuoto, e `parse_endpoints` rifiuta una voce malformata
in `ELECTRUM_FALLBACK_SERVERS`. Un `ADMIN_TOKEN` vuoto non blocca l'avvio: il
pannello resta chiuso a tutti.
server. Durante l'avvio dell'applicazione il server rifiuta di servire se
un segreto non è valido ([SEC-12](sicurezza.md#sec-12)), se la master key
non si decifra o non corrisponde agli indirizzi già salvati
([SEC-49](sicurezza.md#sec-49)), se una voce di
`ELECTRUM_FALLBACK_SERVERS` è malformata (`parse_endpoints`) o se il quorum
minimo non è configurato ([OPS-12]). Un `ADMIN_TOKEN` vuoto non blocca
l'avvio: il pannello resta chiuso a tutti. Un rifiuto termina il processo;
con `restart: unless-stopped` il container riparte e fallisce di nuovo,
quindi il sintomo è un container che si riavvia in continuazione, con il
motivo nell'output di `docker compose logs app`.
*Codice:* `Dockerfile` (`CMD`), `app/main.py:lifespan`,
`app/config.py:validate_runtime_secrets`,
`app/electrum/client.py:parse_endpoints` ·
`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, B-84) · *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, e il server parte anche
senza server di riserva.
- **[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
@@ -64,18 +87,34 @@ flowchart LR
[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
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**
viene riavviato da Docker: è un segnale per l'operatore
([OPS-25]), da trattare con [OPS-11] o [OPS-13].
## Backup e ripristino
### Cosa salvare
| Cosa | Dove | Quanto spesso | Perché |
|---|---|---|---|
| Database | `./data/db/plm_lottery.db` (+ `-wal`, `-shm`) | Spesso (almeno giornaliero) | Utenti, indici di derivazione, UTXO, round, prelievi, audit |
| Database | `./data/db/plm_lottery.db` (+ `-wal`, `-shm`) | Spesso (almeno giornaliero) e **prima di ogni deploy** | Utenti, indici di derivazione, UTXO, round, prelievi, audit |
| Master key cifrata | `./data/keys/master.xprv.enc` | Una volta (non cambia) | Da essa derivano tutti gli indirizzi e il montepremi |
| `XPRV_ENCRYPTION_KEY` | `.env` | Una volta, **in un luogo separato** dalla master key | Senza, la master key cifrata è illeggibile |
| Altri segreti di `.env` | `.env` | A ogni cambio | `JWT_SECRET`, `ADMIN_TOKEN`, server Electrum |
| Altri segreti di `.env` | `.env` | A ogni cambio | `JWT_SECRET`, `ADMIN_TOKEN`, server Electrum, `SITE_ADDRESS` |
| Stato di Caddy | volume `caddy_data` | Facoltativo | Certificati; Caddy li richiede di nuovo se mancano |
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
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.
- **[OPS-05]** Master key cifrata e `XPRV_ENCRYPTION_KEY` si salvano **in posti
diversi**: uno dei due da solo non basta a spendere, entrambi insieme danno il
controllo di tutti i fondi. Perderli entrambi significa perdere per sempre i
@@ -96,43 +135,162 @@ flowchart LR
Copiare a mano il solo `plm_lottery.db` mentre il server gira **non** è un
backup affidabile ([OPS-03]). In alternativa: `docker compose stop app`,
copiare i tre file, `docker compose start app`, ma quel riavvio ha le
conseguenze descritte in [OPS-10].
conseguenze descritte in [OPS-10]. Oggi il file di backup appartiene a root
sull'host ([Topologia](#topologia)).
*Codice:* `app/db/base.py:_register_sqlite_pragmas`
### Ripristino
Un database ripristinato è **indietro rispetto alla catena**: round già
estratti e pagati risultano ancora aperti, scommesse e prelievi fatti dopo il
backup non esistono, utenti registrati dopo il backup sono spariti. La
procedura serve a impedire che il sistema *rigiochi* quel passato con il
denaro di oggi.
- **[OPS-07]** Procedura di ripristino:
1. `docker compose down`.
1. `docker compose down`. Il pubblico non deve raggiungere l'app finché la
procedura non è finita: `caddy` si riavvia solo al passo 10.
2. Rimettere `.env` (con la stessa `XPRV_ENCRYPTION_KEY` usata per cifrare la
master key) e `./data/keys/master.xprv.enc`. Se si ha solo la xprv in
chiaro, ricifrarla con `scripts/encrypt_master_key.py`.
chiaro, ricifrarla con `scripts/encrypt_master_key.py` lanciato nel
container (vedi [OPS-22], passo 0, per il perché).
3. Rimettere il database in `./data/db/plm_lottery.db` e **cancellare**
eventuali `-wal`/`-shm` rimasti dall'istanza precedente, che non
appartengono al file ripristinato.
4. `docker compose up -d --build`. Le migrazioni si applicano da sole.
5. Controllare in `/admin` lo stato del round attivo e le transazioni
pendenti; un round rimasto in `drawing` nel backup va sbloccato con
[OPS-13].
4. **Mettere in pausa la lotteria nel database ripristinato, prima di
avviare l'app** (il valore di `paused` è quello del backup):
```bash
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
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:
```bash
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); print('ROUND'); [print(r) for r in c.execute(\"SELECT id,status,winner_user_id,payout_txid FROM rounds WHERE status!='closed'\")]; print('PENDING'); [print(r) for r in c.execute(\"SELECT id,kind,status,round_id,current_txid FROM pending_transactions WHERE status IN ('building','pending')\")]"
```
e, per ogni round trovato, i partecipanti
(`SELECT user_id,status,bet_txid FROM round_participants WHERE round_id=<ID>`,
con lo stesso comando).
6. **Confrontare ogni round non chiuso con la catena**, con un explorer,
**prima** di avviare l'app. È necessario perché la pausa ferma solo
l'apertura di round nuovi: appena c'è connessione, un round del backup in
`open`, `closing` o `paying_out` riprende da solo, chiude, estrae e paga
([OPS-09]). Oggi tutti i round usano lo stesso indirizzo del montepremi
(`derive_pool_address`, vedi [OPS-15]), quindi un pagamento rifatto
spenderebbe il denaro dei round successivi. Per ogni round:
- si guardano le uscite delle sue scommesse verso l'indirizzo del
montepremi (i `bet_txid` dei partecipanti) e, se c'è, `payout_txid`;
- **se una di quelle uscite è già spesa, o `payout_txid` è confermato**,
il round è già stato pagato dopo il backup. Va chiuso a mano, e ogni
sua riga `payout` ancora `building`/`pending` va segnata `failed`,
altrimenti il riconciliatore la abbandonerebbe e lo scheduler
ricostruirebbe il pagamento:
```bash
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); print(c.execute(\"UPDATE rounds SET status='closed', closed_at=CURRENT_TIMESTAMP WHERE id=<ID> AND status!='closed'\").rowcount, 'round'); print(c.execute(\"UPDATE pending_transactions SET status='failed', failure_reason='restore: round already paid' WHERE kind='payout' AND round_id=<ID> AND status IN ('building','pending')\").rowcount, 'payout'); c.commit()"
```
Unica eccezione: se `payout_txid` della riga `pending` è proprio la
transazione confermata, non serve toccare nulla, perché il
`ConfirmationPoller` chiude il round da sé;
- **se tutte le uscite sono ancora non spese**, il round non è stato
pagato e può riprendere. I giocatori che hanno scommesso su quel round
*dopo* il backup non sono tra i partecipanti, ma i loro soldi sono nel
montepremi: annotarli per un rimborso manuale (nessuna procedura
supportata, vedi [OPS-24]);
- un round in `drawing` **non va mai sbloccato con [OPS-13]** prima di
questo controllo: se è già stato pagato, va chiuso come sopra.
Annotare ogni modifica (data, id, motivo): nessuna lascia una riga di
audit.
7. **Controllare gli indirizzi riassegnabili** con [OPS-28], sempre ad app
ferma.
8. Avviare **solo** l'app: `docker compose up -d --build app` (il servizio
`caddy` resta fermo). Le migrazioni si applicano da sole.
9. In `data/logs/app.log` aspettare `Electrum connected to …`. Alla
connessione il listener risottoscrive e aggiorna subito ogni utente
([ELC-18](connessione-electrum.md#riconnessione)), senza aspettare il giro
di 300 secondi di `DepositReconciler`: gli UTXO spesi dopo il backup
(scommesse e prelievi persi dal database) vengono segnati come spesi
all'esterno (`utxo_spent_externally`), quelli arrivati dopo vengono
accreditati (`deposit_credited`). Il riconciliatore risolve le righe
`building`/`pending` del backup al suo primo passaggio con la connessione
attiva. Controllare il registro di audit e "Transazioni pendenti".
10. `docker compose up -d` (riavvia `caddy`) e "Riprendi lotteria".
All'avvio il riconciliatore delle transazioni e, dopo 300s, il controllo
periodico dei depositi riallineano il database alla catena: UTXO spesi dopo
il backup vengono segnati come spesi all'esterno (`utxo_spent_externally`),
quelli arrivati dopo vengono accreditati.
*Codice:* `app/tx/reconcile.py:reconcile_once`,
`app/deposits/reconcile.py:DepositReconciler`
**Il rischio di replay.** Il riallineamento dei saldi è automatico e
immediato; quello dei round no. Ogni round del backup non ancora `closed`
riprende da solo appena c'è connessione, **anche con la lotteria in pausa**,
e ogni scommessa fatta dopo il backup è persa come partecipazione (il suo
UTXO risulta speso all'esterno, i suoi soldi sono nel montepremi). I passi
4–6 esistono per questo e vanno eseguiti prima del passo 8. Dopo B-80 e B-82
un round rigiocato paga solo gli UTXO del proprio indirizzo, dopo averli
verificati sulla rete, e resta quindi non pagabile invece di essere pagato
due volte; il controllo del passo 6 resta comunque il modo di sapere quali
round del backup sono già conclusi.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
`app/tx/reconcile.py:reconcile_once`,
`app/electrum/listener.py:ElectrumListener._subscribe_all_users`
- **[OPS-08]** **Rischio di un backup vecchio: indirizzi riassegnati.** Il
nuovo utente riceve l'indice di derivazione `max(derivation_index) + 1`.
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. Il codice non ha alcuna protezione contro questo caso, né
un modo per sospendere le registrazioni. Dopo un ripristino da un backup non
recentissimo, prima di riaprire il servizio, bisogna ricostruire gli utenti
mancanti (ad esempio dai log di `POST /auth/register` in `app.log`, se ancora
presenti) oppure accettare consapevolmente il rischio.
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
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
ricostruire dai log: `app.log` non registra né username né indice di una
registrazione, e il log di accesso riporta l'indirizzo di Caddy, non quello
del client.
*Codice:* `app/auth/routes.py:register`
- **[OPS-27]** Con `START_PAUSED=true` in `.env` l'app, all'avvio e prima che
lo scheduler faccia il suo primo ciclo, mette in pausa la lotteria come una
pausa dell'operatore (`paused_reason = 'operator'`, senza sovrascrivere un
motivo automatico già presente, [modello-dati.md](modello-dati.md) B-85) e
scrive `lottery_paused`; la pausa resta finché l'operatore non la toglie
("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]).
- **[OPS-28]** **Occupare gli indici di utenti persi.** Procedura, ad app
ferma, valida oggi e dopo B-90:
1. Trovare il massimo indice assegnato e ricavare gli indirizzi dei 20
indici successivi:
```bash
docker compose run --rm --no-deps app python -c "import sqlite3; from app.wallet.hd import derive_user_address; M=sqlite3.connect('/app/db_data/plm_lottery.db').execute('SELECT COALESCE(MAX(derivation_index),-1) FROM users').fetchone()[0]; print('max', M); [print(i, derive_user_address(i)) for i in range(M+1, M+21)]"
```
2. Controllare con un explorer quali hanno una cronologia. Se ne ha uno fra
gli ultimi della lista, ripetere il passo 1 partendo dal più alto con
cronologia.
3. Se nessuno ha cronologia, non c'è nulla da fare. Altrimenti inserire un
**account segnaposto per ogni indice** da `max + 1` fino al più alto con
cronologia (compresi quelli senza cronologia in mezzo, perché gli indici
si assegnano in ordine):
```bash
docker compose run --rm --no-deps app python -c "import sqlite3, secrets; from datetime import datetime, timezone; from app.auth.security import hash_password; from app.wallet.hd import derive_user_address; c=sqlite3.connect('/app/db_data/plm_lottery.db'); A, B = <DA>, <A>; [c.execute('INSERT INTO users (username, password_hash, derivation_index, address, cached_balance_sats, token_version, created_at) VALUES (?, ?, ?, ?, 0, 0, ?)', ('restore-%d' % i, hash_password(secrets.token_urlsafe(32)), i, derive_user_address(i), datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S.%f'))) for i in range(A, B + 1)]; c.commit(); print(B - A + 1, 'segnaposto')"
```
La password casuale non viene mostrata a nessuno: l'account non è
utilizzabile finché l'operatore non lo consegna. L'inserimento non
lascia una riga di audit: annotarlo altrove.
4. All'avvio il listener si iscrive anche agli indirizzi segnaposto e
accredita i loro fondi. Quando un utente perso si presenta e dimostra di
conoscere il proprio indirizzo, gli si consegna il segnaposto
corrispondente con un reset della password ([OPS-19]).
Dopo B-90 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.
*Codice:* `app/wallet/hd.py:derive_user_address`,
`app/auth/security.py:hash_password`, `app/db/models.py:User`
## Riavvio
Un riavvio avviene a ogni deploy (`docker compose up -d --build`), a ogni
@@ -147,47 +305,101 @@ modifica di `.env` e da solo dopo un crash (`restart: unless-stopped`).
| nessun round / `closed` | Si apre un nuovo round rispettando il cooldown del round precedente |
| `open` | Riprende: la scadenza è calcolata dai valori salvati sul round |
| `closing` | Riprende: si aspetta che le scommesse in volo confermino, poi si estrae |
| `drawing` | **Non riprende.** Il round resta in `drawing` per sempre e nessun nuovo round si apre. Serve [OPS-13] |
| `drawing` | Riprende l'attesa dello stesso blocco di estrazione, con gli stessi partecipanti, e produce lo stesso vincitore ([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione), B-81) |
| `paying_out` | Riprende: il pagamento viene ritentato subito se non c'è un `payout_failed` negli ultimi 60s; un pagamento già in volo non viene duplicato |
Le transazioni rimaste `building` (crash a metà invio) vengono risolte dal
riconciliatore, non prima di 120 secondi dalla loro creazione e al primo
passaggio con la connessione attiva (i passaggi sono ogni 120s, e quello
"immediato" all'avvio di norma trova la connessione non ancora pronta e viene
saltato): promosse se sono in catena, abbandonate altrimenti. Si perdono invece, perché solo in memoria: i
blocchi dei login per troppi tentativi e la quota di registrazioni per IP, le
connessioni SSE (i browser si riconnettono da soli), i lock per utente.
riconciliatore secondo le regole di
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md)
(B-78): promosse se in catena, abbandonate solo con una prova. Si perdono
invece, perché solo in memoria: i blocchi dei login per troppi tentativi e
le quote per IP, le connessioni SSE (i browser si riconnettono da soli), i
lock per utente.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`,
`app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-81, B-78) · *Oggi:* un round in `drawing`
**non riprende**: resta in `drawing` per sempre e nessun nuovo round si apre
(serve [OPS-13]); una riga `building` non trovata nella cronologia del
server attivo viene abbandonata dopo 120 secondi dalla creazione, al primo
passaggio del riconciliatore (ogni 120s) con la connessione attiva.
- **[OPS-10]** **Riavvio sicuro** (deploy, modifica di `.env`, rotazione di un
segreto):
1. In `/admin` → Parametri → Manutenzione, "Interrompi dopo questo round"
- **[OPS-10]** **Riavvio sicuro e deploy** (nuova versione, modifica di
`.env`, rotazione di un segreto):
1. **Backup** del database ([OPS-06]), copiato fuori dall'host. Annotare la
versione in uso (`git rev-parse HEAD`) e la revisione del database
(`docker compose exec app alembic current`): servono per un rollback
([OPS-29]).
2. Verificare che `SITE_ADDRESS` sia scritto in `.env`
(`grep '^SITE_ADDRESS=' .env`). Se è stato passato solo sulla riga di
comando (`SITE_ADDRESS=… docker compose up -d`), un semplice
`docker compose up -d --build` lo perde e Caddy torna a `localhost` con un
certificato autofirmato: il sito pubblico smette di funzionare.
3. In `/admin` → Parametri → Manutenzione, "Interrompi dopo questo round"
(`POST /admin/pause`). Il round in corso finisce normalmente.
2. Aspettare che `GET /rounds/current` non riporti più un round attivo
(`round_id` vuoto) e che in "Transazioni pendenti" non ci siano scommesse
o prelievi `building`.
3. Riavviare (`docker compose up -d --build`, oppure `docker compose up -d`
4. Aspettare che `GET /rounds/current` non riporti più un round attivo
(`round_id` vuoto, cioè l'ultimo round è `closed`) e che in "Transazioni
pendenti" non ci siano righe `building` (la sezione elenca anche righe già
confermate o fallite: guardare la colonna Stato).
5. Riavviare (`docker compose up -d --build`, oppure `docker compose up -d`
per un cambio di `.env`).
4. Controllare in `data/logs/app.log` la riga `Electrum connected to …`,
6. Controllare in `data/logs/app.log` la riga `Electrum connected to …`,
poi "Riprendi lotteria" (`POST /admin/resume`).
**Perché aspettare `closed` e non solo la pausa.** Oggi l'arresto non è
pulito: con uno stream SSE aperto (basta una pagina `/` aperta) uvicorn
aspetta che si chiuda, e Docker uccide il processo dopo 10 secondi. Ogni
deploy è quindi, per il codice, un crash: durante `drawing` blocca il round
([OPS-13]), durante un invio lascia una riga `building`. Dopo B-93 l'arresto
chiude gli stream e le attività entro 30 secondi; il riavvio
durante `drawing` diventa sicuro con B-81 ([OPS-09]).
La pausa non blocca scommesse e prelievi sul round in corso, né
registrazioni e depositi. Un riavvio con il round in `open`, `closing` o
`paying_out` è comunque sicuro ([OPS-09]); quello da evitare è il riavvio
durante `drawing`.
registrazioni e depositi.
*Codice:* `app/api/routes/admin.py:pause_lottery`,
`app/rounds/service.py:open_new_round_if_needed` ·
*Test:* `tests/unit/test_admin.py`
Dopo B-93 lo spegnimento chiude gli stream SSE e attende le attività, entro
`stop_grace_period: 30s` ([architettura.md](architettura.md) ARC-22); ogni
rilascio ha un'immagine con un tag di versione e `SITE_ADDRESS` è
obbligatorio in `.env`: senza, `docker compose` rifiuta di partire invece di
ripiegare su `localhost` ([architettura.md](architettura.md) ARC-01, ARC-05).
- **[OPS-29]** **Rollback a una versione precedente.** Un'immagine vecchia non
parte su un database con migrazioni più nuove: il suo `alembic upgrade head`
non riconosce la revisione del database e il container esce, e si riavvia in
continuazione. Il database va quindi riportato indietro **con l'immagine
nuova, prima** di cambiare versione:
1. Pausa e attesa di `closed` come in [OPS-10], poi backup ([OPS-06]).
2. Trovare la revisione di destinazione: nella versione a cui si torna,
dal venv, `alembic heads` (legge solo i file di migrazione, non il
database).
3. `docker compose stop app`, poi, con l'immagine nuova ancora in uso,
`docker compose run --rm --no-deps app alembic downgrade <revisione>`.
4. `git checkout <versione>` e `docker compose up -d --build`.
5. Controllare il log e riprendere la lotteria.
Un downgrade cancella le colonne e le tabelle aggiunte dalle migrazioni
annullate, con i loro dati. Se un downgrade fallisce, si ripristina il
backup del passo 1 di [OPS-10] con [OPS-07], perdendo ciò che è successo
dopo il deploy. Dopo B-93 il passo 4 usa il tag dell'immagine precedente
invece di ricostruirla.
*Codice:* `migrations/`, `Dockerfile` (`CMD`)
## Procedure di intervento
Ogni procedura ha la stessa forma: come si riconosce, perché succede, cosa
fare. Per leggere il log: `tail -f data/logs/app.log` sull'host (i messaggi
dell'applicazione non compaiono in `docker compose logs`, vedi
[audit-log.md](audit-log.md)).
fare. Per leggere il log: `tail -f data/logs/app.log` sull'host. I messaggi
dell'applicazione (scheduler, listener, riconciliatori) sono **solo** in quel
file; `docker compose logs app` mostra soltanto l'output di avvio del
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
con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
### Electrum irraggiungibile
@@ -195,39 +407,71 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
**Sintomi.** In `/admin` la pillola "Blocco #N" e `chain_tip_height` di
`GET /rounds/current` restano fermi sull'ultimo valore noto (l'altezza non si
azzera alla disconnessione, quindi *non* si vede un errore, si vede un numero
che non cresce). Scommesse e prelievi rispondono 503 `network_unavailable`.
Nel log: `Electrum session on … failed`, `Electrum session on … ending`,
`all N Electrum server(s) unreachable, retrying in Ns`. Lo scheduler e le
altre attività saltano i loro cicli; un round in `drawing` continua ad
aspettare e dopo 360s scrive `draw_stalled`.
**Causa.** Tutti i server configurati sono giù, irraggiungibili, o rifiutati
(un header che non supera la validazione chiude la sessione e fa passare al
server successivo).
che non cresce). Due casi diversi:
- **server irraggiungibili**: scommesse e prelievi rispondono 503
`network_unavailable`; nel log `Electrum session on … failed`,
`Electrum session on … ending`,
`all N Electrum server(s) unreachable, retrying in Ns`. Lo scheduler e le
altre attività saltano i loro cicli;
- **server connesso ma fermo**: nessun errore nel log, l'ultima riga di
connessione è `Electrum connected to <server>`, `ping` riesce, ma
l'altezza non cresce mentre un explorer o un altro server riportano
blocchi nuovi. Scommesse e prelievi vengono accettati, ma nulla conferma.
In entrambi i casi un round in `drawing` continua ad aspettare e dopo 360s
scrive `draw_stalled`.
**Causa.** Nel primo caso tutti i server configurati sono giù,
irraggiungibili o rifiutati (un header che non supera la validazione chiude
la sessione e fa passare al server successivo). Nel secondo il server attivo
ha smesso di seguire la catena: il listener lo rileva solo se la connessione
cade.
**Cosa fare.**
1. Nessuna azione è necessaria per il ripristino: il listener prova il
server successivo subito e, dopo un giro completo di fallimenti, riprova
con attesa crescente da 1 a 30 secondi, all'infinito.
2. Verificare la raggiungibilità dal server:
`docker compose exec app python scripts/electrum_smoke_test.py` (prova
solo il primario, vedi [OPS-26]).
3. Se i server configurati sono davvero tutti inutilizzabili, aggiungerne
1. *Server irraggiungibili*: il listener prova il server successivo subito
e, dopo un giro completo di fallimenti, riprova con attesa crescente da 1
a 30 secondi, all'infinito. Se un server torna, la connessione si
ristabilisce da sola.
2. *Server fermo*: il ripristino **non** è automatico. Un riavvio riparte
sempre dal primario (`ELECTRUM_HOST`), quindi aiuta solo se il server
fermo è una riserva. Se il fermo è il primario, in `.env` sostituirlo con
un server che funziona (spostandolo tra le riserve o togliendolo) e
riavviare con [OPS-10]. Dopo B-86 il listener cambia server da solo
quando la punta non avanza da 360s e un altro server ne riporta una più
alta ([ELC-21](connessione-electrum.md#3-caduta-rilevata)).
3. Verificare un server dal container, senza toccare quello in esecuzione:
`docker compose run --rm --no-deps -e ELECTRUM_HOST=<host> -e ELECTRUM_PORT=<porta> app python scripts/electrum_smoke_test.py`
(senza `-e` prova il primario, vedi [OPS-26]). L'altezza stampata si
confronta con quella di `GET /rounds/current`.
4. Se i server configurati sono davvero tutti inutilizzabili, aggiungerne
altri in `ELECTRUM_FALLBACK_SERVERS` (elenco di riferimento:
`ChainProfiles.Mainnet.BootstrapServers` del repository PalladiumWallet) e
riavviare. **Se un round è in `drawing`, il riavvio lo blocca**: seguire
poi [OPS-13].
riavviare. **Se un round è in `drawing`, oggi il riavvio lo blocca**:
seguire poi [OPS-13].
*Codice:* `app/electrum/listener.py:ElectrumListener.run`,
`app/electrum/listener.py:ElectrumListener._keepalive`,
`app/api/routes/bets.py`, `app/api/routes/withdrawals.py` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[OPS-12]** Con `ELECTRUM_FALLBACK_SERVERS` vuoto la corroborazione passa
sempre (non c'è nessuno a cui chiedere), ma un solo server guasto ferma
tutto. Con server di riserva configurati ma tutti irraggiungibili, la
corroborazione **fallisce**: depositi non accreditati, estrazione che scarta
ogni blocco (`draw_header_corroboration_failed`) e segnalazioni di spesa
esterna ignorate, anche se il server attivo funziona. In produzione servono
almeno due server di riserva raggiungibili.
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
- **[OPS-12]** Le regole del quorum sono di
[connessione-electrum.md](connessione-electrum.md): almeno 2 server di
riserva configurati o il server non parte
([ELC-19](connessione-electrum.md#1-rotazione-tra-server)), almeno 2
risposte per ogni corroborazione
([ELC-14](connessione-electrum.md#5-il-quorum)). La conseguenza operativa:
con meno di 2 server di riserva **raggiungibili** ogni corroborazione
fallisce, anche se il server attivo funziona — depositi non accreditati,
estrazione in attesa, segnalazioni di spesa esterna ignorate, nessun denaro
mosso. In produzione vanno quindi configurati almeno 3 server di riserva,
così che la caduta di uno non fermi la lotteria. `ALLOW_NO_QUORUM=true` non
si usa mai in produzione.
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority`,
`app/main.py:lifespan` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-84) · *Oggi:* con `ELECTRUM_FALLBACK_SERVERS`
vuoto (il valore di `.env.example`) il server parte e la corroborazione
passa sempre: un solo server decide tutto. Con server di riserva la
maggioranza è su chi risponde, quindi basta una sola riserva raggiungibile;
se nessuna risponde, la corroborazione fallisce
(`draw_header_corroboration_failed` a ogni blocco).
### Round bloccato in `drawing`
@@ -249,12 +493,17 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
**Causa.** L'attesa del blocco di estrazione vive solo dentro la chiamata
dello scheduler che ha portato il round in `drawing`; a ogni ciclo successivo
lo scheduler vede `drawing` e non fa nulla. Nessun codice riprende l'attesa.
Dopo B-81 lo scheduler riprende da solo un round in `drawing` e il terzo
caso scompare.
**Cosa fare.** Nei primi due casi basta risolvere la causa (Electrum,
server di riserva): l'estrazione avviene da sola al primo blocco valido.
Nel terzo caso **non esiste una procedura supportata dal codice**. L'unico
intervento possibile è una modifica manuale del database che riporti il round
a `closing`, lo stato da cui lo scheduler rifà l'estrazione da capo:
a `closing`, lo stato da cui lo scheduler rifà l'estrazione da capo.
**Vale solo per un round bloccato nell'istanza in esercizio: un round in
`drawing` arrivato da un backup ripristinato si tratta con [OPS-07],
passo 6**, perché potrebbe essere già stato pagato.
1. Annotare l'id del round (dal pannello, sezione Round) e controllare che
sia davvero in `drawing` e che `winner_user_id` sia vuoto.
@@ -296,15 +545,18 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
**Cosa fare.**
1. Controllare `attempt_count` e `fee_rate_sat_vb` della riga: il
`RbfBumper` alza la commissione di 1 sat/vB ogni `rbf_timeout_seconds`
(predefinito 900s) dall'ultimo invio. Se sale, la scommessa confermerà da
sola.
(predefinito 900s) dall'ultimo invio. Se sale, la scommessa confermerà
quando la commissione basta al mempool.
2. Se nel log compare `could not bump pending_transaction N`, l'RBF è
impossibile: vedi [OPS-17].
3. Se la riga è `building` da più di 2 minuti, il riconciliatore la risolve
al suo prossimo passaggio (ogni 120s).
4. Una transazione che il server non conosce più viene abbandonata dopo 6
ore dal primo invio (`pending_tx_abandoned`); il partecipante viene
rimosso e il round può chiudere.
4. Una transazione che il server attivo non conosce più viene abbandonata
dopo 6 ore dal primo invio (`pending_tx_abandoned`); il partecipante viene
rimosso e il round può chiudere. Dopo B-78 l'abbandono richiede una prova
(ingressi spesi altrove, o un nuovo invio rifiutato da tutti i server):
una transazione che nessuno conosce ma i cui ingressi non sono spesi non
viene più abbandonata per il solo passare del tempo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
`app/tx/broadcast.py:RbfBumper`, `app/tx/reconcile.py:_is_due` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`,
@@ -317,7 +569,9 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
`payout_failed` dello stesso round, al massimo una al minuto. Il campo
`reason` dice il motivo. Lo scheduler riprova da solo ogni 60 secondi: una
volta rimossa la causa, il pagamento parte al tentativo successivo senza
altri interventi.
altri interventi. Un round in `paying_out` senza `payout_failed` e con una
riga `payout` `pending` che non conferma è un pagamento fermo nel mempool:
vedi [OPS-17].
| `reason` | Cosa fare |
|---|---|
@@ -327,9 +581,9 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
| `insufficient pool UTXOs` | Vedi sotto |
| `too_many_inputs` | Il pagamento richiederebbe più di 500 UTXO del montepremi (resti accumulati in molti round). Serve consolidare gli UTXO del montepremi con la sua chiave: **nessuno strumento lo fa**, vedi [OPS-24] |
| `winner_share_below_network_fee` | Il montepremi è troppo piccolo per la commissione di rete. Abbassare `fee_rate_sat_vb`; se è già 1, nessuna procedura supportata |
| `commission_below_dust_limit` | Il 30% è sotto 294 sat (montepremi sotto circa 1.000 sat, solo con un costo della scommessa minuscolo). Nessuna procedura supportata |
| `commission_below_dust_limit` | Il 30% è sotto 294 sat (montepremi sotto 980 sat, solo con un costo della scommessa minuscolo). Nessuna procedura supportata |
| `payout build failed` | Leggere la traccia nel log (`round N payout build failed`) |
| `broadcast rejected` | Si risolve da sola: la riga `building` viene abbandonata dal riconciliatore entro 2–4 minuti, `payout_txid` si azzera e il pagamento viene ricostruito. Se si ripete, la rete rifiuta la transazione: controllare il messaggio nel log e `fee_rate_sat_vb` |
| `broadcast rejected` | Si risolve da sola: la riga `building` viene abbandonata dal riconciliatore fra 120 e 240 secondi dopo (al primo passaggio oltre i 120s), `payout_txid` si azzera e il pagamento viene ricostruito. Se si ripete, la rete rifiuta la transazione: controllare il messaggio nel log e `fee_rate_sat_vb`. Oggi lo stesso motivo compare anche per un timeout o una connessione caduta durante l'invio, quando la transazione potrebbe essere partita; dopo B-76 solo un rifiuto esplicito libera la riga |
**`insufficient pool UTXOs`.** Il pagamento usa solo gli UTXO **confermati**
dell'indirizzo del montepremi, escludendo quelli già riservati da un altro
@@ -338,7 +592,11 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
resta tale, i suoi input sono esclusi. L'indirizzo del montepremi non è
mostrato dal pannello; si ottiene con
`docker compose exec app python -c "from app.wallet.hd import derive_pool_address; print(derive_pool_address())"`
e se ne può controllare il saldo con un explorer.
e se ne può controllare il saldo con un explorer. Dopo B-80 ogni round ha
il proprio indirizzo (`rounds.pool_address`), e dopo B-82 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]).
Un pagamento già in volo (riga `payout` `building` o `pending` del round)
non viene mai duplicato: finché esiste, i tentativi vengono saltati senza
@@ -354,16 +612,27 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
**Sintomi.** Riga `pending_tx_abandoned` nel registro di audit; nel log
`abandoned <tipo> pending_transaction …`; la riga in "Transazioni pendenti"
passa a `failed`.
**Causa.** La transazione non compare più nella cronologia del proprio
indirizzo: mai partita (riga `building` da oltre 120s) o scomparsa dal
mempool (riga `pending` da oltre 6 ore dal primo invio).
**Causa.** Oggi: la transazione non compare nella cronologia del proprio
indirizzo secondo il **solo server attivo**: mai partita (riga `building` da
oltre 120s) o scomparsa dal mempool (riga `pending` da oltre 6 ore dal primo
invio). Non è una prova: un server diverso da quello che ha ricevuto l'invio
può non conoscerla, e una versione precedente di una transazione accelerata
con RBF non viene cercata (B-77).
**Effetto e cosa fare.** Il sistema ha già annullato tutto: UTXO liberati e
saldo ricalcolato; per una scommessa il partecipante è rimosso (l'utente può
riscommettere se il round è ancora aperto); per un prelievo la richiesta è
`failed` e l'utente, che non vede lo storico, va avvisato di ripeterla; per
un pagamento `payout_txid` è azzerato e il nuovo tentativo parte da solo. Se
gli abbandoni sono frequenti, la commissione di rete configurata è
probabilmente troppo bassa.
un pagamento `payout_txid` è azzerato e il nuovo tentativo parte da solo.
**Prima di avvisare l'utente o lasciar ripartire un pagamento, verificare
con un explorer** che né `txid` del payload né una sua versione precedente
(`replaced_by_txid` della riga) siano in catena: se lo sono, i fondi si sono
mossi davvero e il database dice il contrario. Per un prelievo, gli UTXO
liberati verranno poi segnati come spesi all'esterno dalla corroborazione;
per un pagamento già arrivato al vincitore, mettere subito in pausa la
lotteria e trattare il round come in [OPS-07], passo 6. Se gli abbandoni
sono frequenti, la commissione di rete configurata è probabilmente troppo
bassa. Dopo B-78 si abbandona solo con una prova, e una riga `failed` che
conferma più tardi mette in pausa la lotteria da sola ([OPS-30]).
*Codice:* `app/tx/reconcile.py:_abandon` ·
*Test:* `tests/unit/test_reconcile.py`
@@ -373,17 +642,26 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
**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.
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.
non si può fare. **Un pagamento di norma non ha resto** (spende esattamente
gli UTXO del montepremi), quindi oggi un pagamento fermo nel mempool non si
può accelerare in nessun caso.
**Cosa fare.** Nessuna procedura supportata. La transazione resta in attesa
alla commissione che ha: conferma da sola quando il mempool si svuota,
oppure, se sparisce dal mempool, viene abbandonata dopo 6 ore ([OPS-16]). Se
blocca un round in `closing` ([OPS-14]), l'alternativa manuale è spendere il
resto non confermato con una commissione alta (CPFP) usando la chiave
dell'utente ([OPS-23]); il sistema accrediterà l'uscita della nuova
transazione come un normale deposito.
alla commissione che ha: conferma quando il mempool la accetta, oppure, se
sparisce dal mempool, viene abbandonata dopo 6 ore ([OPS-16]). Se blocca un
round in `closing` ([OPS-14]), l'alternativa manuale è spendere il resto non
confermato con una commissione alta (CPFP) usando la chiave dell'utente
([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-79 ogni pagamento ha un'uscita di riserva che l'RBF può ridurre;
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`
@@ -392,8 +670,10 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
- **[OPS-18]**
**Sintomi.** Un utente segnala un deposito che non vede nel saldo.
**Cause, in ordine di probabilità.**
1. La transazione non ha ancora 1 conferma: il deposito non viene mostrato
nemmeno come "in attesa" finché non è in un blocco.
1. La transazione non ha ancora le conferme richieste: oggi 1, dopo B-82
`deposit_confirmations` (predefinito 6) 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.
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)`.
@@ -401,13 +681,16 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
3. La sottoscrizione dell'indirizzo si è persa (log
`could not subscribe a newly-registered user's address` o
`resubscribing all users failed partway through`).
4. Electrum irraggiungibile ([OPS-11]).
**Cosa fare.** Verificare con un explorer che la transazione sia confermata
e paghi esattamente l'indirizzo dell'utente (sezione Utenti del pannello).
Poi aspettare: `DepositReconciler` ricontrolla ogni indirizzo ogni 300
secondi e accredita tutto ciò che il quorum conferma, indipendentemente
dalle notifiche. Un riavvio risottoscrive tutti gli indirizzi, ma non serve
e va fatto con [OPS-10]. Ogni accredito lascia una riga `deposit_credited`.
4. Electrum irraggiungibile o fermo ([OPS-11]).
5. Dopo B-75: l'importo è sotto `DUST_LIMIT_SATS` (294 sat) e non viene
accreditato per scelta.
**Cosa fare.** Verificare con un explorer che la transazione abbia le
conferme richieste e paghi esattamente l'indirizzo dell'utente (sezione
Utenti del pannello). Poi aspettare: `DepositReconciler` ricontrolla ogni
indirizzo ogni 300 secondi e accredita tutto ciò che il quorum conferma,
indipendentemente dalle notifiche. Un riavvio risottoscrive tutti gli
indirizzi, ma non serve e va fatto con [OPS-10]. Ogni accredito lascia una
riga `deposit_credited`.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`,
`app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
*Test:* `tests/unit/test_electrum_listener.py`,
@@ -421,11 +704,9 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
un canale di cui ci si fida (non esiste un'email). Tutte le sessioni esistenti
dell'utente vengono invalidate. Resta la riga `admin_password_reset`.
Verificare l'identità di chi chiede: chi riceve la password controlla i fondi.
**Troppi tentativi.** Dopo 5 login falliti sullo stesso username (20 dallo
stesso IP) ogni ulteriore errore impone un'attesa che parte da 2 secondi e
raddoppia fino a 300; il contatore si azzera dopo 15 minuti senza errori o al
primo login riuscito. Basta aspettare; un riavvio azzera tutti i contatori
ma non è necessario.
**Troppi tentativi.** Il login ha un freno per username e uno per IP
([SEC-30](sicurezza.md#sec-30)). Basta aspettare che il blocco scada; un
riavvio azzera tutti i contatori ma non è necessario.
*Codice:* `app/api/routes/admin.py:reset_user_password`,
`app/auth/rate_limit.py:AuthRateLimiters` ·
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_rate_limit.py`
@@ -462,22 +743,83 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
`app/config.py:validate_runtime_secrets`
- **[OPS-22]** **Rotazione di `XPRV_ENCRYPTION_KEY`** (la master key non
cambia, cambia solo la sua cifratura):
1. `PYTHONPATH=. python scripts/decrypt_master_key.py` con la chiave vecchia
in `.env`, e conservare la xprv stampata solo per il tempo necessario.
2. Generare la chiave nuova
cambia, cambia solo la sua cifratura). Tutti i passi girano **nel
container**, dove `MASTER_KEY_PATH` è forzato su
`/app/key_data/master.xprv.enc`, cioè il file che l'app legge:
0. **Verificare quale file si sta ruotando.** Il file dell'app è
`./data/keys/master.xprv.enc`. Dal venv gli script usano invece il
`MASTER_KEY_PATH` di `.env` (`grep '^MASTER_KEY_PATH=' .env`), che su
questa installazione punta alla copia `./master.xprv.enc` nella radice
(B-71): una rotazione fatta dal venv riscriverebbe quella copia, l'app
leggerebbe il file vecchio con la chiave nuova e fallirebbe alla prima
derivazione. Per questo nessun passo usa il venv.
1. Pausa, attesa di `closed` e backup come in [OPS-10], poi
`docker compose stop app`. Tenere una copia del file attuale per poter
tornare indietro: `cp data/keys/master.xprv.enc data/keys/master.xprv.enc.old`.
2. Annotare gli indirizzi derivati con la chiave vecchia:
```bash
docker compose run --rm --no-deps app python -c "from app.wallet.hd import derive_pool_address, derive_user_address; print(derive_pool_address(), derive_user_address(0))"
```
3. `docker compose run --rm --no-deps app python scripts/decrypt_master_key.py`
con la chiave vecchia ancora in `.env`, e conservare la xprv stampata
solo per il tempo necessario.
4. Generare la chiave nuova
(`python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`)
e scriverla in `.env`.
3. `PYTHONPATH=. python scripts/encrypt_master_key.py --overwrite` e
incollare la xprv.
4. Riavviare ([OPS-10]) e aggiornare i backup ([OPS-05]).
e scriverla in `.env` al posto della vecchia.
5. `docker compose run --rm --no-deps app python scripts/encrypt_master_key.py --overwrite`
e incollare la xprv.
6. **Verificare la decifratura con la chiave nuova prima di riavviare**:
rilanciare il comando del passo 2. Deve stampare gli stessi due
indirizzi. Se dà errore o indirizzi diversi, non avviare l'app: rimettere
in `.env` la chiave vecchia e `master.xprv.enc.old` al posto del file, e
ricominciare.
7. `docker compose up -d`, controllare il log, riprendere la lotteria.
8. Aggiornare i backup ([OPS-05]) e cancellare `master.xprv.enc.old`. Se la
rotazione è dovuta a una fuga della chiave vecchia, distruggere anche ogni
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
indirizzi non coincidono con quelli del database
([SEC-50](sicurezza.md#sec-50)); dopo B-91 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
xprv in chiaro), sono a rischio tutti i fondi degli utenti e del montepremi.
Non esiste una procedura supportata per passare a una nuova master key: ogni
indirizzo, compreso quello del montepremi, deriva da essa, e cambiarla
cambierebbe tutti gli indirizzi senza spostare i fondi né aggiornare il
database.
*Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/hd.py:_load_account_key`
*Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/hd.py:_load_account_key`,
`scripts/encrypt_master_key.py`, `scripts/decrypt_master_key.py`
### Pausa automatica
- **[OPS-30]** Oltre all'operatore, il sistema mette in pausa la lotteria da
sé in tre casi, ognuno con il proprio motivo (`paused_reason`, valori in
[modello-dati.md](modello-dati.md) B-85) e una riga di audit. La pausa ha
l'effetto di sempre: nessun round nuovo si apre, quello in corso prosegue
salvo dove indicato. Solo l'operatore la toglie ("Riprendi lotteria", che
azzera anche il motivo); `/admin` mostra il motivo. Prima di riprendere
l'operatore esegue la verifica della tabella.
| Motivo | Quando | Cosa fare prima di riprendere |
|---|---|---|
| `operator` | "Interrompi dopo questo round", oppure avvio con `START_PAUSED=true` ([OPS-27]) | Dopo un ripristino, le verifiche di [OPS-07] |
| `reorg` | Riorganizzazione della catena profonda almeno 2 blocchi (`chain_reorg_detected`, [ELC-24](connessione-electrum.md#riorganizzazioni), B-85) | Confrontare con un explorer e con i server di riserva ([OPS-11], passo 3) la punta attuale e l'altezza di biforcazione del payload. Controllare le righe `participant_reorged_out`, `utxo_spent_externally` e `pending_tx_resurrected` scritte dopo. Riprendere quando la punta ha superato l'altezza di biforcazione di almeno `deposit_confirmations` blocchi senza un'altra `chain_reorg_detected` |
| `resurrected_tx` | Una transazione dichiarata `failed` è stata confermata (`pending_tx_resurrected`, [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#riconciliazione), B-78) | La riga torna `confirmed`, ma le righe collegate restano come le aveva lasciate l'abbandono: il database dice che i fondi non si sono mossi, la catena il contrario. Secondo `kind` del payload: *prelievo* → l'utente ha ricevuto i fondi, e il prelievo risulta `failed`; gli UTXO liberati verranno segnati come spesi all'esterno dalla corroborazione, ma nel frattempo erano di nuovo spendibili: controllare che l'utente non li abbia già prelevati o scommessi con un'altra transazione; *scommessa* → i soldi sono nel montepremi del round senza partecipante: rimborso manuale ([OPS-24]); *pagamento* → il vincitore potrebbe essere stato pagato due volte: confrontare `payout_txid` del round con la catena |
| `payout_blocked` | La scommessa del vincitore non è più sulla catena ([RND-39](ciclo-di-vita-del-round.md#4--pagamento), B-82) | Il round resta in `paying_out` con `payout_blocked_at`. Verificare con un explorer che l'uscita della scommessa sia davvero spesa altrove (doppia spesa del deposito): è il segno di un probabile attacco, e le prove vanno conservate. Alla ripresa la verifica di RND-39 si ripete: se la scommessa è ancora assente, il pagamento resta bloccato e la lotteria torna in pausa |
*Codice:* `app/api/routes/admin.py:pause_lottery`,
`app/api/routes/admin.py:resume_lottery`,
`app/rounds/service.py:open_new_round_if_needed`,
`app/electrum/listener.py:ElectrumListener._apply_header`,
`app/tx/confirmation.py:poll_once`,
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* —
*Stato:* da implementare (B-85, B-78, B-82, B-90) · *Oggi:* esiste solo la
pausa dell'operatore, senza motivo; nessun evento del sistema mette in pausa
la lotteria.
### Intervento manuale sui fondi
@@ -508,11 +850,15 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
(`m/84'/746'/0'/1/0` della master key) non è esportabile dal pannello né da
alcuno script: l'unico modo è derivarla a mano dalla xprv ottenuta con
`scripts/decrypt_master_key.py`. Non c'è quindi una procedura supportata per
consolidare gli UTXO del montepremi (`too_many_inputs`) o per recuperare
resti accumulati. Spendere UTXO del montepremi fuori dal sistema non è
rilevato come spesa esterna (l'indirizzo non è in `utxo_events`) e può far
fallire il pagamento di un round in corso (`insufficient pool UTXOs` o una
trasmissione rifiutata): farlo solo senza round in `paying_out`.
consolidare gli UTXO del montepremi (`too_many_inputs`), per recuperare
resti accumulati o per rimborsare una scommessa rimasta senza partecipante.
Spendere UTXO del montepremi fuori dal sistema non è rilevato come spesa
esterna (l'indirizzo non è in `utxo_events`) e può far fallire il pagamento
di un round in corso (`insufficient pool UTXOs` o una trasmissione
rifiutata): farlo solo senza round in `paying_out`. Dopo B-80 ogni round ha
il proprio indirizzo `m/84'/746'/0'/2/<round_id>` e `m/84'/746'/0'/1/0`
diventa l'indirizzo di riserva; il pagamento di un round spende solo le
uscite delle sue scommesse.
*Codice:* `app/wallet/hd.py:derive_pool_key`,
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`
@@ -523,28 +869,72 @@ dell'applicazione non compaiono in `docker compose logs`, vedi
| Segnale | Dove | Allarme se |
|---|---|---|
| Altezza della catena | `chain_tip_height` in `GET /rounds/current` | Non cresce per più di ~10 minuti (blocchi ogni 120s): Electrum giù ([OPS-11]) |
| Stato e durata del round | `status`, `closes_at`, `draw_waiting_since` in `GET /rounds/current` | `closing` o `paying_out` per più di qualche blocco; `drawing` oltre 6 minuti ([OPS-13]–[OPS-15]) |
| Altezza della catena | `chain_tip_height` in `GET /rounds/current` | Non cresce per più di 10 minuti (blocchi ogni 120s): Electrum giù o fermo ([OPS-11]) |
| Stato e durata del round | `status`, `closes_at`, `draw_waiting_since` in `GET /rounds/current` | `closing` o `paying_out` per più di 3 blocchi (6 minuti); `drawing` oltre 6 minuti ([OPS-13]–[OPS-15]) |
| Istanza configurata | `lottery_configured` in `GET /rounds/current` | `false`: manca `fee_address`, nessun round si apre |
| Pausa dimenticata | `lottery_paused` in `GET /rounds/current` | `true` fuori da una manutenzione |
| Pagamenti ed estrazioni | Audit: `payout_failed`, `draw_stalled`, `draw_header_corroboration_failed` | Qualunque occorrenza |
| Transazioni perse | Audit: `pending_tx_abandoned` | Frequenti: commissione troppo bassa |
| Transazioni perse | Audit: `pending_tx_abandoned` | Qualunque occorrenza va verificata ([OPS-16]); frequenti: commissione troppo bassa |
| Spese esterne | Audit: `utxo_spent_externally` | Non dovute a un intervento dell'operatore: chiave dell'utente compromessa o server che mente |
| Uso del pannello | Audit: `admin_privkey_accessed`, `config_updated`, `admin_password_reset` | Non attribuibili a un operatore: `ADMIN_TOKEN` compromesso ([OPS-20]) |
| Transazioni lente | `/admin` → Transazioni pendenti | `attempt_count` alto o `broadcast_at` vecchio di ore |
| Transazioni lente | `/admin` → Transazioni pendenti | `attempt_count` alto o `broadcast_at` (primo invio) vecchio di ore |
| 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]) |
| 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
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.
## Prova su rete reale
I test automatici simulano la rete. Nessuna delle falle critiche della
revisione del 2026-09-28 sarebbe emersa da essi, e prelievo e RBF non sono mai
stati trasmessi davvero. Prima di aprire al pubblico, e dopo le correzioni di
[piano-correzioni.md](piano-correzioni.md), serve una prova su un'istanza di
staging collegata alla rete PLM reale (o, dove indicato, a una rete di prova),
con importi piccoli (costo della scommessa 0,1 PLM) e guasti provocati di
proposito.
- **[OPS-31]** L'istanza si apre al pubblico solo dopo che ogni prova della
tabella è stata eseguita e ha dato l'esito atteso. Per ogni prova si
annotano data, versione (`git rev-parse HEAD`), txid coinvolti ed esito, e
una prova con esito diverso diventa una voce di [BUGS.md](../BUGS.md).
*Codice:* —
| # | Prova | Come | Esito atteso |
|---|---|---|---|
| 1 | Ciclo completo | Due utenti, depositi, una scommessa ciascuno, un round intero | Deposito accreditato dopo `deposit_confirmations` conferme; `draw_committed` all'ingresso in `drawing`; vincitore, seme rivelato e prova di `GET /rounds/{id}/proof` ricalcolabili a mano; ripartizione 70/30 al satoshi; round `closed` e round successivo aperto |
| 2 | Prelievo reale | Prelievo parziale e prelievo dell'intero saldo | Transazione confermata; importo e resto come in [scommesse-e-prelievi.md](scommesse-e-prelievi.md); il prelievo totale lascia un resto di almeno `DUST_LIMIT_SATS` |
| 3 | Server ucciso durante un invio | Interrompere la rete del server Electrum attivo (o `docker compose kill app`) mentre una scommessa e un prelievo sono in invio | Nessun rilascio senza prova (B-76): la risposta è 202 o l'errore esplicito; la riga resta `building` e il riconciliatore la promuove se la transazione è in catena; nessun UTXO liberato per una transazione partita |
| 4 | Riavvio durante `drawing` | `docker compose restart app` con un round in `drawing` | Il round riprende da solo (B-81) con lo stesso `draw_height` e gli stessi partecipanti, e il vincitore è quello calcolabile dalla prova |
| 5 | Pagamento fermo a commissione bassa | `fee_rate_sat_vb` = 1 con mempool affollato, oppure invio del pagamento a un solo server che non lo propaga | Dopo `rbf_timeout_seconds` il pagamento viene accelerato riducendo l'uscita di riserva (B-79) e conferma; se l'aumento è impossibile compare `rbf_bump_failed` (B-87) |
| 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) |
| 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) |
| 13 | Chiave sbagliata | Avvio con una `XPRV_ENCRYPTION_KEY` diversa, poi con una master key diversa | Il server rifiuta di servire in entrambi i casi (B-89) |
| 14 | Deploy con stream aperti | `docker compose up -d --build` con pagine `/` aperte | Arresto completo entro 30 secondi senza uccisione del processo (B-93) |
## Script manuali
Si lanciano dal venv sull'host (`source .venv/bin/activate`, con
`PYTHONPATH=.`) oppure nel container con `docker compose run --rm app python
scripts/<nome>.py`. Nel container `MASTER_KEY_PATH` punta a
Si lanciano nel container con `docker compose run --rm --no-deps app python
scripts/<nome>.py`, oppure dal venv sull'host (`source .venv/bin/activate`,
con `PYTHONPATH=.`). Nel container `MASTER_KEY_PATH` punta a
`/app/key_data/master.xprv.enc`, cioè allo stesso file `./data/keys` dell'host;
dal venv conta il `MASTER_KEY_PATH` di `.env`.
dal venv conta il `MASTER_KEY_PATH` di `.env`, che su questa installazione
punta a un'altra copia (B-71, vedi [OPS-22], passo 0): per gli script sulla
master key usare il container.
- **[OPS-26]** Gli script disponibili:
@@ -553,10 +943,14 @@ dal venv conta il `MASTER_KEY_PATH` di `.env`.
| `scripts/generate_master_key.py` | Crea una master key casuale, la cifra con `XPRV_ENCRYPTION_KEY` e la scrive in `MASTER_KEY_PATH`. Si rifiuta se il file esiste già | Una sola volta, alla prima installazione ([setup.md](setup.md)) |
| `scripts/encrypt_master_key.py` | Cifra una xprv incollata (input nascosto) e la scrive in `MASTER_KEY_PATH`, con ripiego su `./data/keys/` se la cartella di `MASTER_KEY_PATH` non esiste. Rifiuta una xpub e un file già presente senza `--overwrite` | Installazione con una xprv generata altrove; ripristino da xprv in chiaro ([OPS-07]); rotazione della cifratura ([OPS-22]) |
| `scripts/decrypt_master_key.py` | Decifra e stampa la xprv, dopo aver chiesto di scrivere `yes`. Cerca il file in `MASTER_KEY_PATH`, poi in `./data/keys/master.xprv.enc` | Backup offline ([OPS-05]), rotazione ([OPS-22]), recupero dei fondi del montepremi ([OPS-24]) |
| `scripts/electrum_smoke_test.py` | Si collega **solo** al server primario (`ELECTRUM_HOST`/`PORT`/`USE_SSL`), fa l'handshake, si iscrive agli header e stampa l'altezza | Diagnosi di [OPS-11]. Per provare un server di riserva, lanciarlo con `ELECTRUM_HOST`/`ELECTRUM_PORT` impostati su quel server |
| `scripts/electrum_smoke_test.py` | Si collega **solo** al server primario (`ELECTRUM_HOST`/`PORT`/`USE_SSL`), fa l'handshake, si iscrive agli header e stampa l'altezza | Diagnosi di [OPS-11]. Per provare un altro server, lanciarlo con `-e ELECTRUM_HOST=… -e ELECTRUM_PORT=…` |
`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`.
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
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`,
`scripts/encrypt_master_key.py`, `scripts/decrypt_master_key.py`,
`scripts/electrum_smoke_test.py`
+127
View File
@@ -0,0 +1,127 @@
# Piano delle correzioni (revisione del 2026-09-28)
La revisione della specifica del 2026-09-28 (coin e catena, macchina a stati
del denaro, operatività, coerenza dei documenti) ha trovato falle logiche
nella struttura del software. Questo documento raccoglie **le decisioni prese**
e **l'ordine in cui implementarle**.
La specifica è già stata aggiornata al comportamento **atteso**. Ogni
requisito che il codice non soddisfa ancora è marcato
`*Stato:* da implementare (B-nn)` e dice in una riga cosa fa il codice oggi
(vedi [processo-di-modifica.md](processo-di-modifica.md#requisiti-non-ancora-implementati)).
Il dettaglio tecnico di ogni voce, in inglese, è in [BUGS.md](../BUGS.md).
## Le decisioni
1. **Estrazione con commit-reveal.** Il vincitore non deve essere calcolabile
da chi produce o trasmette i blocchi: la potenza di calcolo di PLM
(~22 TH/s) si può affittare. All'apertura il server si impegna su un
segreto pubblicandone l'impronta. Alla chiusura si fissano l'elenco dei
partecipanti e l'altezza del blocco. Il seme combina segreto, blocco ed
elenco, e il segreto viene rivelato dopo. Un minatore non sa chi vincerebbe
e quindi non ha motivo di scartare blocchi. L'operatore non può cambiare
il segreto dopo averne pubblicato l'impronta.
→ [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione), B-73
2. **Nel dubbio non si liberano mai i fondi.** Una transazione viene
annullata solo con una prova: un rifiuto esplicito della rete, oppure i suoi
ingressi spesi da un'altra transazione confermata. Mai per un timeout o per
il silenzio di un solo server.
→ [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md), B-76, B-77, B-78
3. **Un indirizzo del montepremi per ogni round.** Soldi, cronologia e
pagamento di un round restano separati da quelli di tutti gli altri round.
→ B-80
4. **6 conferme per i depositi** (configurabili). Tutte le altre transazioni
restano a 1: il deposito è l'unico ingresso che un utente può spendere due
volte, perché il resto lo firma solo il server.
→ [registrazione-e-depositi.md](registrazione-e-depositi.md#deposito), B-82
5. **Le riorganizzazioni della catena si contengono.** Si rilevano, si mette
in pausa la lotteria quando sono profonde, e si paga solo ciò che risulta
davvero sulla catena.
→ B-82, B-85
**Limite accettato:** un attacco al 51% su PLM resta possibile e riguarda
chiunque usi PLM, non solo la lotteria. Queste misure fanno sì che non
convenga e che non blocchi la lotteria; non lo rendono impossibile. Vedi
[decisioni-e-limiti.md](decisioni-e-limiti.md).
## Ordine di implementazione
Le fasi vanno eseguite in ordine: ognuna si appoggia sulle precedenti.
Dentro una fase l'ordine è indicativo. Ogni voce è un commit a sé, con il suo
test di regressione, secondo [processo-di-modifica.md](processo-di-modifica.md).
### Fase 1 — Fondamenta
| Voce | Cosa | Gravità |
|---|---|---|
| B-88 | Transazioni SQLite vere (`BEGIN IMMEDIATE`, `synchronous=FULL`) | alta |
| B-75 | Client Electrum: limite di 16 MiB, risposta troppo grande = errore della richiesta, UTXO sotto la soglia di dust ignorati | critica |
| B-97 | Test che esegue le migrazioni e verifica gli indici | media |
| B-80 | Indirizzo del montepremi per round | critica |
### Fase 2 — Nel dubbio non liberare
| Voce | Cosa | Gravità |
|---|---|---|
| B-84 | Quorum minimo obbligatorio (almeno 2 server di riserva, almeno 2 risposte) | alta |
| B-76 | Invio ambiguo: la riga resta `building`, invio a tutti i server | critica |
| B-77 | Tutti i txid della catena di sostituzioni; RBF salvato prima dell'invio | critica |
| B-78 | Abbandono solo con prova; le righe `failed` restano sorvegliate | critica |
| B-83 | Conferme e reintegri verificati dal quorum (prova merkle) | alta |
### Fase 3 — Estrazione
| Voce | Cosa | Gravità |
|---|---|---|
| B-74 | Header: difficoltà minima, collegamento verificato su ogni salto | critica |
| B-85 | Riorganizzazioni seguite, registrate, con pausa se profonde | alta |
| B-86 | Rotazione se il server si ferma | alta |
| B-73 | Commit-reveal, altezza fissa, conferme sul blocco di estrazione, prova pubblica | critica |
| B-81 | `drawing` riprende dopo un errore o un riavvio | critica |
### Fase 4 — Pagamento
| Voce | Cosa | Gravità |
|---|---|---|
| B-79 | Riserva per l'RBF su ogni pagamento | critica |
| B-82 | 6 conferme sui depositi, 120 sulle coinbase, verifica prima del pagamento | critica |
| B-87 | RBF che non lascia resti sotto la soglia di dust | alta |
| B-95 | Parametri del round fotografati all'apertura, tetto al montepremi | media |
### Fase 5 — Operatività
| Voce | Cosa | Gravità |
|---|---|---|
| B-71 | Una sola copia della master key e del database | bassa |
| B-72 | `setup.md` senza il falso "in locale o via Docker" | bassa |
| B-89 | Verifica della chiave all'avvio | alta |
| B-90 | Ripristino da backup sicuro | alta |
| B-92 | Readiness, stato Electrum, filtri dell'audit, log | media |
| B-93 | Deploy e rollback | media |
| B-91 | Container non root, permessi, dipendenze fissate | alta |
### Fase 6 — Rifiniture
| Voce | Cosa | Gravità |
|---|---|---|
| B-94 | Limiti su cambio password, login, bug report; errori strutturati | media |
| B-96 | Incoerenze minori (sette voci, un test ciascuna) | media |
### Fase 7 — Prova su rete reale
Nessuna delle falle critiche sarebbe stata trovata dai test attuali, che
simulano tutto. Prima di aprire al pubblico serve una prova con importi
piccoli e guasti provocati di proposito, secondo la lista di
[operativita.md](operativita.md#prova-su-rete-reale).
## Correzioni solo documentali già applicate
Nella stessa revisione sono state corrette, senza un B-nn perché il codice non
cambia:
- affermazioni false sul comportamento attuale: timeout trattato come rifiuto,
"prima o poi abbandonata", quorum "su ogni decisione", tempi "nel caso
peggiore", resto di 294 sat "accelerabile";
- due procedure operative pericolose: il ripristino con sblocco di `drawing`
(OPS-07) e la rotazione della chiave di cifratura (OPS-22);
- puntatori sbagliati e requisiti duplicati in più documenti;
- formulazioni vaghe in regole normative.
+38 -2
View File
@@ -11,8 +11,9 @@ Ogni regola verificabile ha un **identificativo stabile**, nella forma
seguito dai riferimenti all'implementazione e, dove esistono, ai test:
```markdown
- **[RND-11]** Alla scadenza il round passa subito a `closing`: da quel momento
nessuna scommessa è più accettata.
- **[RND-11]** Al primo passo dello scheduler successivo alla scadenza il round
passa a `closing`; le scommesse sono però rifiutate già dall'istante della
scadenza ([BET-04]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* `tests/unit/test_scheduler.py`
```
@@ -28,6 +29,41 @@ Regole della convenzione:
mai numeri di riga (cambiano a ogni modifica).
- Il testo descrittivo che non contiene regole (introduzioni, spiegazioni,
esempi) non ha identificativo.
- **Un solo proprietario per regola.** Ogni regola ha l'identificativo in un
solo documento; gli altri documenti che la citano rimandano a quell'ID
invece di ripeterla con un ID proprio. Il documento di dominio (round,
scommesse, transazioni…) è proprietario del comportamento, `DAT` dello
schema, `SEC` delle soglie di sicurezza.
- **Niente formulazioni vaghe nelle regole.** "Circa", "normalmente", "prima o
poi" non compaiono in un requisito: si scrive la condizione esatta, con il
nome della costante.
### Requisiti non ancora implementati
La specifica può essere più avanti del codice: è il caso normale fra il passo 1
e il passo 3 del ciclo qui sotto. Un requisito che il codice non soddisfa
ancora porta, subito dopo i riferimenti, una riga di stato e una che dice cosa
fa il codice oggi:
```markdown
- **[RND-33]** L'altezza del blocco di estrazione è fissata all'ingresso in
`drawing` come `tip_at_close + 1` e non cambia più.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* —
*Stato:* da implementare (B-73) · *Oggi:* si usa la punta vista al primo
controllo utile, che può essere un blocco successivo.
```
- *Codice* indica dove andrà l'implementazione (il file o la funzione
esistente più vicina); *Test* è `—` finché il test non esiste.
- Il B-nn rimanda alla voce di [BUGS.md](../BUGS.md) che descrive la
correzione; l'ordine di implementazione è in
[piano-correzioni.md](piano-correzioni.md).
- Quando il codice viene implementato, nello stesso commit si tolgono le righe
*Stato* e *Oggi* e si completano *Codice* e *Test*. `git grep "da
implementare"` elenca in ogni momento tutto ciò che manca.
- Un requisito modificato (non nuovo) mantiene il suo identificativo: il testo
descrive il comportamento atteso, *Oggi* quello attuale.
### Prefissi
+237 -96
View File
@@ -9,7 +9,10 @@ e saldo), secondo la convenzione di
descritti in dettaglio in [api.md](api.md); hashing, token e limiti di
frequenza in [sicurezza.md](sicurezza.md); le colonne delle tabelle in
[modello-dati.md](modello-dati.md); gli eventi di audit in
[audit-log.md](audit-log.md).
[audit-log.md](audit-log.md). Il documento descrive il comportamento
**atteso** dopo la revisione del 2026-09-28; i requisiti che il codice non
soddisfa ancora sono marcati *da implementare* (vedi
[piano-correzioni.md](piano-correzioni.md)).
## Registrazione
@@ -23,7 +26,7 @@ L'utente sceglie username e password (`POST /auth/register`). Il server:
4. si iscrive, via Electrum, alle notifiche di quell'indirizzo. Questo passo
è "al meglio": se fallisce, l'indirizzo viene comunque ripreso alla
riconnessione successiva o al giro periodico di `DepositReconciler`
(ogni 5 minuti).
([DEP-10](#dep-10)).
- **[REG-01]** Lo username è lungo da 3 a 32 caratteri, composto solo da
lettere ASCII, cifre, `_`, `.` e `-`; la password è lunga da 8
@@ -52,7 +55,8 @@ L'utente sceglie username e password (`POST /auth/register`). Il server:
riprova con un indice ricalcolato fino a 5 volte (`_MAX_REGISTER_RETRIES`),
poi la richiesta fallisce con `derivation_index_conflict` (HTTP 409).
*Codice:* `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` ·
*Test:* `tests/unit/test_hd.py`
*Test:* `tests/unit/test_hd.py` (solo la derivazione dell'indirizzo:
nessun test copre i tentativi ripetuti né `derivation_index_conflict`)
- **[REG-05]** L'indirizzo è **permanente** e unico per utente, ed è allo
stesso tempo indirizzo di deposito, destinazione delle vincite e
destinazione del resto di ogni scommessa e prelievo: non esiste un
@@ -73,51 +77,81 @@ L'utente sceglie username e password (`POST /auth/register`). Il server:
periodico di [DEP-10](#dep-10).
*Codice:* `app/electrum/listener.py:ElectrumListener.address_for_new_user` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[REG-08]** La registrazione è limitata a **5 account all'ora per
indirizzo IP**. Il limite è verificato prima di calcolare l'hash, e conta
solo le registrazioni riuscite; oltre il limite la risposta è
`rate_limited` (HTTP 429) con `retry_after_seconds`. I dettagli sono in
[sicurezza.md](sicurezza.md).
*Codice:* `app/auth/routes.py:register`, `app/auth/rate_limit.py:AuthRateLimiters` ·
*Test:* `tests/unit/test_auth.py`
- ~~[REG-08]~~ Assorbito da [SEC-31](sicurezza.md#limiti-di-frequenza):
la quota di registrazioni per IP (soglia, finestra, momento del controllo,
risposta `rate_limited`) è una soglia di sicurezza e ha lì il suo unico
proprietario; qui non resta nulla di specifico del dominio.
- **[REG-15]** **Indici già usati dopo un ripristino.** Un database
ripristinato da un backup non conosce gli utenti registrati dopo il backup,
e riassegnerebbe i loro indici — e quindi i loro indirizzi, con i fondi che
contengono — a utenti nuovi. Per questo, all'avvio, appena la connessione
Electrum è disponibile, si controllano i **20 indici successivi** al
massimo indice assegnato (da 0 a 19 se non c'è alcun utente): per ciascun
indirizzo si chiede la cronologia (`blockchain.scripthash.get_history`) al
server attivo e a ogni server del quorum, e basta che **uno** riporti una
transazione perché l'indirizzo conti come usato. Se almeno un indirizzo è
usato si scrive `restore_gap_detected` (indici e indirizzi con
cronologia) e la registrazione viene rifiutata con
`registration_unavailable` (HTTP 503) finché, a un avvio successivo, il
controllo non trova più alcun indirizzo usato: l'operatore deve prima
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.
- **[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.
### Sessione e password
Il login (`POST /auth/login`) restituisce un token JWT valido 24 ore. Cambiare
la password (dal Profilo) o farsela reimpostare dall'operatore invalida
**tutte** le sessioni aperte in precedenza.
Il login (`POST /auth/login`) restituisce un token di sessione. Cambiare la
password (dal Profilo) o farsela reimpostare dall'operatore invalida
**tutte** le sessioni aperte in precedenza. Formato, durata e revoca del token
sono regole di sicurezza ([SEC-21, SEC-22](sicurezza.md#token-di-sessione-jwt));
il freno sui tentativi di login falliti è
[SEC-30](sicurezza.md#limiti-di-frequenza).
- **[REG-09]** Il login confronta lo username senza distinguere maiuscole e
minuscole. Username inesistente e password sbagliata producono la stessa
risposta, `invalid_credentials` (HTTP 401), così che non si possa scoprire
quali account esistono. Una risposta riuscita contiene `access_token` e
l'indirizzo dell'utente.
*Codice:* `app/auth/routes.py:login` ·
l'indirizzo dell'utente. Username e password del login sono lunghi al
massimo 256 caratteri ciascuno; oltre, la richiesta è rifiutata con HTTP 422
prima di qualunque verifica.
*Codice:* `app/auth/routes.py:login`, `app/auth/routes.py:LoginRequest` ·
*Test:* `tests/unit/test_auth.py`
- **[REG-10]** I tentativi di login falliti sono frenati sia per username sia
per IP; durante il blocco la risposta è `rate_limited` (HTTP 429) con
`retry_after_seconds`. Un login riuscito azzera il contatore dello username,
non quello dell'IP. Soglie e tempi sono in [sicurezza.md](sicurezza.md).
*Codice:* `app/auth/routes.py:login`, `app/auth/rate_limit.py:RateLimiter` ·
*Test:* `tests/unit/test_auth.py`
- **[REG-11]** Il token di sessione scade dopo 24 ore (`jwt_expire_minutes`)
e porta con sé la versione del token dell'utente (`token_version`). Un token
la cui versione non coincide più con quella dell'utente, oppure scaduto,
malformato o di un utente inesistente, è rifiutato con `session_expired`
(HTTP 401) dagli endpoint autenticati, e trattato come "non autenticato"
dagli endpoint accessibili anche senza login. Un token emesso prima che la
versione esistesse vale come versione 0.
*Codice:* `app/auth/security.py:create_access_token`, `app/auth/security.py:decode_access_token`, `app/auth/dependencies.py:get_current_user`, `app/auth/dependencies.py:get_optional_user` ·
*Test:* `tests/unit/test_security.py`, `tests/unit/test_users.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.
- ~~[REG-11]~~ Assorbito da [SEC-22](sicurezza.md#token-di-sessione-jwt):
scadenza, versione del token e rifiuto con `session_expired` sono regole di
sicurezza (SEC-21, SEC-22, SEC-23) e non resta nulla di specifico del
dominio.
- **[REG-12]** Il cambio password (`POST /users/me/change-password`, dal
Profilo) richiede la password attuale — altrimenti `current_password_incorrect`
(HTTP 401) — e una nuova password di almeno 8 caratteri — altrimenti
`password_too_short` (HTTP 400). Se riesce, incrementa `token_version`,
invalidando **tutte** le sessioni aperte, compresa quella della richiesta, e
`password_too_short` (HTTP 400). Entrambi i campi sono lunghi al massimo
256 caratteri, altrimenti HTTP 422. I tentativi con password attuale
sbagliata sono frenati come il login (soglie in
[sicurezza.md](sicurezza.md#limiti-di-frequenza), B-94). Se riesce,
incrementa `token_version`, invalidando **tutte** le sessioni aperte,
compresa quella della richiesta, scrive l'evento `password_changed` e
restituisce un token nuovo perché la sessione corrente continui senza nuovo
login.
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.
- **[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
@@ -130,63 +164,116 @@ la password (dal Profilo) o farsela reimpostare dall'operatore invalida
### Il conto del montepremi
Oltre agli indirizzi utente, dalla stessa master key si deriva un indirizzo
riservato al **montepremi**. Tutte le scommesse vengono inviate lì, e i
pagamenti ai vincitori sono firmati con la sua chiave.
Oltre agli indirizzi utente, dalla stessa master key si derivano gli indirizzi
del **montepremi**: uno per ogni round, su cui arrivano le scommesse di quel
round e da cui parte il suo pagamento, e un indirizzo di **riserva** che
riceve la riserva per l'RBF di ogni pagamento
([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento), B-79).
- **[REG-14]** L'indirizzo del montepremi è il P2WPKH del ramo `1/0`
dell'account (`m/84'/746'/0'/1/0`), mentre gli utenti stanno sul ramo `0`.
Le sue monete non sono registrate tra le monete degli utenti: il pagamento
le legge direttamente dalla rete (vedi
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento)).
- **[REG-14]** Gli utenti stanno sul ramo `0` dell'account
(`m/84'/746'/0'/0/<indice>`). Il montepremi di un round è il P2WPKH di
`m/84'/746'/0'/2/<round_id>`, salvato sul round all'apertura; l'indirizzo
di riserva è il P2WPKH di `m/84'/746'/0'/1/0`, che è anche il montepremi
dei round aperti prima di questa modifica. Le loro monete non sono
registrate tra le monete degli utenti: il pagamento le legge dalla rete e
le verifica con il quorum
([ELC-27](connessione-electrum.md#5-il-quorum)).
*Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address` ·
*Test:* `tests/unit/test_pending_address.py`
*Stato:* da implementare (B-80) · *Oggi:* esiste un solo indirizzo del
montepremi, `m/84'/746'/0'/1/0`, per tutti i round.
## Deposito
L'utente invia PLM al proprio indirizzo da qualunque wallet esterno. Non c'è
un importo minimo.
L'utente invia PLM al proprio indirizzo da qualunque wallet esterno.
Un **deposito** è una moneta arrivata all'indirizzo di un utente da una
transazione che la piattaforma non ha costruito. Le monete prodotte dalle
transazioni della piattaforma — il resto di scommesse e prelievi, le
vincite — arrivano allo stesso indirizzo e passano per lo stesso codice, ma
con la soglia di conferme delle transazioni della piattaforma (1): il
deposito è l'unico ingresso che un utente può spendere due volte
([DEC-06](decisioni-e-limiti.md)).
```mermaid
flowchart TB
A["Arriva una notifica Electrum<br/>per l'indirizzo, oppure<br/>il giro periodico di controllo"] --> B["Si legge l'elenco delle<br/>monete (UTXO) dell'indirizzo<br/>dal server attivo"]
B --> C{"Moneta nuova<br/>con almeno 1 conferma?"}
C -- "No" --> Z["Nulla da fare"]
C -- "Sì" --> D{"La maggioranza degli<br/>altri server conferma stessa<br/>moneta, stesso importo?"}
D -- "No / non rispondono" --> R["Accredito rinviato<br/>al prossimo controllo"]
A["Notifica Electrum per l'indirizzo,<br/>(re)iscrizione, oppure<br/>giro periodico (DEP-09)"] --> L["Aggiornamenti dello stesso<br/>utente in fila (DEP-15)"]
L --> B["Si legge l'elenco delle<br/>monete (UTXO) dell'indirizzo<br/>dal server attivo"]
B --> DU{"Moneta sotto<br/>DUST_LIMIT_SATS?"}
DU -- "Sì" --> Z["Ignorata (DEP-01)"]
DU -- "No" --> C{"Moneta nuova<br/>e confermata?"}
C -- "No" --> Z2["Nulla da fare"]
C -- "Sì" --> K["Soglia (DEP-03):<br/>1 se la transazione è della piattaforma,<br/>120 se coinbase,<br/>altrimenti deposit_confirmations"]
K --> KK{"Conferme ≥ soglia?"}
KK -- "No" --> P["Mostrata come in attesa<br/>nel saldo mostrato (DEP-14)"]
KK -- "Sì" --> D{"Il quorum degli altri<br/>server conferma moneta,<br/>importo e conferme?"}
D -- "No / meno di 2 risposte" --> R["Accredito rinviato<br/>al prossimo controllo"]
D -- "Sì" --> E["Moneta registrata,<br/>saldo ricalcolato<br/>(deposit_credited)"]
B --> F{"Una moneta nota<br/>non spesa è sparita?"}
F -- "Sì, e la maggioranza degli<br/>altri server la conferma spesa" --> G["Segnata come spesa<br/>fuori dalla piattaforma<br/>(utxo_spent_externally)"]
F -- "Sì, e il quorum<br/>la conferma spesa" --> G["Segnata come spesa<br/>fuori dalla piattaforma<br/>(utxo_spent_externally)"]
B --> H{"Una moneta data per spesa<br/>fuori è ricomparsa?"}
H -- "Sì" --> I["Reintegrata<br/>(utxo_external_spend_reinstated)"]
H -- "Sì, e il quorum<br/>la conferma non spesa" --> I["Reintegrata<br/>(utxo_external_spend_reinstated)"]
```
Le vincite arrivano allo stesso modo: il pagamento del round è una normale
transazione verso l'indirizzo del vincitore, e viene accreditato come un
deposito. Lo stesso vale per il resto di scommesse e prelievi, che torna
all'indirizzo dell'utente.
*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.
- **[DEP-01]** Non c'è un importo minimo di deposito.
*Codice:* `app/deposits/service.py:credit_confirmed_utxos`
- **[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
viene mai accreditata né mostrata, e non entra in alcun altro controllo di
questo documento. Non è spendibile in modo economico e, se accreditata,
farebbe crescere senza limite il lavoro di ogni aggiornamento; chiunque
può inviarne a qualunque indirizzo pubblico.
*Codice:* `app/deposits/service.py:find_new_credit_candidates`, `app/deposits/service.py:credit_confirmed_utxos`, `app/wallet/psbt_builder.py:DUST_LIMIT_SATS` · *Test:* —
*Stato:* da implementare (B-75) · *Oggi:* si accredita una moneta di
qualunque importo.
- **[DEP-02]** Un aggiornamento dell'indirizzo di un utente legge l'elenco
delle monete non spese (`listunspent`) dal server Electrum attivo ed esegue,
in quest'ordine: individuazione degli accrediti nuovi, reintegro delle
monete ricomparse, individuazione delle monete sparite; poi corrobora
accrediti e sparizioni presso gli altri server e solo dopo scrive il
risultato. Nessuna sessione del database resta aperta durante le chiamate
di rete.
delle monete non spese (`listunspent`) dal server Electrum attivo e procede
in tre fasi. **Lettura**, senza scrivere nulla: individuazione degli
accrediti nuovi, delle monete ricomparse e delle monete sparite.
**Corroborazione** presso gli altri server di accrediti
([ELC-17](connessione-electrum.md#5-il-quorum)), reintegri
([ELC-25](connessione-electrum.md#5-il-quorum)) e sparizioni
([ELC-16](connessione-electrum.md#5-il-quorum)). **Scrittura** dei soli
casi corroborati, ricontrollando lo stato di ogni moneta al salvataggio.
Nessuna sessione del database resta aperta durante le chiamate di rete.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` ·
*Test:* `tests/unit/test_electrum_listener.py`
- **[DEP-03]** **Una conferma basta.** Una moneta è candidata all'accredito
*Stato:* da implementare (B-83) · *Oggi:* il reintegro delle monete
ricomparse viene **scritto e committato già nella fase di lettura**, prima
di qualunque corroborazione; solo accrediti e sparizioni sono corroborati
prima di essere scritti.
- **[DEP-03]** **Soglia di conferme.** Una moneta è candidata all'accredito
solo se è confermata (altezza maggiore di 0: le monete ancora in mempool
sono ignorate) e non è già registrata. Il rischio di una riorganizzazione
della catena a 1 conferma è accettato consapevolmente; non c'è logica di
annullamento (vedi [decisioni-e-limiti.md](decisioni-e-limiti.md)).
*Codice:* `app/deposits/service.py:find_new_credit_candidates` ·
sono ignorate), non è già registrata e ha raggiunto la sua soglia di
conferme. Le conferme sono `punta − altezza + 1`, sulla punta validata del
listener ([connessione-electrum.md](connessione-electrum.md#4-header-validati-non-creduti)).
La soglia è:
- **1** se la transazione che l'ha creata è una transazione della
piattaforma (uno qualunque dei txid di una riga di
`pending_transactions`, compresi quelli sostituiti da un RBF,
[modello-dati.md](modello-dati.md) B-77): resto di scommesse e prelievi,
vincite;
- **120** (la maturità delle coinbase di PLM, `COINBASE_MATURITY` in
`palladiumcore/src/consensus/consensus.h`) se la transazione è una
coinbase, cioè il suo unico ingresso ha l'outpoint nullo. Lo si stabilisce
dalla transazione grezza (`blockchain.transaction.get`), accettata solo se
il suo hash coincide con il txid;
- **`deposit_confirmations`** (`round_config`, predefinito 6,
[modello-dati.md](modello-dati.md) B-82) in ogni altro caso.
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.
- **[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, confermata
esiste davvero, con quell'importo e con le conferme richieste
([ELC-17](connessione-electrum.md#5-il-quorum)). Un server malevolo non può
quindi inventarsi un deposito. Se la verifica non riesce l'accredito non è
perso, solo rimandato: la moneta viene riproposta all'aggiornamento
@@ -210,8 +297,8 @@ all'indirizzo dell'utente.
incompleta, non come una spesa di tutto.
*Codice:* `app/deposits/service.py:find_utxos_missing_from` ·
*Test:* `tests/unit/test_deposits.py`
- **[DEP-07]** Un candidato viene tolto dal saldo solo dopo che la
maggioranza degli altri server conferma che la moneta non è più non spesa
- **[DEP-07]** Un candidato viene tolto dal saldo solo dopo che il quorum
degli altri server conferma che la moneta non è più non spesa
([ELC-16](connessione-electrum.md#5-il-quorum)). Al salvataggio si
ricontrolla che la moneta sia ancora non spesa (nel frattempo potrebbe
averla spesa la piattaforma); la si segna con il marcatore di spesa esterna,
@@ -220,12 +307,18 @@ all'indirizzo dell'utente.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/deposits/service.py:mark_utxos_spent_externally` ·
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`
- **[DEP-08]** **Reintegro.** Una moneta segnata come spesa fuori dalla
piattaforma che ricompare nell'elenco viene reintegrata: il marcatore è
piattaforma che ricompare nell'elenco viene reintegrata solo dopo che il
quorum degli altri server la conferma non spesa, con lo stesso importo e
confermata, come per un accredito
([ELC-25](connessione-electrum.md#5-il-quorum)). Al salvataggio si
ricontrolla che porti ancora il marcatore di spesa esterna; il marcatore è
rimosso, si scrive `utxo_external_spend_reinstated`, si ricalcola il saldo e
si invia una notifica SSE. Il reintegro si basa sulla sola risposta del
server attivo, senza corroborazione.
*Codice:* `app/deposits/service.py:reinstate_reappeared_utxos` ·
si invia una notifica SSE. Se la verifica non riesce la moneta resta fuori
dal saldo e viene riproposta all'aggiornamento successivo.
*Codice:* `app/deposits/service.py:reinstate_reappeared_utxos`, `app/electrum/listener.py:ElectrumListener.refresh_user` ·
*Test:* `tests/unit/test_deposits.py`
*Stato:* da implementare (B-83) · *Oggi:* il reintegro si basa sulla sola
risposta del server attivo, senza corroborazione.
- **[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
@@ -234,13 +327,36 @@ all'indirizzo dell'utente.
*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`
- **[DEP-10]** <a id="dep-10"></a>Il giro periodico (`DepositReconciler`)
attende 300 secondi, poi riaggiorna **tutti** gli utenti, non solo quelli
che risultano non iscritti: serve a recuperare un'iscrizione persa
silenziosamente. Il giro salta se non c'è connessione, si interrompe se la
connessione cade a metà (la reiscrizione alla riconnessione copre tutti), e
l'errore su un utente viene registrato senza fermare gli altri.
riaggiorna **tutti** gli utenti, non solo quelli che risultano non
iscritti: serve a recuperare un'iscrizione persa silenziosamente. Prima di
ogni giro, incluso il primo, attende 300 s (`_SWEEP_INTERVAL_SECONDS`)
dalla fine del giro precedente (per il primo, dall'avvio); i giri sono
seriali, e dentro un giro gli utenti sono aggiornati uno alla volta. Il giro
salta se non c'è connessione, si interrompe se la connessione cade a metà
(la reiscrizione alla riconnessione copre tutti), e l'errore su un utente
viene registrato senza fermare gli altri.
*Codice:* `app/deposits/reconcile.py:DepositReconciler.run`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
*Test:* `tests/unit/test_deposit_reconcile.py`
- **[DEP-15]** Gli aggiornamenti dello **stesso** utente sono serializzati:
una notifica, la reiscrizione e il giro periodico possono chiederli insieme,
ma ne gira uno alla volta per utente, in ordine di arrivo. Il blocco è
proprio dell'aggiornamento e distinto da quello per-utente di scommesse e
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.
- **[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à
txid/posizione all'accredito ([DEP-05]) vale come "moneta già registrata":
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.
## Il saldo mostrato
@@ -248,19 +364,23 @@ Scommesse e prelievi spendono monete **intere**: per pagare 10 PLM con una
moneta da 50, la moneta da 50 viene spesa tutta e 40 tornano all'utente come
resto (nella scommessa la commissione è dedotta dai 10 PLM, vedi
[scommesse-e-prelievi.md](scommesse-e-prelievi.md#cosa-succede)), in una nuova
moneta che però è confermata solo al blocco successivo.
moneta che però è confermata solo al blocco successivo. Allo stesso modo un
deposito resta non spendibile finché non raggiunge la sua soglia di conferme
([DEP-03]).
Per questo esistono due saldi, entrambi restituiti da `GET /users/me`:
| Campo | Cosa conta | A cosa serve |
|---|---|---|
| `balance_sats` | Solo monete confermate e non spese | È ciò che si può spendere: prelievo massimo, controlli sulla scommessa |
| `pending_balance_sats` | Il precedente **più** il resto delle proprie transazioni ancora in corso | È ciò che l'interfaccia mostra |
| `balance_sats` | Solo monete accreditate e non spese | È ciò che si può spendere: prelievo massimo, controlli sulla scommessa |
| `pending_balance_sats` | Il precedente **più** il resto delle proprie transazioni ancora in corso **più** i depositi confermati ma sotto la soglia | È ciò che l'interfaccia mostra |
Senza il secondo, subito dopo una scommessa l'utente vedrebbe sparire l'intero
resto (spesso molto più dei 10 PLM giocati) fino alla conferma. L'interfaccia
mostra il saldo in **verde** quando tutto è confermato e in **ambra** finché
c'è qualcosa in corso (`has_pending`).
resto (spesso molto più dei 10 PLM giocati) fino alla conferma, e un
deposito resterebbe invisibile finché non raggiunge `deposit_confirmations`
conferme. L'interfaccia mostra il saldo
in **verde** quando tutto è confermato e in **ambra** finché c'è qualcosa in
corso (`has_pending`).
- **[DEP-11]** Il saldo confermato (`balance_sats`, colonna
`cached_balance_sats`) è la somma delle monete registrate dell'utente non
@@ -269,19 +389,40 @@ c'è qualcosa in corso (`has_pending`).
*Codice:* `app/wallet/balance.py:recompute_balance` ·
*Test:* `tests/unit/test_deposits.py`
- **[DEP-12]** Il saldo mostrato (`pending_balance_sats`) è il saldo
confermato più le uscite verso l'indirizzo dell'utente di ogni sua
confermato più: (a) le uscite verso l'indirizzo dell'utente di ogni sua
scommessa o prelievo in stato `building` o `pending`, lette dalla
transazione corrente (aggiornata a ogni RBF). Un'uscita già registrata come
moneta (perché il resto è stato accreditato prima che la transazione
risultasse confermata) non viene contata due volte. `has_pending` è vero
finché esiste almeno una di queste transazioni.
transazione corrente (aggiornata a ogni RBF); (b) i depositi in attesa di
[DEP-14]. Un'uscita già registrata come moneta (perché il resto è stato
accreditato prima che la transazione risultasse confermata) non viene
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`
- **[DEP-13]** Ogni spesa (scommessa, prelievo) usa solo monete confermate e
*Stato:* da implementare (B-82), per la parte (b) · *Oggi:* conta solo la
parte (a).
- **[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
tiene, per ogni utente, l'insieme dei depositi in attesa visti all'ultimo
aggiornamento, sostituendolo a ogni aggiornamento: un deposito sparito
dall'elenco (ad esempio per una riorganizzazione) smette di essere mostrato,
uno accreditato passa nel saldo confermato. L'insieme sta solo in memoria —
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.
- **[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 coperto dal saldo mostrato ma non ancora da quello
confermato, riceve l'errore `balance_pending_confirmation` ("attendi la
conferma") invece di un generico "saldo insufficiente" che contraddirebbe
ciò che vede ([WDR-05](scommesse-e-prelievi.md#prelievo)).
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
chiede un prelievo o una scommessa coperti dal saldo mostrato ma non ancora
da quello confermato, riceve l'errore `balance_pending_confirmation`
("attendi la conferma") invece di un generico "saldo insufficiente" che
contraddirebbe ciò che vede ([WDR-05](scommesse-e-prelievi.md#prelievo); per
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`.
+201 -80
View File
@@ -29,124 +29,206 @@ Prima dei controlli valgono due condizioni di contorno:
- **[BET-01]** Se il server non è connesso alla rete, la scommessa è
rifiutata con `network_unavailable` (HTTP 503) prima di qualunque altro
controllo.
*Codice:* `app/api/routes/bets.py:create_bet`
*Codice:* `app/api/routes/bets.py:create_bet` ·
*Test:* — (nessun test verifica `network_unavailable`)
- **[BET-02]** Tutta l'elaborazione di una scommessa, dai controlli all'invio,
avviene tenendo il **blocco per-utente**, lo stesso usato dal prelievo
([WDR-01](#prelievo)).
*Codice:* `app/api/routes/bets.py:create_bet`, `app/tx/locks.py:UserLocks.acquire`
([WDR-01](#prelievo)). Il client della rete usato per la scommessa
(`listener.client`) viene letto **dopo** aver acquisito il blocco, così
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.
Una scommessa (`POST /bets`, senza corpo) passa solo se **tutte** queste
condizioni sono vere, verificate nell'ordine della tabella; altrimenti viene
rifiutata con il codice indicato (HTTP 400), prima che si muova qualunque
cifra:
cifra. Il **costo** è quello del round ([BET-21]):
| ID | Condizione | Codice di errore |
|---|---|---|
| **[BET-03]** | C'è un round attivo. Se non ce n'è nessuno e le condizioni di apertura sono soddisfatte ([RND-03](ciclo-di-vita-del-round.md#0--apertura)), è la scommessa stessa ad aprirlo | `no_round_open` |
| **[BET-04]** | Il round è in stato `open` e la sua scadenza non è ancora passata | `round_closing` |
| **[BET-03]** | C'è un round attivo. Se non ce n'è nessuno e le condizioni di apertura sono soddisfatte ([RND-04](ciclo-di-vita-del-round.md#0--apertura)), è la scommessa stessa ad aprirlo | `no_round_open` |
| **[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-07]** | La somma delle monete confermate e non spese copre il costo della scommessa (`bet_amount_sats`) | `insufficient_balance` (parametro `required_sats`) |
| **[BET-08]** | Il costo si copre con al massimo 50 monete (`MAX_TX_INPUTS`), scelte dalla più grande | `too_many_inputs` (parametro `max_inputs`) |
| **[BET-09]** | Dopo la scommessa resta un resto di almeno 294 sat (`DUST_LIMIT_SATS`) | `balance_leaves_no_change` (parametro `required_extra_sats`) |
| **[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-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` |
*Codice:* `app/bets/service.py:place_bet`, `app/rounds/service.py:round_accepts_bets`,
`app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
(nessun test verifica `no_round_open`)
La **scadenza** è quella del round stesso (apertura + durata fissata
all'apertura), non il suo stato nel database: lo scheduler controlla ogni 5
secondi, quindi per qualche istante il round potrebbe risultare ancora
"aperto" oltre la scadenza.
Lo stato nel database non basta per la scadenza ([BET-04]) perché lo
scheduler controlla ogni 5 secondi: fino a 5 secondi il round può risultare
ancora `open` oltre la scadenza.
La selezione si ferma alla prima moneta che porta il totale a coprire il
costo ([BET-08]): il resto di [BET-09] si misura su quelle monete, non
sull'intero saldo.
- **[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.
- **[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
transazione del database di un *compare-and-set* sulla riga del round
transazione del database — aperta con `BEGIN IMMEDIATE`
([TX-28](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia))
— di un *compare-and-set* sulla riga del round
(`UPDATE rounds … WHERE status = 'open'`): se il round non è più aperto in
quell'istante esatto, o il database segnala un conflitto di scrittura, tutto
viene annullato prima dell'invio e la scommessa è rifiutata con
`round_closing`. Così nessuna scommessa può finire nel montepremi senza
essere inclusa nell'estrazione.
quell'istante esatto tutto viene annullato prima dell'invio e la scommessa è
rifiutata con `round_closing`. Così nessuna scommessa può finire nel
montepremi senza essere inclusa nell'estrazione. Un `OperationalError` in
questo punto non è un conflitto di scrittura (SQLite non ne produce): è il
blocco in scrittura non ottenuto entro 5 secondi (`database is locked`).
Anche in quel caso tutto viene annullato prima dell'invio, ma la risposta è
`service_busy` (HTTP 503), perché il round non è necessariamente in
chiusura.
*Codice:* `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`
*Stato:* da implementare (B-88) · *Oggi:* la transazione si apre con la
prima scrittura, non con `BEGIN IMMEDIATE`; un `OperationalError` viene
trattato come un conflitto di scrittura e risponde `round_closing`.
- **[BET-22]** `MAX_PARTICIPANTS_PER_ROUND` è minore di
`MAX_PAYOUT_TX_INPUTS`. Il pagamento spende una moneta del montepremi per
ogni scommessa, e una singola transazione di pagamento può spenderne al
massimo 500; bloccare il 401° giocatore all'ingresso ([BET-06]) fa sì che
il numero di monete non impedisca mai di pagare un round. La differenza
lascia spazio ad altre monete presenti sull'indirizzo del montepremi del
round (pagamenti arrivati da fuori).
*Codice:* `app/wallet/psbt_builder.py:MAX_PARTICIPANTS_PER_ROUND`, `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_bets.py`
Il limite di **400 partecipanti** esiste perché il pagamento deve spendere una
moneta del montepremi per ogni scommessa, e una singola transazione di
pagamento può contenerne al massimo 500 (`MAX_PAYOUT_TX_INPUTS`); la
differenza lascia spazio ai resti accumulati nel montepremi dai round
precedenti. Bloccare il 401° giocatore all'ingresso garantisce che ogni round
si possa sempre pagare.
Oggi il montepremi è un solo indirizzo per tutti i round, e la differenza fra
400 e 500 copre anche i resti accumulati dai pagamenti precedenti; con un
indirizzo per round
([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-80) restano solo i
pagamenti arrivati da fuori. Il numero di ingressi non è l'unica cosa che può
impedire un pagamento: vedi
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento).
### Cosa succede
Comportamento atteso:
```mermaid
flowchart TB
A["Richiesta di scommessa"] --> N{"Connessi<br/>alla rete?"}
A["Richiesta di scommessa"] --> B["Blocco per-utente acquisito"]
B --> N{"Connessi<br/>alla rete?"}
N -- "No" --> N1["network_unavailable (503)"]
N -- "Sì" --> B["Blocco per-utente acquisito"]
B --> C["Controlli (tabella sopra)"]
N -- "Sì" --> C["Controlli (tabella sopra)"]
C --> D["Scelta delle monete,<br/>dalla più grande"]
D --> E["Transazione firmata:<br/>costo meno commissione → montepremi<br/>resto → indirizzo utente"]
D --> E["Transazione firmata:<br/>costo meno commissione → montepremi del round<br/>resto → indirizzo utente"]
E --> CAS{"Scadenza non passata e<br/>round ancora 'open'<br/>(compare-and-set)?"}
CAS -- "No" --> CAS1["Annullato prima dell'invio:<br/>round_closing"]
CAS -- "Sì" --> F["Salvataggio PRIMA dell'invio:<br/>monete segnate come spese,<br/>partecipante 'building'"]
F --> G{"La rete accetta<br/>l'invio?"}
G -- "No" --> H["Tutto annullato: monete liberate,<br/>saldo ripristinato,<br/>partecipante rimosso (errore 502)"]
G -- "Sì" --> I["Partecipante 'broadcast'<br/>(in attesa)"]
F --> G{"Risposta del server"}
G -- "Rifiuto esplicito" --> H["Tutto annullato: monete liberate,<br/>saldo ripristinato,<br/>partecipante rimosso (errore 502)"]
G -- "Nessuna risposta<br/>(timeout, connessione caduta)" --> U["Nulla liberato: partecipante 'building'<br/>(202), decide il riconciliatore"]
G -- "Accettata o già nota" --> I["Partecipante 'broadcast'<br/>(in attesa), inviata anche<br/>agli altri server"]
U --> R{"Il riconciliatore<br/>la trova in rete?"}
R -- "Sì" --> I
R -- "No, e c'è la prova<br/>che non è partita" --> X
I --> J{"Confermata entro<br/>il tempo limite?"}
J -- "No" --> K["Commissione aumentata (RBF),<br/>pagata dal resto dell'utente"] --> J
J -- "Mai, e la rete non<br/>la conosce più" --> X["Abbandonata dal riconciliatore:<br/>monete liberate, partecipante rimosso"]
J -- "Sì" --> L["Partecipante confermato:<br/>entra nell'estrazione"]
J -- "No" --> K["Commissione aumentata (RBF),<br/>pagata dal resto dell'utente;<br/>un resto che finirebbe sotto 294 sat<br/>va tutto in commissione"] --> J
J -- "Mai, e c'è la prova<br/>che non è partita" --> X["Abbandonata dal riconciliatore:<br/>monete liberate, partecipante rimosso"]
J -- "Sì (prova merkle del quorum)" --> L["Partecipante confermato:<br/>entra nell'estrazione"]
```
- **[BET-12]** La transazione spende monete **intere** dell'utente, scelte
dalla più grande; tutti gli ingressi segnalano la sostituibilità (BIP125,
`RBF_SEQUENCE`). Ha due uscite: il costo meno la commissione verso
l'indirizzo del montepremi, e il resto (monete spese meno il costo) verso
l'indirizzo dell'utente.
*Codice:* `app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction` ·
l'**indirizzo del montepremi del round** (`rounds.pool_address`,
[RND-35](ciclo-di-vita-del-round.md#0--apertura)), e il resto (monete
spese meno il costo) verso l'indirizzo dell'utente.
*Codice:* `app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction`, `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_psbt_builder.py`
*Stato:* da implementare (B-80) · *Oggi:* tutte le scommesse di tutti i
round pagano lo stesso indirizzo (`derive_pool_address`,
`m/84'/746'/0'/1/0`).
- **[BET-13]** La **commissione di rete** è la dimensione stimata della
transazione per `fee_rate_sat_vb` (1 sat/vB predefinito, modificabile da
`/admin`) ed è **dedotta dal costo** della scommessa: al montepremi arrivano
10 PLM meno la commissione, e l'importo registrato per il partecipante
`/admin`) ed è **dedotta dal costo** della scommessa: al montepremi arriva
il costo meno la commissione, e l'importo registrato per il partecipante
(`bet_amount_sats`) è proprio questo netto. Per questo il montepremi si
calcola sommando gli importi effettivi delle scommesse, non moltiplicando il
numero di giocatori per il costo.
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction`, `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_psbt_builder.py`, `tests/unit/test_rounds_route.py`
- **[BET-14]** Il **resto** torna sempre allo stesso indirizzo dell'utente ed è
sempre di almeno 294 sat: senza un'uscita di resto non si potrebbe aumentare
la commissione in caso di ritardo. A differenza del prelievo, la scommessa
non riduce mai il proprio importo per far posto al resto: la rifiuta
([BET-09]). Il saldo di un utente che scommette deve quindi essere
strettamente maggiore del costo.
sempre di almeno 294 sat (`DUST_LIMIT_SATS`). A differenza del prelievo, la
scommessa non riduce mai il proprio importo per far posto al resto: la
rifiuta ([BET-09]). Le monete scelte devono quindi superare il costo di
almeno 294 sat. Questo resto è l'uscita che l'RBF riduce o elimina, secondo
la regola di
[TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta):
un resto di 294 sat non regge una riduzione (l'aumento minimo è di 141 sat a
1 sat/vB), quindi garantisce una sola accelerazione, che lo elimina.
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
*Test:* `tests/unit/test_withdrawals.py` (i test del resto delle scommesse
sono lì: `test_a_bet_from_a_balance_equal_to_the_bet_is_refused`,
`test_a_bet_with_a_dust_limit_of_headroom_is_accepted`),
`tests/unit/test_psbt_builder.py`
- **[BET-15]** **Prima si scrive, poi si invia.** Prima dell'invio, in
un'unica transazione del database, le monete spese vengono segnate come tali
(con il txid della scommessa), il saldo viene ricalcolato, e si registrano il
partecipante (stato `building`, con l'orario di invio che ne determina
l'ordine nell'estrazione) e la transazione in corso (stato `building`).
partecipante (stato `building`, con l'orario di invio `broadcast_at`) e la
transazione in corso (stato `building`). L'ordine dei partecipanti
nell'estrazione è definito in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione).
Dettagli in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia).
*Codice:* `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`
- **[BET-16]** Se l'invio fallisce per qualunque motivo, tutto torna com'era:
monete liberate, partecipante e transazione in corso **cancellati**, saldo
ricalcolato, evento `bet_broadcast_failed` nel registro di audit, notifica
SSE; la risposta è `broadcast_failed` (HTTP 502).
- **[BET-16]** Se il server **rifiuta esplicitamente** l'invio
([TX-04](transazioni-e-riconciliazione.md#invio-rifiutato)), tutto torna
com'era: monete liberate, partecipante e transazione in corso
**cancellati**, saldo ricalcolato, evento `bet_broadcast_failed` nel
registro di audit, notifica SSE; la risposta è `broadcast_failed`
(HTTP 502).
*Codice:* `app/bets/service.py:_release_failed_bet`, `app/api/routes/bets.py:create_bet` ·
*Test:* `tests/unit/test_bets.py`
- **[BET-17]** Se l'invio riesce, il partecipante passa a `broadcast`, la
transazione in corso a `pending`, si scrive `bet_placed` (txid e importo
netto) e si invia una notifica SSE. La risposta è HTTP 201 con `round_id`,
`bet_txid`, `bet_amount_sats` e `status`.
*Stato:* da implementare (B-76) · *Oggi:* tutto viene annullato per
qualunque errore dell'invio, anche un timeout o una connessione caduta
dopo la scrittura, quando la transazione può essere già in rete: i suoi
sat arrivano al montepremi senza partecipante.
- **[BET-23]** Se l'invio ha un **esito incerto**
([TX-20](transazioni-e-riconciliazione.md#invio-rifiutato)), partecipante e
transazione in corso restano `building` con le monete ancora segnate come
spese, e la risposta è HTTP **202** con `round_id`, `bet_txid`,
`bet_amount_sats` e `status` = `building`. Il partecipante conta per il
limite di [BET-06] e tiene il round in `closing` finché il riconciliatore
non lo promuove o lo abbandona.
*Codice:* `app/bets/service.py:place_bet`, `app/api/routes/bets.py:create_bet` ·
*Test:* —
*Stato:* da implementare (B-76) · *Oggi:* un esito incerto è trattato come
un rifiuto ([BET-16]).
- **[BET-17]** Se l'invio è accettato — anche con un rifiuto "già nota"
([TX-04](transazioni-e-riconciliazione.md#invio-rifiutato)) — il
partecipante passa a `broadcast`, la transazione in corso a `pending`, si
scrive `bet_placed` (txid e importo netto) e si invia una notifica SSE; la
transazione viene poi inviata anche agli altri server
([TX-21](transazioni-e-riconciliazione.md#invio-rifiutato)). La risposta è
HTTP 201 con `round_id`, `bet_txid`, `bet_amount_sats` e `status`. Una
scommessa promossa dal riconciliatore scrive `bet_placed` in quel momento
([TX-16](transazioni-e-riconciliazione.md#riconciliazione)).
*Codice:* `app/bets/service.py:place_bet`, `app/api/routes/bets.py:create_bet` ·
*Test:* `tests/unit/test_bets.py`
- **[BET-18]** Alla prima conferma il partecipante passa a `confirmed`
*Stato:* da implementare (B-76) · *Oggi:* un "già nota" è trattato come un
rifiuto e la transazione va solo al server attivo.
- **[BET-18]** Alla conferma il partecipante passa a `confirmed`
([TX-07](transazioni-e-riconciliazione.md#conferma)). Solo le scommesse
**confermate** partecipano all'estrazione; il round, allo scadere, aspetta
che tutte quelle già inviate confermino (vedi
@@ -167,8 +249,12 @@ esterno scelto dall'utente.
- **[WDR-01]** Come per la scommessa, un prelievo senza connessione alla rete
è rifiutato con `network_unavailable` (HTTP 503), e tutta l'elaborazione
avviene tenendo il **blocco per-utente**.
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/tx/locks.py:UserLocks.acquire`
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.
Le condizioni sono verificate nell'ordine della tabella; un rifiuto risponde
HTTP 400 prima che si muova qualunque cifra:
@@ -177,7 +263,8 @@ HTTP 400 prima che si muova qualunque cifra:
|---|---|---|
| **[WDR-02]** | L'indirizzo di destinazione è un indirizzo PLM bech32: inizia con `plm1` ed è decodificabile. Un indirizzo di un'altra catena (ad esempio `bc1…`) o in formato legacy (`P…`) è rifiutato | `invalid_address` |
| **[WDR-03]** | L'indirizzo di destinazione non è l'indirizzo di deposito dell'utente stesso | `withdrawal_to_own_address` |
| **[WDR-04]** | L'importo è almeno pari al costo corrente di una scommessa (`bet_amount_sats`; non esiste un minimo separato) | `amount_below_minimum` (parametro `minimum_sats`) |
| **[WDR-14]** | L'indirizzo di destinazione non è un indirizzo della piattaforma: l'indirizzo del montepremi di un round qualunque (`rounds.pool_address`), l'indirizzo di riserva (`m/84'/746'/0'/1/0`, [RND-35](ciclo-di-vita-del-round.md#0--apertura)), il `fee_address` corrente di `round_config` o quello fotografato su un round. *Stato:* da implementare (B-80) · *Oggi:* nessun controllo; un prelievo verso il montepremi finisce fra le monete di un round | `withdrawal_to_platform_address` |
| **[WDR-04]** | L'importo è almeno pari al costo di una scommessa **corrente** in `round_config` (`bet_amount_sats`; non esiste un minimo separato, e non si usa il costo fotografato su un round) | `amount_below_minimum` (parametro `minimum_sats`) |
| **[WDR-05]** | La somma delle monete confermate e non spese copre l'importo | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`) |
| **[WDR-06]** | L'importo si copre con al massimo 50 monete (`MAX_TX_INPUTS`) | `too_many_inputs` (parametro `max_inputs`) |
| **[WDR-07]** | L'importo meno la commissione di rete resta positivo e di almeno 294 sat | `amount_below_network_fee`, `amount_below_dust_limit` |
@@ -189,7 +276,7 @@ HTTP 400 prima che si muova qualunque cifra:
La struttura è identica alla scommessa: transazione firmata dall'indirizzo
dell'utente verso l'indirizzo esterno, resto all'utente, **commissione dedotta
dall'importo prelevato**, salvataggio prima dell'invio, annullamento completo
se la rete rifiuta, RBF se non conferma in tempo.
solo se la rete rifiuta esplicitamente, RBF se non conferma in tempo.
- **[WDR-08]** La transazione spende monete intere dell'utente (dalla più
grande, ingressi sostituibili BIP125); all'indirizzo esterno arriva
@@ -198,36 +285,70 @@ se la rete rifiuta, RBF se non conferma in tempo.
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_psbt_builder.py`, `tests/unit/test_withdrawals.py`
- **[WDR-09]** **Resto garantito.** Se l'importo richiesto lascerebbe un resto
inferiore a 294 sat — tipicamente il prelievo dell'intero saldo —, l'importo
viene ridotto quanto basta perché il resto sia esattamente 294 sat, che
restano sul conto dell'utente. Chi preleva tutto riceve quindi il saldo meno
294 sat meno la commissione. Il database registra sia l'importo richiesto
(`amount_requested_sats`) sia quello effettivamente inviato al destinatario,
al netto della commissione (`amount_sent_sats`).
inferiore a 294 sat (`DUST_LIMIT_SATS`) — ad esempio il prelievo
dell'intero saldo —, l'importo viene ridotto quanto basta perché il resto
sia esattamente 294 sat, che restano sul conto dell'utente. Chi preleva
tutto riceve quindi il saldo meno 294 sat meno la commissione. Il database
registra sia l'importo richiesto (`amount_requested_sats`) sia quello
effettivamente inviato al destinatario, al netto della commissione
(`amount_sent_sats`). Come per la scommessa ([BET-14]), questo resto è
l'uscita che l'RBF riduce o elimina
([TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)).
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction`, `app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
- **[WDR-10]** Prima dell'invio, in un'unica transazione del database, le
monete spese vengono segnate come tali, il saldo ricalcolato, e si
- **[WDR-15]** L'importo ridotto da [WDR-09] viene verificato di nuovo
contro il minimo di [WDR-04]: se è sceso sotto `bet_amount_sats`, il
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.
- **[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
registrano il prelievo e la transazione in corso, entrambi in stato
`building`.
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
- **[WDR-11]** Se l'invio fallisce, le monete vengono liberate e il saldo
ricalcolato, ma — a differenza della scommessa — il prelievo **non viene
cancellato**: resta come `failed` (senza txid), così come la transazione in
corso (con il motivo in `failure_reason`); si scrive
`withdrawal_broadcast_failed`, si invia una notifica SSE e la risposta è
`broadcast_failed` (HTTP 502).
- **[WDR-11]** Se il server **rifiuta esplicitamente** l'invio
([TX-04](transazioni-e-riconciliazione.md#invio-rifiutato)), le monete
vengono liberate e il saldo ricalcolato, ma — a differenza della
scommessa — il prelievo **non viene cancellato**: resta come `failed`
(senza txid), così come la transazione in corso (con il motivo in
`failure_reason`); si scrive `withdrawal_broadcast_failed`, si invia una
notifica SSE e la risposta è `broadcast_failed` (HTTP 502).
*Codice:* `app/withdrawals/service.py:_release_failed_withdrawal`, `app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
- **[WDR-12]** Se l'invio riesce, il prelievo passa a `broadcast`, la
transazione in corso a `pending`, si scrive `withdrawal_sent` (txid, importo
inviato, indirizzo esterno) e si invia una notifica SSE. La risposta è
HTTP 201 con `txid`, `amount_requested_sats`, `amount_sent_sats` e `status`.
Alla prima conferma il prelievo passa a `confirmed`
*Test:* `tests/unit/test_withdrawals.py` (nessun test verifica l'evento
`withdrawal_broadcast_failed`)
*Stato:* da implementare (B-76) · *Oggi:* tutto viene annullato per
qualunque errore dell'invio, anche un timeout o una connessione caduta
dopo la scrittura: la transazione può essere partita mentre il prelievo
risulta `failed` e le monete di nuovo spendibili.
- **[WDR-16]** Se l'invio ha un **esito incerto**
([TX-20](transazioni-e-riconciliazione.md#invio-rifiutato)), prelievo e
transazione in corso restano `building` con le monete ancora segnate come
spese, e la risposta è HTTP **202** con `txid`, `amount_requested_sats`,
`amount_sent_sats` e `status` = `building`; sarà il riconciliatore a
promuoverlo o abbandonarlo.
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* —
*Stato:* da implementare (B-76) · *Oggi:* un esito incerto è trattato come
un rifiuto ([WDR-11]).
- **[WDR-12]** Se l'invio è accettato — anche con un rifiuto "già nota"
([TX-04](transazioni-e-riconciliazione.md#invio-rifiutato)) — il prelievo
passa a `broadcast`, la transazione in corso a `pending`, si scrive
`withdrawal_sent` (txid, importo inviato, indirizzo esterno) e si invia una
notifica SSE; la transazione viene poi inviata anche agli altri server
([TX-21](transazioni-e-riconciliazione.md#invio-rifiutato)). La risposta è
HTTP 201 con `txid`, `amount_requested_sats`, `amount_sent_sats` e
`status`. Alla conferma il prelievo passa a `confirmed`
([TX-07](transazioni-e-riconciliazione.md#conferma)).
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` ·
*Test:* `tests/unit/test_withdrawals.py`
*Stato:* da implementare (B-76) · *Oggi:* un "già nota" è trattato come un
rifiuto e la transazione va solo al server attivo.
## Scommessa e prelievo insieme
+308 -77
View File
@@ -3,33 +3,39 @@
Come ogni movimento di denaro — scommessa, prelievo, pagamento del round —
viene registrato, inviato, accelerato e infine confrontato con la catena.
L'obiettivo è che il sistema si rimetta in ordine da solo dopo un crash o un
problema di rete, senza interventi manuali sul database.
problema di rete, senza interventi manuali sul database, e che **nel dubbio
non liberi mai i fondi**: una transazione si annulla solo con una prova che
non è partita, mai per un timeout o per il silenzio di un solo server
(decisione 2 di [piano-correzioni.md](piano-correzioni.md#le-decisioni)).
I requisiti usano il prefisso `TX`, secondo la convenzione di
[processo-di-modifica.md](processo-di-modifica.md). Le colonne della tabella
`pending_transactions` sono descritte in [modello-dati.md](modello-dati.md),
gli eventi di audit in [audit-log.md](audit-log.md), le attività in
background e le loro cadenze in [architettura.md](architettura.md).
[processo-di-modifica.md](processo-di-modifica.md). Le colonne delle tabelle
`pending_transactions` e `pending_transaction_txids` sono descritte in
[modello-dati.md](modello-dati.md), gli eventi di audit in
[audit-log.md](audit-log.md), le attività in background e le loro cadenze in
[architettura.md](architettura.md).
## Il principio: prima si scrive, poi si invia
Nessuna transazione viene inviata alla rete senza che il database ne abbia già
traccia. Ogni transazione in uscita ha una riga `PendingTransaction` che
attraversa questi stati:
attraversa questi stati (comportamento atteso):
```mermaid
stateDiagram-v2
[*] --> building: transazione firmata,<br/>monete segnate come spese,<br/>salvato PRIMA dell'invio
building --> pending: la rete accetta l'invio
building --> [*]: scommessa rifiutata dalla rete<br/>(riga cancellata, tutto annullato)
building --> failed: prelievo rifiutato dalla rete<br/>(tutto annullato)
building --> pending: il riconciliatore la trova<br/>in rete (dopo 120s)
building --> failed: il riconciliatore non la<br/>trova in rete (dopo 120s)
building --> pending: il server accetta l'invio<br/>(anche "già nota")
building --> building: invio incerto (timeout,<br/>connessione caduta): nulla liberato
building --> [*]: scommessa rifiutata esplicitamente<br/>(riga cancellata, tutto annullato)
building --> failed: prelievo rifiutato esplicitamente<br/>(tutto annullato)
building --> pending: il riconciliatore trova in rete<br/>uno dei suoi txid (dopo 120s)
building --> failed: il riconciliatore ha la prova<br/>che non è partita (dopo 120s)
pending --> pending: nessuna conferma<br/>entro il tempo limite → RBF
pending --> confirmed: 1 conferma
pending --> failed: il riconciliatore non la<br/>trova in rete (dopo 6h dal primo invio)
pending --> confirmed: prova merkle del quorum<br/>per uno dei suoi txid
pending --> failed: il riconciliatore ha la prova<br/>che non è partita (dopo 6h dal primo invio)
failed --> failed: conferma tardiva entro 14 giorni:<br/>pending_tx_resurrected + pausa
confirmed --> [*]
failed --> [*]
failed --> [*]: dopo 14 giorni
```
- **[TX-01]** Ogni transazione in uscita ha una riga `PendingTransaction` di
@@ -53,25 +59,104 @@ stateDiagram-v2
quello dell'ultimo invio, ed è l'unico che l'RBF aggiorna.
*Codice:* `app/db/models.py:PendingTransaction`, `app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_broadcast.py`
- **[TX-19]** **Tutte le versioni sono registrate.** Ogni txid che una riga
ha avuto, con la sua transazione completa, è una riga di
`pending_transaction_txids` ([modello-dati.md](modello-dati.md) (B-77)): la
prima versione è scritta insieme alla riga `PendingTransaction` ([TX-01]),
ogni sostituzione RBF prima di essere inviata ([TX-12]). Conferma
([TX-06]), riconciliazione ([TX-15]) e sorveglianza delle righe `failed`
([TX-23]) cercano **ogni** txid della riga, non solo `current_txid`. Quando
la versione trovata in catena non è quella corrente, `current_txid` e
`raw_tx_hex` diventano quelli della versione trovata e il txid viene
propagato ovunque sia registrato, come dopo un RBF ([TX-12]).
*Codice:* `app/tx/confirmation.py:poll_once`, `app/tx/reconcile.py:reconcile_once`, `app/tx/broadcast.py:_retarget_txid_references` ·
*Test:* —
*Stato:* da implementare (B-77) · *Oggi:* si conserva solo il txid corrente
(più il precedente in `replaced_by_txid`, che nessuno legge); se conferma
una versione precedente a un RBF non viene riconosciuta.
- **[TX-28]** Ogni transazione del database che sposta denaro — la scrittura
di `building` ([TX-01]), la promozione dopo l'invio, l'annullamento dopo un
rifiuto, le due scritture dell'RBF ([TX-12]), la promozione e l'abbandono
del riconciliatore, la conferma ([TX-07]) — si apre con `BEGIN IMMEDIATE`,
cioè prende il blocco in scrittura di SQLite **prima** delle proprie
letture, così quelle letture non possono cambiare fino al commit. Se il
blocco non si ottiene entro il tempo di attesa di SQLite (5 secondi,
`_SQLITE_BUSY_TIMEOUT_MS`) l'operazione fallisce con `database is locked`
e viene annullata: prima dell'invio non si è mosso nulla; dopo un invio
riuscito le righe restano `building` e vale [TX-20]. La durabilità dei
commit (`synchronous=FULL`) è descritta in
[architettura.md](architettura.md) (B-88).
*Codice:* `app/db/base.py:_register_sqlite_pragmas`, `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal` ·
*Test:* —
*Stato:* da implementare (B-88) · *Oggi:* pysqlite in modalità legacy apre
la transazione solo alla prima scrittura, quindi le letture precedenti non
sono isolate; un'attesa del blocco oltre 5 secondi emerge come
`OperationalError` in un punto qualunque, anche dopo un invio riuscito.
### Invio rifiutato
- **[TX-04]** Se l'invio di una scommessa o di un prelievo fallisce, tutto
torna com'era: monete liberate, saldo ripristinato, evento scritto nel
Un invio può finire in tre modi, e solo uno di essi permette di liberare le
monete:
| Esito | Cosa significa | Cosa si fa |
|---|---|---|
| Accettato | Il server risponde con il txid, oppure rifiuta dicendo che la transazione è già nota | Si promuove la riga ([BET-17](scommesse-e-prelievi.md#cosa-succede), [WDR-12](scommesse-e-prelievi.md#prelievo)) |
| Rifiutato esplicitamente | Il server risponde con un errore JSON-RPC diverso da "già nota" | Si annulla tutto ([TX-04]) |
| Incerto | Timeout, connessione caduta, errore di scrittura sul socket: la transazione può essere partita | Non si libera nulla ([TX-20]) |
- **[TX-04]** Solo un **rifiuto esplicito** — una risposta del server con un
oggetto `error` JSON-RPC — all'invio di una scommessa o di un prelievo
annulla tutto: monete liberate, saldo ripristinato, evento scritto nel
registro di audit (`bet_broadcast_failed`, `withdrawal_broadcast_failed`),
notifica SSE. Per una scommessa partecipante e riga vengono **cancellati**;
per un prelievo prelievo e riga restano, in stato `failed`. L'utente riceve
`broadcast_failed` con codice HTTP **502**, perché è stata la rete a
rifiutare, non la sua richiesta a essere sbagliata. Qualunque errore
dell'invio è trattato così, anche la scadenza del tempo di risposta.
*Codice:* `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal` ·
rifiutare, non la sua richiesta a essere sbagliata. Un rifiuto che dice che
la transazione è **già nota** (messaggi che contengono `already known`,
`already in mempool`, `txn-already-known`, `txn-already-in-mempool` o
`transaction already in block chain`) non è un rifiuto: vale come invio
accettato.
*Codice:* `app/bets/service.py:_release_failed_bet`, `app/withdrawals/service.py:_release_failed_withdrawal`, `app/electrum/client.py:ElectrumClient.broadcast` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`
- **[TX-05]** Se l'invio di un pagamento fallisce, la riga resta `building`
e si scrive `payout_failed` (`broadcast rejected`): è il riconciliatore a
decidere, interrogando la rete, se la transazione è partita o no
([TX-16], [TX-17]).
*Stato:* da implementare (B-76) · *Oggi:* qualunque eccezione dell'invio,
compresi il timeout di 15 secondi, la connessione caduta dopo la scrittura
e il "già nota", annulla tutto e risponde 502, anche se la transazione può
essere già in rete.
- **[TX-20]** **Invio incerto.** Se l'invio di una scommessa o di un prelievo
finisce senza una risposta del server (timeout, connessione caduta, errore
di scrittura), o se il commit della promozione successiva a un invio
accettato fallisce ([TX-28]), non si libera nulla: monete spese,
partecipante o prelievo e riga `PendingTransaction` restano `building`, e
sarà il riconciliatore a stabilire se la transazione è partita ([TX-16],
[TX-17]). L'utente riceve HTTP **202** con l'oggetto nello stato
`building` ([BET-23](scommesse-e-prelievi.md#cosa-succede),
[WDR-16](scommesse-e-prelievi.md#prelievo)) e si invia una notifica SSE.
*Codice:* `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal` ·
*Test:* —
*Stato:* da implementare (B-76) · *Oggi:* l'invio incerto è trattato come
un rifiuto ([TX-04]); un commit fallito dopo un invio accettato solleva un
errore 500 lasciando le righe `building`.
- **[TX-21]** **Invio a tutti i server.** Dopo che il server attivo ha
accettato una transazione ([TX-04]) — scommessa, prelievo, pagamento o
sostituzione RBF — la stessa transazione viene inviata anche a ogni altro
server configurato. È un tentativo in più, non una condizione: un errore di
questi invii viene solo registrato nei log, non cambia lo stato della riga
e non ritarda la risposta all'utente.
*Codice:* `app/electrum/listener.py:ElectrumListener`, `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/tx/broadcast.py:bump_fee` ·
*Test:* —
*Stato:* da implementare (B-76) · *Oggi:* ogni transazione viene inviata
solo al server attivo.
- **[TX-05]** Se l'invio di un pagamento non è accettato — rifiuto esplicito
o esito incerto — la riga resta `building` e si scrive `payout_failed`
(`broadcast rejected`): è il riconciliatore a decidere, interrogando la
rete, se la transazione è partita o no ([TX-16], [TX-17]). Un rifiuto
"già nota" ([TX-04]) vale come invio accettato: la riga passa a `pending` e
si scrive `payout_sent`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-76) · *Oggi:* anche il "già nota" lascia la
riga `building` con `payout_failed`, finché il riconciliatore non la
promuove.
## Conferma
@@ -79,13 +164,33 @@ stateDiagram-v2
in stato `pending` (non quelle `building`), e salta il giro se non c'è
connessione. Per ciascuna legge la cronologia
(`blockchain.scripthash.get_history`) dell'indirizzo che la spende —
l'indirizzo dell'utente, o quello del montepremi per un pagamento — e la
considera confermata quando il suo txid corrente vi compare con altezza
maggiore di 0. La cronologia è letta una volta per indirizzo a ogni giro;
un indirizzo che non si riesce a leggere non blocca gli altri.
l'indirizzo dell'utente, o l'indirizzo del montepremi del round per un
pagamento — e cerca **ogni** txid della riga ([TX-19]); un txid che vi
compare con altezza maggiore di 0 è il candidato alla conferma, che viene
accettata solo alle condizioni di [TX-22]. La cronologia è letta una volta
per indirizzo a ogni giro; un indirizzo che non si riesce a leggere non
blocca gli altri.
*Codice:* `app/tx/confirmation.py:poll_once`, `app/tx/pending_address.py:own_address_for` ·
*Test:* `tests/unit/test_confirmation.py`
- **[TX-07]** Alla prima conferma la riga passa a `confirmed` e, nella stessa
*Stato:* da implementare (B-77, B-80) · *Oggi:* cerca solo `current_txid`,
nella cronologia dell'unico indirizzo del montepremi condiviso da tutti i
round per i pagamenti; un'altezza maggiore di 0 riportata dal solo server
attivo basta a confermare ([TX-22]).
- **[TX-22]** **Conferma verificata.** Un candidato ([TX-06]) è confermato
solo quando il quorum dei server
([connessione-electrum.md](connessione-electrum.md) (B-84)) restituisce per
quel txid una prova merkle valida (`blockchain.transaction.get_merkle`)
contro l'header dell'altezza indicata. L'header è quello già validato dal
listener ([connessione-electrum.md](connessione-electrum.md) (B-74)); se
il listener non lo tiene più, è quello su cui il quorum concorda. Senza
quorum, o con una prova non valida, la riga resta `pending` e si riprova al
giro successivo.
*Codice:* `app/tx/confirmation.py:poll_once`, `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
*Test:* —
*Stato:* da implementare (B-83) · *Oggi:* basta la parola del server
attivo: un server ostile può dichiarare confermato un pagamento mai
trasmesso.
- **[TX-07]** Alla conferma la riga passa a `confirmed` e, nella stessa
transazione del database, viene chiamato il gestore del suo tipo; poi si
invia una notifica SSE:
@@ -119,7 +224,7 @@ sostituibilità fin dal primo invio.
`pending` (salta il giro senza connessione) e accelera quelle il cui
`last_broadcast_at` è più vecchio di `rbf_timeout_seconds`: dopo ogni
accelerazione la transazione ha di nuovo l'intero tempo limite. Un errore
su una riga viene registrato nei log e non ferma le altre.
su una riga non ferma le altre.
*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
@@ -131,35 +236,86 @@ sostituibilità fin dal primo invio.
precedenti come interi in satoshi.
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_prevout_amount` ·
*Test:* `tests/unit/test_broadcast.py`
- **[TX-11]** **Paga chi riceve il resto**: la sostituzione spende le stesse
monete e riduce del solo aumento l'uscita di resto verso il mittente —
l'utente per scommesse e prelievi, il montepremi per i pagamenti. Le uscite
verso la controparte (montepremi, indirizzo esterno, vincitore, indirizzo
commissioni) non vengono mai toccate. Se non c'è un'uscita di resto verso il
mittente, o non supera l'aumento, l'accelerazione non è possibile: l'errore
viene registrato nei log, la transazione resta in attesa (il tentativo si
ripete a ogni giro) e prima o poi viene abbandonata dal riconciliatore, che
ne libera le monete.
- **[TX-11]** **Chi paga l'accelerazione.** La sostituzione spende le stesse
monete e riduce del solo aumento un'unica uscita, quella che torna alla
piattaforma o al mittente: il **resto dell'utente** per scommesse e
prelievi, l'**uscita di riserva** verso l'indirizzo di riserva del
montepremi (`m/84'/746'/0'/1/0`) per i pagamenti
([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento)
(B-79)). Le uscite verso la controparte (montepremi, indirizzo esterno,
vincitore, indirizzo commissioni) non vengono mai toccate. Se dopo la
riduzione quell'uscita scenderebbe sotto `DUST_LIMIT_SATS` (294 sat),
l'uscita viene **eliminata** e tutto il suo valore va in commissione: la
sostituzione non ha più nulla da ridurre ed è quindi **l'ultima**, e la
riga non viene più accelerata. Se l'uscita non esiste, o se nemmeno la sua
eliminazione raggiunge l'aumento minimo di [TX-10], l'accelerazione non è
possibile ([TX-24]). Una transazione che non si può accelerare resta
`pending` finché conferma o finché il riconciliatore ha la prova che non è
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`
- **[TX-12]** Dopo l'invio della sostituzione la riga registra il nuovo txid
(`current_txid`), la nuova transazione (`raw_tx_hex`), il txid sostituito
(`replaced_by_txid`), la tariffa effettivamente pagata, il numero di
tentativi e `last_broadcast_at`. Il nuovo txid viene propagato ovunque sia
registrato: monete spese (`spent_txid`), partecipante (`bet_txid`),
prelievo (`txid`), round (`payout_txid`).
*Stato:* da implementare (B-79, B-87) · *Oggi:* per il pagamento si cerca
un resto verso l'indirizzo del montepremi, che esiste solo se le monete
scelte superano vincitore più commissioni di almeno 294 sat: quando il
montepremi contiene esattamente le scommesse del round il pagamento le
spende tutte per esattamente `pool_amount_sats`, non ha resto e non è
accelerabile; per scommesse e prelievi basta che il resto superi
l'aumento, così un resto di 294 sat diventa 153 sat, sotto la soglia di
dust, e la rete rifiuta la sostituzione a ogni giro.
- **[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
committata **prima** dell'invio. Dopo l'invio (accettato o incerto) la riga
viene aggiornata con un aggiornamento condizionato
(`UPDATE … WHERE id = … AND status = 'pending' AND current_txid = <txid
sostituito>`): nuovo txid (`current_txid`), nuova transazione
(`raw_tx_hex`), txid sostituito (`replaced_by_txid`), tariffa
effettivamente pagata, numero di tentativi e `last_broadcast_at`; nella
stessa transazione il nuovo txid viene propagato ovunque sia registrato:
monete spese (`spent_txid`), partecipante (`bet_txid`), prelievo (`txid`),
round (`payout_txid`). Se la sostituzione è rifiutata esplicitamente la
riga non cambia ([TX-24]).
*Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_retarget_txid_references` ·
*Test:* `tests/unit/test_broadcast.py`
- **[TX-13]** Se al momento dell'accelerazione la riga non esiste più o non è
più `pending` (ad esempio è appena stata confermata), non si fa nulla.
*Stato:* da implementare (B-77) · *Oggi:* la sostituzione viene inviata
senza essere registrata, e la riga viene aggiornata solo dopo l'invio, senza
condizioni: un crash o un `database is locked` in mezzo lascia in rete una
transazione che il database non conosce.
- **[TX-13]** L'RBF non tocca una riga che è cambiata sotto di lui. Prima di
costruire la sostituzione, se la riga non esiste più o non è più
`pending` (ad esempio è appena stata confermata), non si fa nulla. Dopo
l'invio, se l'aggiornamento condizionato di [TX-12] non trova la riga
(stato o `current_txid` cambiati nel frattempo: confermata, abbandonata o
già sostituita), è un **conflitto**: la riga non viene sovrascritta, la
candidata resta registrata — la conferma e la riconciliazione la cercano
comunque ([TX-19]) — e il conflitto viene scritto nei log.
*Codice:* `app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_broadcast.py`
*Stato:* da implementare (B-77) · *Oggi:* il controllo avviene solo prima
della costruzione; dopo l'invio la riga viene aggiornata senza ricontrollare
né lo stato né `current_txid`, anche se nel frattempo è stata confermata o
abbandonata.
- **[TX-24]** Un'accelerazione impossibile ([TX-11], o la riga già a
`MAX_FEE_RATE_SAT_VB`) o una sostituzione rifiutata esplicitamente dalla
rete scrivono un evento `rbf_bump_failed` ([audit-log.md](audit-log.md)
(B-87)) con la riga, il txid corrente e il motivo; la riga resta `pending`
e il tentativo si ripete al giro successivo in cui è dovuto ([TX-09]).
*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`.
Scommesse e prelievi nascono sempre con un'uscita di resto di almeno 294 sat
proprio perché l'RBF abbia qualcosa da ridurre
([scommesse-e-prelievi.md](scommesse-e-prelievi.md)). Il pagamento del round
invece ha un resto verso il montepremi solo quando le monete spese superano il
montepremi di almeno 294 sat: senza resto non è accelerabile.
Il resto minimo di scommesse e prelievi è di 294 sat
([BET-14](scommesse-e-prelievi.md#cosa-succede),
[WDR-09](scommesse-e-prelievi.md#prelievo)), ma non basta a una riduzione:
una scommessa con una moneta in ingresso e due uscite misura 141 vB, quindi
l'aumento minimo a 1 sat/vB è di 141 sat e lascerebbe un resto di 153 sat,
sotto la soglia di dust. Con un resto minimo la prima accelerazione elimina il
resto ([TX-11]) ed è anche l'ultima; un resto più grande permette più
accelerazioni prima di arrivare lì. Il pagamento del round, invece, non ha
resto quando il montepremi contiene esattamente le scommesse del round: la
riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
## Riconciliazione
@@ -168,34 +324,102 @@ montepremi di almeno 294 sat: senza resto non è accelerabile.
ancora `building` o `pending` trascorso il suo periodo di tolleranza,
misurato da `broadcast_at` (il primo invio, che l'RBF non sposta): 120s per
`building`, 6 ore per `pending`, così l'RBF ha il tempo di fare i suoi
tentativi.
tentativi. Il periodo di tolleranza stabilisce solo da quando la riga viene
esaminata: l'abbandono richiede comunque una prova ([TX-17]).
*Codice:* `app/tx/reconcile.py:PendingTransactionReconciler.run`, `app/tx/reconcile.py:_is_due` ·
*Test:* `tests/unit/test_reconcile.py`
- **[TX-15]** L'esistenza di una transazione si decide cercando il suo txid
corrente nella cronologia (`blockchain.scripthash.get_history`)
dell'indirizzo che la spende, in mempool o in un blocco. Un **errore di
rete** non porta a nulla: la riga resta com'è fino al giro successivo. Solo
un server che risponde con una cronologia priva del txid porta
all'abbandono, mai un server irraggiungibile.
*Codice:* `app/tx/reconcile.py:reconcile_once` ·
- **[TX-15]** Il riconciliatore chiede a ogni server del quorum
([connessione-electrum.md](connessione-electrum.md) (B-84)) la cronologia
(`blockchain.scripthash.get_history`) dell'indirizzo che spende la riga, e
cerca **ogni** txid della riga ([TX-19]), in mempool o in un blocco. La
transazione è **presente** se almeno un server conosce almeno uno dei suoi
txid ([TX-16]); è **assente** solo se nessun server del quorum ne conosce
alcuno, e anche in quel caso l'assenza da sola non basta ad abbandonarla
([TX-17]). Un **errore di rete** non porta a nulla: la riga resta com'è
fino al giro successivo.
*Codice:* `app/tx/reconcile.py:reconcile_once`, `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
*Test:* `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-78) · *Oggi:* si interroga solo il server
attivo e solo per `current_txid`, e un txid assente dalla sua cronologia è
trattato come "sconosciuto alla rete": dopo un riavvio su un server diverso
una riga `building` viene abbandonata dopo 120s anche se la transazione è
nella mempool degli altri nodi (che la tengono fino a 336 ore con le
impostazioni predefinite di Bitcoin Core, contro le 6 ore di tolleranza).
- **[TX-26]** Prima di scrivere un esito, il riconciliatore rilegge la riga
nella propria transazione del database ([TX-28]) e non fa nulla se il suo
stato **o il suo `current_txid`** non sono più quelli che aveva quando ha
interrogato la rete (una conferma o un RBF avvenuti nel frattempo).
*Codice:* `app/tx/reconcile.py:reconcile_once` ·
*Test:* —
*Stato:* da implementare (B-78) · *Oggi:* ricontrolla solo lo stato, quindi
può abbandonare una riga appena sostituita dall'RBF sulla base del txid
precedente.
- **[TX-16]** **Presente in rete**: una riga `building` viene promossa a
`pending` (e partecipante o prelievo da `building` a `broadcast`), con un
evento `pending_tx_recovered`; da lì la conferma segue la via normale. Una
riga già `pending` presente in rete non viene toccata.
evento `pending_tx_recovered` e l'evento che l'invio riuscito avrebbe
scritto: `bet_placed` per una scommessa, `withdrawal_sent` per un
prelievo, `payout_sent` per un pagamento. Se il txid trovato non è quello
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`
- **[TX-17]** **Sconosciuta alla rete**: la riga passa a `failed` con motivo
`not found on chain`; le monete che risultano ancora impegnate da *questa*
transazione vengono liberate (non quelle già spese da altro); il
partecipante viene rimosso, o il prelievo segnato `failed` senza txid; il
saldo dell'utente viene ricalcolato; si scrive `pending_tx_abandoned`
(con il numero di monete liberate) e si invia una notifica SSE.
*Codice:* `app/tx/reconcile.py:_abandon`, `app/tx/reconcile.py:_release_inputs` ·
*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`).
- **[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
non è nessuna delle sue versioni ([TX-19]), confermato dal quorum
([connessione-electrum.md](connessione-electrum.md) (B-84));
2. nessun server del quorum conosce alcuno dei suoi txid ([TX-15]) **e**
il reinvio della sua versione più recente (`raw_tx_hex`) a tutti i server
viene rifiutato esplicitamente perché gli ingressi mancano o sono già
spesi (`missing-inputs`, `bad-txns-inputs-missingorspent`).
È questo che rende finita l'attesa del round in `closing`: una scommessa che
non confermerà mai viene prima o poi abbandonata e rimossa.
Un rifiuto del reinvio per commissione insufficiente non è una prova: una
riga `pending` resta com'è e la accelera l'RBF ([TX-09]); una riga
`building` passa a `pending` come in [TX-16], così che l'RBF la possa
accelerare. Un reinvio accettato da almeno un server significa che la
transazione è viva: vale [TX-16]. Con la prova, la riga passa a `failed`
con il motivo in `failure_reason`; le monete che risultano ancora impegnate
da *questa* transazione vengono liberate (non quelle già spese da altro);
il partecipante viene rimosso, o il prelievo segnato `failed` senza txid; il
saldo dell'utente viene ricalcolato; si scrive `pending_tx_abandoned` (con
il numero di monete liberate) e si invia una notifica SSE.
*Codice:* `app/tx/reconcile.py:_abandon`, `app/tx/reconcile.py:_release_inputs`, `app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-78) · *Oggi:* basta che la cronologia del
server attivo non contenga `current_txid` (motivo `not found on chain`),
senza reinvio e senza guardare gli ingressi.
- **[TX-23]** **Le righe `failed` restano sorvegliate** per 14 giorni dal
passaggio a `failed` (`failed_at`, [modello-dati.md](modello-dati.md)
(B-78)): a ogni giro il riconciliatore cerca i loro txid come in [TX-15].
Se uno di essi risulta confermato ([TX-22]) — una transazione data per
morta che invece è arrivata in un blocco — si scrive una sola volta
`pending_tx_resurrected` ([audit-log.md](audit-log.md) (B-78)) e la
lotteria viene messa in pausa (`paused_reason = 'tx_resurrected'`) perché
l'operatore rimetta in ordine partecipante, prelievo o pagamento; il
riconciliatore non corregge da solo le righe collegate.
*Codice:* `app/tx/reconcile.py:reconcile_once` ·
*Test:* —
*Stato:* da implementare (B-78) · *Oggi:* una riga `failed` non viene più
guardata: se la transazione conferma dopo l'abbandono, nessuno se ne
accorge.
- **[TX-27]** Il riconciliatore invia una notifica SSE solo per le righe di
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.
- **[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
esiste un tempo massimo: finché almeno un server tiene la transazione in
mempool, il round resta in `closing`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_scheduler.py`
## Pagamenti del round: riprovare senza pagare due volte
@@ -211,12 +435,17 @@ monete del montepremi già impegnate da un pagamento non risolto sono escluse
dalla selezione ([RND-26](ciclo-di-vita-del-round.md#4--pagamento)). Senza
queste ultime due, un nuovo tentativo potrebbe pagare il vincitore due volte.
- **[TX-18]** Un pagamento abbandonato non libera monete utente (quelle del
montepremi non sono registrate): azzera `payout_txid` del round, che resta
in `paying_out` senza transazione, e lo scheduler ricostruisce il pagamento
al tentativo successivo.
- **[TX-18]** Un pagamento abbandonato ([TX-17]) non libera monete utente
(quelle del montepremi non sono registrate): azzera `payout_txid`,
`winner_amount_sats` e `fee_amount_sats` del round, che resta in
`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`
*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.
## Dove guardare
@@ -226,5 +455,7 @@ Nel pannello `/admin` (vedi [guida-admin.md](guida-admin.md) e
- **Transazioni pendenti** — tutto ciò che è `building` o `pending`, con
orari di invio e candidati all'RBF;
- **Audit log** — `payout_sent`, `payout_failed`, `pending_tx_recovered`,
`pending_tx_abandoned`, `draw_stalled` e tutti gli altri eventi;
`pending_tx_abandoned`, `draw_stalled` e tutti gli altri eventi; con le
correzioni in corso anche `rbf_bump_failed` ([TX-24]) e
`pending_tx_resurrected` ([TX-23]);
- **Round** — stato di ogni round, vincitore e txid del pagamento.