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:
1 parent
61efbb0e37
commit
88221b0dbd
15 files changed
+4524
-1172
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+667
-209
File diff suppressed because it is too large.
Load diff
+528
-134
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user