From db24287ea58eca4e865a39892b500128aff0e22c Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Wed, 14 Oct 2026 16:29:09 +0200 Subject: [PATCH] =?UTF-8?q?Make=20the=20draw=20a=20commit-reveal=20at=20a?= =?UTF-8?q?=20fixed,=20confirmed,=20quorum-agreed=20height=20with=20a=20pu?= =?UTF-8?q?blic=20proof=20(B-73,=20RND-14=20=E2=80=A6=20RND-20,=20RND-33?= =?UTF-8?q?=20=E2=80=A6=20RND-37,=20DAT-61=20=E2=80=A6=20DAT-66,=20API-25,?= =?UTF-8?q?=20API-45,=20AUD-24=20=E2=80=A6=20AUD-27,=20AUD-37,=20ELC-15,?= =?UTF-8?q?=20SEC-56,=20ADR-24,=20ADR-25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed was the hash of the first block seen after the close, so a miner holding tickets could withhold blocks until one made them win, the active Electrum server could pick which real block was used by not announcing another (a failed corroboration even moved the draw to the next block), and the participant order was the server-only `broadcast_at` with no tie-break, so nobody could recompute the result. - Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the master-xprv key (`server_seed_enc`), with its published `server_seed_hash`; `draw_confirmations` is copied from round_config. The plaintext column is written only by the reveal. - Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n` lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round `closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw reads rather than recomputes (the `_tick` branch itself is B-81). - `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for `draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash` asks every server, the active one included, for the header at that exact height (valid work, >= 2 other answers, strict majority of all); no agreement means the same height is asked again and `draw_header_corroboration_failed` is written once per distinct reported hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n. - The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in one transaction; a round closing with no participants reveals too. A round opened before this change (no commitment) is given a secret as it enters `drawing`. - `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the /admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret. - Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched). - Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats` (RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified. Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the other servers about the block the active one announced) is replaced by agreed_block_hash and its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four _wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height, confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner independently from the public proof, checks the plaintext secret is nowhere in the database, the API or the log before the reveal, and that a second pass over a `drawing` round yields the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the tip instead of the draw height each fail tests. Co-Authored-By: Claude Sonnet 5.5 --- BUGS.md | 22 - CLAUDE.md | 15 +- README.md | 2 +- app/api/routes/admin.py | 7 + app/api/routes/rounds.py | 101 +++- app/bets/service.py | 2 + app/db/models.py | 36 +- app/electrum/listener.py | 67 ++- app/rounds/draw.py | 63 ++- app/rounds/scheduler.py | 318 ++++++++----- app/rounds/service.py | 16 + app/static/admin.html | 2 + app/static/admin.js | 2 + app/static/i18n.js | 7 + app/wallet/keystore.py | 11 + app/wallet/psbt_builder.py | 5 + ...i-decisione-sposta-denaro-base-risposta.md | 2 +- ...4-l-estrazione-commit-reveal-fra-server.md | 10 +- ...one-fissato-altezza-successivo-chiusura.md | 13 +- docs/api.md | 43 +- docs/architettura.md | 9 +- docs/audit-log.md | 57 +-- docs/ciclo-di-vita-del-round.md | 87 ++-- docs/come-funziona.md | 2 +- docs/connessione-electrum.md | 8 +- docs/guida-admin.md | 3 +- docs/guida-utente.md | 12 +- docs/limiti-noti.md | 12 +- docs/modello-dati.md | 67 ++- docs/operativita.md | 2 +- docs/processo-di-modifica.md | 2 +- docs/sicurezza.md | 11 +- docs/storico-bug.md | 6 +- ...d3e8b15a7c42_commit_reveal_draw_columns.py | 68 +++ tests/unit/test_admin.py | 26 ++ tests/unit/test_bets.py | 8 + tests/unit/test_draw.py | 81 +++- tests/unit/test_draw_proof.py | 348 ++++++++++++++ tests/unit/test_electrum_listener.py | 246 +++++----- tests/unit/test_migrations.py | 36 ++ tests/unit/test_rounds_service.py | 61 +++ tests/unit/test_scheduler.py | 439 +++++++++++++----- 42 files changed, 1715 insertions(+), 620 deletions(-) create mode 100644 migrations/versions/d3e8b15a7c42_commit_reveal_draw_columns.py create mode 100644 tests/unit/test_draw_proof.py diff --git a/BUGS.md b/BUGS.md index c1afedf..bff0b61 100644 --- a/BUGS.md +++ b/BUGS.md @@ -102,28 +102,6 @@ decisions, see `docs/limiti-noti.md`): ## 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 -**encrypted** (`server_seed_enc`, Fernet with the master-xprv key, so a DB copy or -backup alone never reveals it; the plaintext column is written only at reveal), and -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-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 diff --git a/CLAUDE.md b/CLAUDE.md index d809e4e..da5ed41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin ## Project status -All 10 stages of the original build order are code-complete and unit-tested — 583 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. +All 10 stages of the original build order are code-complete and unit-tested — 617 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only. @@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace) PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip -python -m pytest # all 583 tests +python -m pytest # all 617 tests python -m pytest tests/unit/test_hd.py # one file python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test ``` @@ -102,6 +102,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW | Prize split | **70% winner / 30% fees**, rounding remainder to fees | **hardcoded** in `rounds/scheduler.py` — a code change, not an admin edit | | Round duration / cooldown | 600s / 30s | `RoundConfig`, but **snapshotted onto `Round.duration_seconds`/`cooldown_seconds` when a round opens** (B-61) — an edit applies from the next round, never to the one in progress | | Draw animation | 20s (cosmetic frontend minimum only) | `RoundConfig` | +| Draw confirmations | 3 blocks on the draw block (`draw_confirmations`, 1–20) | `RoundConfig`, admin-editable, **snapshotted onto `Round.draw_confirmations` at open** (B-73) | | Fee rate / RBF timeout | 1 sat/vB / 900s | `RoundConfig` | | Min withdrawal | = current `bet_amount_sats` (no separate field) | `withdrawals/service.py` | | Min deposit | none | — | @@ -131,7 +132,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW | `app/electrum/` | `client.py` (JSON-RPC, endpoint parsing, timeouts), `listener.py` (the one connection: rotation, keepalive, header validation, corroboration, deposit crediting) | | `app/deposits/` | crediting / external-spend detection / reinstatement (`service.py`), periodic sweep (`reconcile.py`) | | `app/bets/`, `app/withdrawals/` | build+broadcast services and their confirmation handlers | -| `app/rounds/` | `scheduler.py` (close/draw/payout), `service.py` (open/active-round rules), `draw.py` (header math + winner pick), `config.py`, `events.py` (SSE pub/sub) | +| `app/rounds/` | `scheduler.py` (close/draw/payout), `service.py` (open/active-round rules), `draw.py` (header math, powLimit, commit-reveal seed + winner pick), `config.py`, `events.py` (SSE pub/sub) | | `app/tx/` | `broadcast.py` (RBF bumper), `outcome.py` (broadcast verdict, B-76), `versions.py` (every txid of a row, B-77), `confirmation.py` (poller + handler registry), `reconcile.py`, `locks.py` (per-user locks) | | `app/static/` | the two SPAs (`index.html`/`app.js`/`style.css`, `admin.html`/`admin.js`/`admin.css`) + `i18n.js` | @@ -175,7 +176,7 @@ Diagrams: [come-funziona.md](docs/come-funziona.md) (the 5 phases), [ciclo-di-vi **DRAW** — configurable timer (default 600s): - *Bet cutoff is the round's own deadline* (`round_deadline` = `opened_at + Round.duration_seconds`, the value snapshotted at open time — B-61), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a **compare-and-set on the round row** (`UPDATE rounds ... WHERE status = 'open'`, B-53): the scheduler flips `open` → `closing` in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only `confirmed` participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is `_close_and_draw` re-counting in-flight bets in the same session it snapshots the participants from. - *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms). -- *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount. The baseline the draw compares against (`tip_at_close`) must be a height we actually knew at closing time: a `0` there means *unknown*, not "the chain is at zero", so `_wait_for_next_block` adopts the first height it then learns as the baseline and waits for a block strictly after it (`draw_baseline_tip_unknown`, B-63) — seeding from a block that already existed while bets were open would make the winner predictable to whoever was watching the chain. +- *Algorithm* (deliberately simple, meant to be replaced; **commit-reveal since B-73**, spec RND-14 … RND-20, RND-34 … RND-37): at open the round gets a 32-byte `server_seed` — stored only **Fernet-encrypted** with the master-xprv key (`server_seed_enc`; the plaintext column `server_seed` stays NULL until the reveal) — and publishes `server_seed_hash = sha256(seed)`. Entering `drawing` fixes, in one transaction with the status change, the confirmed participants **ordered by bet txid ascending** (public data, no ties — never `broadcast_at`), each one's `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n` lines; `bet_vout` is the output paying the round's pool address), and `draw_height = tip_at_close + 1`, and writes `draw_committed`. The draw waits until the chain has `draw_confirmations` blocks from `draw_height` (snapshotted at open from `round_config`, default 3), then asks **every** server, the active one included, for the header *at that exact height* (`ElectrumListener.agreed_block_hash`: valid work, at least 2 other answers, strict majority of all answers) — no agreement means the same height is asked again, never another block, so whoever mines or relays blocks cannot pick the one that seeds the draw (`draw_header_corroboration_failed`, once per distinct hash the active server reported). `seed = sha256(server_seed ‖ block_hash ‖ participants_hash)` as a big-endian integer, `index = seed mod n`, winner = participant with that `draw_position`; equal probability for everyone, regardless of amount. The reveal (`server_seed` column + `winner_drawn` payload) happens in the same transaction that stores the winner (or when a round closes with no participants), and `GET /rounds/{id}/proof` (public) returns everything needed to recompute it. A tip of 0 at closing means *unknown*: the round stays `closing` (`draw_baseline_tip_unknown`, once per round, B-63) because `draw_height` must come from a height we actually knew. `draw_stalled` fires when the chain has not advanced for 360s while waiting for confirmations, or the quorum keeps failing to agree. `_complete_draw` reads everything it needs from the database (`_close_and_draw` enters `drawing` and then calls it); a round opened before B-73 (no `server_seed_enc`) is given a secret as it enters `drawing`. - *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern. - *UI, two independent layers.* A generic phase box ("Pagamento al vincitore in corso…") shows to **every** viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by `status`. **Additively**, a personalized "Hai vinto!/Non hai vinto" box appears only where `user_played` is true (computed via `get_optional_user`, since the endpoint is reachable logged-out) — nobody else has anything to reveal. - *Reveal timing.* Delayed by at least `draw_animation_seconds`, anchored to the server's `closes_at` so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for `winner_user_id`. Once revealed it's persisted in `localStorage.plm_persisted_result`, surviving the move to `closed` — at which point `get_active_round` stops returning the round and `winner_user_id` disappears from `GET /rounds/current`. `GET /users/me/last-round-result` is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: `refreshRound`/`checkLastRoundResult` in `app/static/app.js`. @@ -186,10 +187,10 @@ PLAY and WITHDRAW share a **per-user lock** (`tx/locks.py`): a bet-build and a w **Three separate on-chain confirmations sit between the timer hitting zero and the payout landing** — a common point of confusion: 1. **Last bet's confirmation** — the round doesn't even flip to `"closing"` until every broadcast bet has 1 conf (`_tick`'s `pending_count` check). May already have happened before the deadline. -2. **The draw block** — `_wait_for_next_block` waits for `tip_height > tip_at_close`, recorded only once step 1 is done, so this is necessarily a later block. +2. **The draw block** — fixed at `draw_height = tip_at_close + 1` (recorded only once step 1 is done, so necessarily a later block) and read once the chain has `draw_confirmations` blocks from it (3 by default, so it is really ~3 blocks, not one). 3. **Payout confirmation** — built only after step 2's winner is known, so it needs yet another block; the generic `ConfirmationPoller` tracks it. -At 120s blocks that's ~4–6 min worst case (last bet confirms right at the deadline), ~2–4 min best case — independent of `draw_animation_seconds`. +At 120s blocks that's ~8–10 min worst case (last bet confirms right at the deadline), ~6–8 min best case with the default 3 draw confirmations — independent of `draw_animation_seconds`. ## Balance display @@ -261,7 +262,7 @@ Explicit design choices, not derivable from any single file — respect them: Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here. -- **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_wait_for_next_block` after a crash. That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36). Restart-resumption itself remains the last prerequisite for running unattended. +- **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_complete_draw` after a crash (everything it needs is in the database since B-73, only the `_tick` branch is missing — B-81). That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36). Restart-resumption itself remains the last prerequisite for running unattended. - **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why `build_signed_transaction` now guarantees a change output of at least `DUST_LIMIT_SATS` instead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused with `balance_leaves_no_change`. What's left is a bump whose *delta* exceeds an otherwise-fine change output, which still raises `RbfError`; that tx is eventually abandoned and its UTXOs released. - **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network). - **No user-facing history of rounds or transactions.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above) and `GET /bug-reports/mine` one more (the reporter's own reports). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this. diff --git a/README.md b/README.md index 3aba92a..441594c 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ python -m pytest # all tests python -m pytest tests/unit/test_hd.py # one file ``` -583 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +617 unit tests cover HD derivation, PSBT building, the Electrum client, bets, deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config, the pending-inclusive balance calculation, and the SSE push channel. No automated integration tests against a live Electrum connection — mainnet diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index be259ed..733d58f 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -45,6 +45,7 @@ _CONFIG_FIELDS = ( "fee_rate_sat_vb", "rbf_timeout_seconds", "draw_animation_seconds", + "draw_confirmations", # B-73 (DAT-62) ) @@ -56,6 +57,7 @@ class RoundConfigResponse(BaseModel): fee_rate_sat_vb: int rbf_timeout_seconds: int draw_animation_seconds: int + draw_confirmations: int # B-73 (DAT-62) paused: bool paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused @@ -77,6 +79,7 @@ class RoundConfigUpdate(BaseModel): fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB) rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600) draw_animation_seconds: int | None = Field(default=None, ge=0, le=600) + draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62) @field_validator("fee_address") @classmethod @@ -264,6 +267,8 @@ class AdminRoundResponse(BaseModel): fee_amount_sats: int | None payout_txid: str | None pool_address: str | None # B-80 + server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret + draw_height: int | None # B-73 @router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)]) @@ -292,6 +297,8 @@ async def list_rounds( fee_amount_sats=r.fee_amount_sats, payout_txid=r.payout_txid, pool_address=r.pool_address or None, + server_seed_hash=r.server_seed_hash, + draw_height=r.draw_height, ) for r in rounds ] diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index f8d0083..ed991ca 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -9,9 +9,10 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.client_ip import client_ip +from app.api.errors import http_error from app.api.timeutil import isoformat_utc from app.auth.dependencies import get_optional_user -from app.db.models import RoundParticipant, User +from app.db.models import Round, RoundParticipant, User from app.db.session import get_session from app.rounds.config import get_round_config from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster @@ -118,6 +119,13 @@ class CurrentRoundResponse(BaseModel): # waiting for a block" rather than a countdown implying a bounded wait — this # phase has no timeout, only draw_animation_seconds' cosmetic minimum. draw_waiting_since: str | None = None + # B-73 (RND-34, RND-14): the commitment published from the opening, and what is fixed + # on entering "drawing". The secret itself is never in this response (API-45 has it, once + # revealed). + server_seed_hash: str | None = None + participants_hash: str | None = None + draw_height: int | None = None + draw_confirmations: int | None = None chain_tip_height: int | None = None lottery_paused: bool = False paused_reason: str | None = None # B-78 (DAT-69): operator / resurrected_tx / ..., None if not paused @@ -225,9 +233,100 @@ async def current_round( draw_block_height=round_.draw_block_height, draw_block_hash=round_.draw_block_hash, draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None, + server_seed_hash=round_.server_seed_hash, + participants_hash=round_.participants_hash, + draw_height=round_.draw_height, + draw_confirmations=round_.draw_confirmations, chain_tip_height=chain_tip_height, lottery_paused=config.paused, paused_reason=config.paused_reason, lottery_configured=rounds_can_open(config), user_played=user_played, ) + + +class ProofParticipant(BaseModel): + draw_position: int + txid: str + vout: int + address: str + amount_sats: int + status: str + + +class RoundProofResponse(BaseModel): + round_id: int + status: str + algorithm: str + pool_address: str | None + server_seed_hash: str | None + server_seed: str | None + draw_height: int | None + draw_confirmations: int + participants: list[ProofParticipant] + participants_hash: str | None + draw_block_hash: str | None + seed: str | None + winner_index: int | None + winner_address: str | None + payout_txid: str | None + + +@router.get("/{round_id}/proof", response_model=RoundProofResponse) +async def round_proof(round_id: int, session: AsyncSession = Depends(get_session)) -> RoundProofResponse: + """B-73 (RND-37, API-45): everything needed to recompute a round's winner without + trusting the server — public, unauthenticated, for a round in any state. A field that + does not exist yet is null (or an empty list). + + `server_seed` is the plaintext column, which is written only by the reveal (the same + transaction that stores the winner, or the close of a round without participants), so + there is no state of the round in which this endpoint can return it early. + """ + round_ = await session.get(Round, round_id) + if round_ is None: + raise http_error(404, "round_not_found", "round not found") + + rows = ( + await session.execute( + select(RoundParticipant, User.address) + .join(User, User.id == RoundParticipant.user_id) + .where(RoundParticipant.round_id == round_id, RoundParticipant.draw_position.is_not(None)) + .order_by(RoundParticipant.draw_position) + ) + ).all() + participants = [ + ProofParticipant( + draw_position=p.draw_position, + txid=p.bet_txid, + vout=p.bet_vout, + address=address, + amount_sats=p.bet_amount_sats, + status=p.status, + ) + for p, address in rows + ] + + seed_decimal = round_.seed_int + winner_index = None + winner_address = None + if seed_decimal is not None and participants: + winner_index = int(seed_decimal) % len(participants) + winner_address = participants[winner_index].address + + return RoundProofResponse( + round_id=round_.id, + status=round_.status, + algorithm="commit-reveal-v1" if round_.server_seed_hash else "block-hash-v0", + pool_address=round_.pool_address or None, + server_seed_hash=round_.server_seed_hash, + server_seed=round_.server_seed, + draw_height=round_.draw_height, + draw_confirmations=round_.draw_confirmations, + participants=participants, + participants_hash=round_.participants_hash, + draw_block_hash=round_.draw_block_hash, + seed=seed_decimal, + winner_index=winner_index, + winner_address=winner_address, + payout_txid=round_.payout_txid, + ) diff --git a/app/bets/service.py b/app/bets/service.py index a6c2188..a5c3427 100644 --- a/app/bets/service.py +++ b/app/bets/service.py @@ -20,6 +20,7 @@ from app.wallet.balance import recompute_balance from app.wallet.hd import derive_user_key from app.wallet.psbt_builder import ( MAX_PARTICIPANTS_PER_ROUND, + RECIPIENT_VOUT, BuiltTransaction, InsufficientFundsError, Utxo, @@ -143,6 +144,7 @@ async def place_bet( user_id=user.id, bet_amount_sats=built.recipient_sats, bet_txid=built.txid, + bet_vout=RECIPIENT_VOUT, # B-73 (DAT-63) broadcast_at=broadcast_at, status="building", ) diff --git a/app/db/models.py b/app/db/models.py index ee186bc..3c9e1e5 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -111,20 +111,48 @@ class Round(Base): winner_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None) fee_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None) payout_txid: Mapped[str | None] = mapped_column(String(64), default=None) + # B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's + # 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with + # `server_seed_hash` (sha256 of the secret, published from then on); the plaintext + # `server_seed` stays NULL until the reveal, in the transaction that stores the winner (or + # at the close of a round without participants), so a copy of the database alone never + # tells who will win. `participants_hash`, `draw_height` and each participant's + # `draw_position` are written when the round enters "drawing" and never change — what a + # resumed draw (B-81) reads instead of recomputing. `draw_confirmations` is the number of + # blocks the chain must have from `draw_height` before its hash is read, copied from the + # config at open. + draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3") + server_seed_enc: Mapped[str | None] = mapped_column(Text, default=None) + server_seed: Mapped[str | None] = mapped_column(String(64), default=None) + server_seed_hash: Mapped[str | None] = mapped_column(String(64), default=None) + participants_hash: Mapped[str | None] = mapped_column(String(64), default=None) + draw_height: Mapped[int | None] = mapped_column(default=None) class RoundParticipant(Base): __tablename__ = "round_participants" - __table_args__ = (UniqueConstraint("round_id", "user_id"),) + __table_args__ = ( + UniqueConstraint("round_id", "user_id"), + # B-73 (DAT-66): one draw position per participant; NULLs (not yet in an ordered + # list) do not collide. + Index("ux_round_participants_round_draw_position", "round_id", "draw_position", unique=True), + ) id: Mapped[int] = mapped_column(primary_key=True) round_id: Mapped[int] = mapped_column(ForeignKey("rounds.id"), index=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) bet_amount_sats: Mapped[int] = mapped_column(BigInteger) bet_txid: Mapped[str] = mapped_column(String(64)) - # Ordering / tie-break field per spec: broadcast time, not confirmation time. + # B-73 (DAT-63): which output of the bet transaction pays the round's pool address — + # together with bet_txid the outpoint the draw list publishes and the payout spends. + bet_vout: Mapped[int] = mapped_column(default=0, server_default="0") + # Informational only since B-73 (DAT-25): the draw order is by bet_txid, which anyone + # can check, not by this server-side timestamp. broadcast_at: Mapped[datetime] = mapped_column(default=utcnow) confirmed_at: Mapped[datetime | None] = mapped_column(default=None) + # B-73 (DAT-66): 0-based position in the ordered draw list, written once on entering + # "drawing" for every confirmed participant. + draw_position: Mapped[int | None] = mapped_column(default=None) status: Mapped[str] = mapped_column(String(16), default="broadcast") @@ -149,6 +177,10 @@ class RoundConfig(Base): # NOT gate the actual draw, which still waits for a real confirmed block for # its entropy (rounds/scheduler.py) — that can take longer than this value. draw_animation_seconds: Mapped[int] = mapped_column(default=20) + # B-73 (DAT-62): blocks the chain must have from a round's draw_height before the hash + # of that block is read; copied onto each round when it opens (DAT-61), so an edit + # applies from the next round. + draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3") fee_rate_sat_vb: Mapped[int] = mapped_column(default=1) rbf_timeout_seconds: Mapped[int] = mapped_column(default=900) # Maintenance switch: when true, the round currently in progress still runs to diff --git a/app/electrum/listener.py b/app/electrum/listener.py index a560f19..a21e466 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -542,8 +542,8 @@ class ElectrumListener: agrees: Callable[[object], bool], description: str, ) -> bool: - """Shared quorum logic behind corroborate_header (B-28) and - corroborate_utxo_spent (B-29): ask every *other* configured server (never + """Shared quorum logic behind + corroborate_utxo_spent (B-29), credits and reinstatements: ask every *other* configured server (never the currently active one — that's exactly what a hostile server or a MITM would control) and require a strict majority of the ones that actually answer to agree, via `agrees`, with what our own connection reported. @@ -749,33 +749,50 @@ class ElectrumListener: txid, votes = counts.most_common(1)[0] return txid if votes * 2 > len(answered) else None - async def corroborate_header(self, height: int, expected_hash: str) -> bool: - """B-28: is `expected_hash` — the header our own active connection - reported for `height` — corroborated by other configured servers before - the draw (rounds/scheduler.py:_wait_for_next_block) treats it as - trustworthy entropy? Without this, a single hostile server (or a MITM on - the one active connection) can single-handedly decide who wins every - round; this raises the bar to controlling a majority of the configured - servers. See _corroborate_majority for the shared quorum logic. + async def agreed_block_hash(self, height: int) -> tuple[str | None, str | None]: + """B-73 (RND-16, ELC-15): the hash of the block at exactly `height` — the draw's + entropy — as the *quorum* agrees on it, and what the active server said about it. + + The header at that height (`blockchain.block.header`) is asked of every configured + server, the active one included, each over its own connection and within ELC-14's + timeouts. A header only counts if it is 80 bytes of valid work (ELC-11). The hash is + returned only when at least the quorum minimum of the *other* servers answered and a + strict majority of everybody who answered names the same one; otherwise the first + element is None and the caller asks again for **the same height** — the draw never + moves to a different block, so whoever relays blocks cannot pick one. The second + element is the active server's own answer (None if it gave none), for the audit row + written on a disagreement. + + The old rule asked only the *other* servers to confirm the block the active one had + announced first after the close, which still let the active server choose which real + block seeded the draw simply by never announcing another. """ - async def _ask(endpoint: ElectrumEndpoint) -> str | None: - client = self._client_factory(endpoint) - try: - await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS) - result = await asyncio.wait_for( - client.request("blockchain.block.header", [height]), - timeout=_CORROBORATION_TIMEOUT_SECONDS, - ) - if not isinstance(result, str): - return None - return header_hex_to_block_hash(result) - except Exception: + async def _ask(client: ElectrumClient) -> str | None: + header = await client.get_block_header(height) + if not isinstance(header, str) or not header_meets_its_own_target(header): return None - finally: - await client.close() + return header_hex_to_block_hash(header) - return await self._corroborate_majority(_ask, lambda block_hash: block_hash == expected_hash, f"block {height} header") + answers = await self._ask_everyone(_ask) + reported = answers.active + if len(answers.answered_others) < self._minimum_other_answers(): + logger.warning( + "block %s: %s of the other servers answered, %s needed", height, len(answers.answered_others), + self._minimum_other_answers(), + ) + return None, reported + votes = Counter(answers.all) + if not votes: + return None, reported + block_hash, count = votes.most_common(1)[0] + if count * 2 <= len(answers.all): + return None, reported + if reported is not None and reported != block_hash: + logger.warning( + "block %s: the active server reported %s but the other servers agree on %s", height, reported, block_hash + ) + return block_hash, reported async def corroborate_utxo_spent(self, scripthash: str, txid: str, vout: int) -> bool: """B-29: before deposits/service.py's find_utxos_missing_from candidates diff --git a/app/rounds/draw.py b/app/rounds/draw.py index 75b12ce..36ce6ab 100644 --- a/app/rounds/draw.py +++ b/app/rounds/draw.py @@ -1,4 +1,5 @@ import hashlib +import secrets # Byte offsets of a standard 80-byte block header: version(4) + prev_block(32) + # merkle_root(32) + timestamp(4) + bits(4) + nonce(4). @@ -68,8 +69,8 @@ def header_meets_its_own_target(header_hex: str) -> bool: a real but implausibly low self-chosen difficulty: a server could still declare an easy target and grind it out with modest hardware. That residual risk is why the draw additionally requires the winning block's header to be corroborated by - the *other* configured servers before using it as the seed (see - ElectrumListener.corroborate_header and rounds/scheduler.py:_wait_for_next_block) + the configured servers at a fixed height before using it as the seed (see + ElectrumListener.agreed_block_hash and rounds/scheduler.py:_wait_for_draw_block) rather than relying on this check alone. """ header_bytes = bytes.fromhex(header_hex) @@ -88,12 +89,56 @@ def header_meets_its_own_target(header_hex: str) -> bool: return hash_int <= target -def draw_winner(participants: list[str], block_hash_hex: str) -> str: - """v1 draw algorithm (docs/ciclo-di-vita-del-round.md): seed = block hash as an - integer, index = seed mod participant_count, winner = participants[index]. Anyone - can recompute and verify it from public data. Deliberately simple/replaceable.""" +def sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def new_server_seed() -> str: + """B-73 (RND-34, DAT-64): a round's secret — 32 random bytes from the OS CSPRNG, as + 64 lowercase hex characters.""" + return secrets.token_bytes(32).hex() + + +def server_seed_commitment(server_seed_hex: str) -> str: + """`server_seed_hash`: sha256 of the secret's 32 bytes, published from the opening of + the round so the operator cannot change the secret afterwards (RND-34).""" + return sha256_hex(bytes.fromhex(server_seed_hex)) + + +def participants_hash(rows: list[tuple[str, int, str]]) -> str: + """The fingerprint of the ordered draw list (RND-14): sha256 of one `txid:vout:address` + line per participant, in `draw_position` order, each line ending in `\n`, UTF-8. `txid` + is the bet transaction's, lowercase hex as explorers show it; `vout` is the output paying + the round's pool address, in decimal; `address` is the participant's own.""" + text = "".join(f"{txid}:{vout}:{address}\n" for txid, vout, address in rows) + return sha256_hex(text.encode("utf-8")) + + +def draw_seed(server_seed_hex: str, block_hash_hex: str, participants_hash_hex: str) -> int: + """RND-17: sha256 of the secret (32 bytes), the hash of the block at `draw_height` (32 + bytes, in the order explorers show it) and `participants_hash` (32 bytes), read as an + unsigned big-endian integer. Nobody who lacks the secret can predict it, and the + operator, who knows the secret, cannot know the block when committing to the list.""" + digest = hashlib.sha256( + bytes.fromhex(server_seed_hex) + bytes.fromhex(block_hash_hex) + bytes.fromhex(participants_hash_hex) + ).digest() + return int.from_bytes(digest, "big") + + +def draw_index(seed: int, participant_count: int) -> int: + """The winner's `draw_position`: seed modulo the number of participants. Every + participant holds exactly one position, so odds are equal whatever the amount.""" + if participant_count <= 0: + raise ValueError("no participants to draw from") + return seed % participant_count + + +def draw_winner(participants: list[str], server_seed_hex: str, block_hash_hex: str, participants_hash_hex: str) -> str: + """The v1 draw algorithm (docs/ciclo-di-vita-del-round.md RND-17), recomputable by anyone + from the public proof (GET /rounds/{id}/proof): `participants` are the entries in + `draw_position` order, the winner is the one at `draw_index(draw_seed(...), n)`. Deliberately + simple and replaceable.""" if not participants: raise ValueError("no participants to draw from") - seed = int(block_hash_hex, 16) - index = seed % len(participants) - return participants[index] + seed = draw_seed(server_seed_hex, block_hash_hex, participants_hash_hex) + return participants[draw_index(seed, len(participants))] diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index 5a1074f..bef59a0 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -14,12 +14,19 @@ from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, from app.electrum.listener import ElectrumListener from app.electrum.scripthash import address_to_scripthash from app.rounds.config import get_round_config -from app.rounds.draw import draw_winner, header_hex_to_block_hash +from app.rounds.draw import ( + draw_index, + draw_seed, + new_server_seed, + participants_hash, + server_seed_commitment, +) from app.rounds.events import broadcaster from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict from app.tx.versions import record_version from app.wallet.hd import derive_round_pool_key +from app.wallet.keystore import decrypt_round_seed, encrypt_round_seed from app.wallet.plm_network import PLM_MAINNET from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction @@ -128,6 +135,10 @@ class RoundScheduler: await self._close_and_draw(round_id) async def _close_and_draw(self, round_id: int) -> None: + """Close a `closing` round once nothing is in flight: either close it (no confirmed + participant) or commit to the draw — fix the participant list, its fingerprint and the + draw height, all in one transaction, and enter `drawing` (RND-13, RND-14, RND-33, + DAT-65) — then run the draw itself (`_complete_draw`).""" async with self._session_factory() as session: await begin_immediate(session) # B-88 round_ = await session.get(Round, round_id) @@ -153,48 +164,140 @@ class RoundScheduler: ) return + # B-73 (RND-14, DAT-66): the draw order is the bet txid, ascending — a value the + # public can read off the chain, with no ties (two bets never share a txid). The + # server-only `broadcast_at` it replaces could not be checked by anyone. participants = ( await session.scalars( select(RoundParticipant) .where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed") - .order_by(RoundParticipant.broadcast_at) + .order_by(RoundParticipant.bet_txid) ) ).all() if not participants: round_.status = "closed" + # DAT-64: nothing was drawn, so the secret has nothing left to protect. + if round_.server_seed_enc and not round_.server_seed: + round_.server_seed = decrypt_round_seed(round_.server_seed_enc) await write_audit_log(session, "round_closed", {"participants": 0}, round_id=round_id) await session.commit() broadcaster.publish() logger.info("round %s closed with no participants", round_id) return - pool_amount = sum(p.bet_amount_sats for p in participants) - addresses: list[str] = [] - user_by_address: dict[str, int] = {} - for p in participants: - user = await session.get(User, p.user_id) - addresses.append(user.address) - user_by_address[user.address] = user.id + # B-73 (RND-19, RND-33): draw_height is "the height we knew at closing, plus one", so + # an unknown height (0) cannot be a base — the round stays "closing" and this is + # tried again at the next step, with one audit row per round. + tip_at_close = self._listener.tip_height + if tip_at_close <= 0: + already_written = await session.scalar( + select(func.count()) + .select_from(AuditLog) + .where(AuditLog.event_type == "draw_baseline_tip_unknown", AuditLog.round_id == round_id) + ) + if not already_written: + logger.warning("round %s: chain tip unknown at closing time, not entering drawing yet", round_id) + await write_audit_log(session, "draw_baseline_tip_unknown", {}, round_id=round_id) + await session.commit() + return + users = { + user.id: user + for user in ( + await session.scalars(select(User).where(User.id.in_({p.user_id for p in participants}))) + ).all() + } + if not round_.server_seed_enc: + # A round opened before B-73 has no commitment. Give it a secret now: it still + # cannot be known to anyone who could steer the block, because the list and the + # height are fixed below before that block exists — only the early publication + # of the commitment is missing for this one round. + seed = new_server_seed() + round_.server_seed_hash = server_seed_commitment(seed) + round_.server_seed_enc = encrypt_round_seed(seed) + for position, participant in enumerate(participants): + participant.draw_position = position + round_.participants_hash = participants_hash( + [(p.bet_txid, p.bet_vout, users[p.user_id].address) for p in participants] + ) + round_.draw_height = tip_at_close + 1 + round_.pool_amount_sats = sum(p.bet_amount_sats for p in participants) round_.status = "drawing" - drawing_started_at = datetime.now(timezone.utc) - round_.drawing_started_at = drawing_started_at + round_.drawing_started_at = datetime.now(timezone.utc) + await write_audit_log( + session, + "draw_committed", + { + "participants_hash": round_.participants_hash, + "draw_height": round_.draw_height, + "participants": len(participants), + "server_seed_hash": round_.server_seed_hash, + }, + round_id=round_id, + ) await session.commit() broadcaster.publish() - tip_at_close = self._listener.tip_height - block_height, block_hash = await self._wait_for_next_block(round_id, tip_at_close, drawing_started_at) - winner_address = draw_winner(addresses, block_hash) + await self._complete_draw(round_id) + + async def _complete_draw(self, round_id: int) -> None: + """The draw of a round in `drawing`, from nothing but what the database holds (RND-36, + DAT-65): wait for the block at `draw_height` to be confirmed and agreed on by the + quorum, compute the winner, store it and reveal the secret in one transaction, then + start the payout. Idempotent — a round no longer in `drawing`, or with a winner + already stored, is left alone.""" + async with self._session_factory() as session: + round_ = await session.get(Round, round_id) + if round_ is None or round_.status != "drawing" or round_.winner_user_id is not None: + return + draw_height = round_.draw_height + draw_confirmations = round_.draw_confirmations + committed_hash = round_.participants_hash + server_seed_enc = round_.server_seed_enc + server_seed_hash = round_.server_seed_hash + waiting_since = round_.drawing_started_at.replace(tzinfo=timezone.utc) + + block_hash = await self._wait_for_draw_block(round_id, draw_height, draw_confirmations, waiting_since) + + server_seed = decrypt_round_seed(server_seed_enc) + if server_seed_commitment(server_seed) != server_seed_hash: + raise RuntimeError(f"round {round_id}: the stored secret does not match its published commitment") async with self._session_factory() as session: await begin_immediate(session) # B-88 round_ = await session.get(Round, round_id) - round_.draw_block_height = block_height + if round_.status != "drawing" or round_.winner_user_id is not None: + return # drawn meanwhile (a second pass over the same round) + participants = ( + await session.scalars( + select(RoundParticipant) + .where(RoundParticipant.round_id == round_id, RoundParticipant.draw_position.is_not(None)) + .order_by(RoundParticipant.draw_position) + ) + ).all() + users = { + user.id: user + for user in ( + await session.scalars(select(User).where(User.id.in_({p.user_id for p in participants}))) + ).all() + } + ordered_rows = [(p.bet_txid, p.bet_vout, users[p.user_id].address) for p in participants] + if participants_hash(ordered_rows) != committed_hash: + raise RuntimeError(f"round {round_id}: the draw list no longer matches its published fingerprint") + seed = draw_seed(server_seed, block_hash, committed_hash) + winner = participants[draw_index(seed, len(participants))] + winner_address = users[winner.user_id].address + pool_amount = sum(p.bet_amount_sats for p in participants if p.status == "confirmed") + + round_.draw_block_height = draw_height round_.draw_block_hash = block_hash - round_.seed_int = str(int(block_hash, 16)) - round_.winner_user_id = user_by_address[winner_address] + round_.seed_int = str(seed) + round_.winner_user_id = winner.user_id round_.pool_amount_sats = pool_amount + # The reveal (RND-18, DAT-64): the plaintext secret becomes visible in the same + # transaction that stores the winner, never before. + round_.server_seed = server_seed round_.status = "paying_out" await write_audit_log( session, @@ -202,11 +305,15 @@ class RoundScheduler: { "winner_address": winner_address, "pool_amount_sats": pool_amount, - "block_height": block_height, + "block_height": draw_height, "block_hash": block_hash, - "participants": len(addresses), + "participants": len(participants), + "draw_height": draw_height, + "participants_hash": committed_hash, + "server_seed": server_seed, + "seed": f"{seed:064x}", }, - user_id=user_by_address[winner_address], + user_id=winner.user_id, round_id=round_id, ) await session.commit() @@ -215,115 +322,94 @@ class RoundScheduler: logger.info("round %s: winner=%s pool=%s", round_id, winner_address, pool_amount) await self._trigger_payout(round_id) - async def _wait_for_next_block( - self, round_id: int, tip_at_close: int, waiting_since: datetime - ) -> tuple[int, str]: - """Waits for a block after tip_at_close and, before handing it back as the - draw's entropy source, requires it to be corroborated by the other - configured Electrum servers (B-28) — our own active connection is exactly - the thing a hostile server or a MITM would control, so its header alone is - not enough to seed a payout. A candidate that fails corroboration is never - used: this keeps waiting for a further block and tries corroborating that - one instead, logging why every time so a stuck draw is visible in - /admin's audit log rather than a silent, unexplained wait. + async def _wait_for_draw_block( + self, round_id: int, draw_height: int, confirmations: int, waiting_since: datetime + ) -> str: + """The hash of the block at `draw_height`, once it is safe to seed the draw with it + (RND-15, RND-16, RND-20; B-73): the chain must have `confirmations` blocks from that + height (a tip of at least `draw_height + confirmations - 1`), and then the quorum of + servers must agree on the header *at that exact height* — never on the tip, never on + a later block. If they do not agree the same height is asked again at the next step + and `draw_header_corroboration_failed` is written once per distinct hash the active + server reported, so a disagreement that lasts does not write a row every 5 seconds. + Whoever produces or relays blocks therefore cannot choose which block seeds the draw: + withholding or delaying one only delays the draw. - This wait has no timeout — it can't, since the draw's entropy genuinely - depends on a future block. B-36: what it lacked was *visibility*, so a - connection that stopped advancing the tip left the round silently frozen - in "drawing" with nothing in the logs or /admin to explain why. Progress - is now logged periodically, and past _DRAW_STALL_THRESHOLD_SECONDS a - draw_stalled audit entry is written (and re-written every threshold - interval for as long as the stall continues) so the wait shows up next - to the draw_header_corroboration_failed entries above. - - B-63: `tip_at_close` of 0 means the tip was *unknown* when the round closed, - not that the chain was at height zero — and "the first block we hear about" - is then not necessarily a block mined after the close. Rather than seed the - draw from a hash that may already have been public while bets were open, the - first height we do learn becomes the baseline and this waits for a block - strictly after it. Since the Electrum listener now only publishes its client - once a header has been applied, and _tick won't run without one, this should - be unreachable — it stays as the local statement of what the draw actually - requires, since nothing else in this function would notice if that stopped - holding. + The wait has no timeout — the entropy genuinely depends on a future block — but it is + observable (B-36): progress is logged every _DRAW_PROGRESS_LOG_INTERVAL_SECONDS, and a + `draw_stalled` row is written when for _DRAW_STALL_THRESHOLD_SECONDS the chain has not + advanced while the round waits for confirmations, or the quorum has been failing to + agree — and repeated every threshold for as long as that lasts. The clock restarts + whenever the tip advances during the confirmation wait, so a chain that is merely + producing its three blocks at the normal pace never counts as stalled. """ - next_progress_log_at = waiting_since + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS) - next_stall_audit_at = waiting_since + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS) - if tip_at_close <= 0: - tip_at_close = await self._adopt_baseline_tip(round_id) + required_tip = draw_height + confirmations - 1 + now = datetime.now(timezone.utc) + next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS) + stall_since = now + next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS) + last_tip = self._listener.tip_height + agreeing = False # True once the confirmations are there and only the quorum is awaited + reported_hashes: set[str | None] = set() + while True: - while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex: - now = datetime.now(timezone.utc) - if now >= next_progress_log_at: - logger.info( - "round %s: still waiting for a block past height %s (%.0fs since drawing started)", + now = datetime.now(timezone.utc) + tip = self._listener.tip_height + if tip >= required_tip: + if not agreeing: + agreeing = True + stall_since = now + next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS) + block_hash, reported = await self._listener.agreed_block_hash(draw_height) + if block_hash is not None: + return block_hash + if reported not in reported_hashes: + reported_hashes.add(reported) + logger.error( + "round %s: block %s header (active server: %s) is not agreed by the other Electrum " + "servers; asking again for the same height", round_id, - tip_at_close, - (now - waiting_since).total_seconds(), + draw_height, + reported, ) - next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS) - if now >= next_stall_audit_at: async with self._session_factory() as session: await write_audit_log( session, - "draw_stalled", - { - "tip_at_close": tip_at_close, - "current_tip_height": self._listener.tip_height, - "elapsed_seconds": int((now - waiting_since).total_seconds()), - }, + "draw_header_corroboration_failed", + {"height": draw_height, "reported_hash": reported}, round_id=round_id, ) await session.commit() - next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS) - await asyncio.sleep(_TICK_INTERVAL_SECONDS) - height = self._listener.tip_height - block_hash = header_hex_to_block_hash(self._listener.tip_header_hex) - if await self._listener.corroborate_header(height, block_hash): - return height, block_hash - logger.error( - "round %s: block %s header %s could not be corroborated by other Electrum servers; " - "waiting for a further block", - round_id, - height, - block_hash, - ) - async with self._session_factory() as session: - await write_audit_log( - session, - "draw_header_corroboration_failed", - {"height": height, "reported_hash": block_hash}, - round_id=round_id, - ) - await session.commit() - tip_at_close = height + elif tip != last_tip: + last_tip = tip + stall_since = now + next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS) - async def _adopt_baseline_tip(self, round_id: int) -> int: - """B-63: the height the draw must find a *later* block than, for the case - where the tip wasn't known at closing time. Waits for a header to arrive and - takes that height as the baseline — the block it describes may predate the - close, which is exactly why it is used as the floor rather than as the seed — - and records why, since a draw that waits one extra block should be explainable - from /admin rather than looking like a stall. - """ - while not self._listener.tip_header_hex or self._listener.tip_height <= 0: + if now >= next_progress_log_at: + logger.info( + "round %s: still waiting for block %s (%s confirmation(s) needed, chain at %s, %.0fs since drawing started)", + round_id, + draw_height, + confirmations, + tip, + (now - waiting_since).total_seconds(), + ) + next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS) + if now >= next_stall_audit_at: + async with self._session_factory() as session: + await write_audit_log( + session, + "draw_stalled", + { + "draw_height": draw_height, + "current_tip_height": tip, + "elapsed_seconds": int((now - stall_since).total_seconds()), + }, + round_id=round_id, + ) + await session.commit() + next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS) await asyncio.sleep(_TICK_INTERVAL_SECONDS) - height = self._listener.tip_height - logger.warning( - "round %s: chain tip was unknown at closing time; using height %s as the draw baseline " - "and waiting for a further block", - round_id, - height, - ) - async with self._session_factory() as session: - await write_audit_log( - session, - "draw_baseline_tip_unknown", - {"baseline_height": height}, - round_id=round_id, - ) - await session.commit() - return height async def _retry_payout_if_due(self, round_id: int) -> None: """B-26: whether a "paying_out" round is due for another payout attempt. diff --git a/app/rounds/service.py b/app/rounds/service.py index 3ba9566..36c603c 100644 --- a/app/rounds/service.py +++ b/app/rounds/service.py @@ -8,7 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Round, RoundConfig from app.rounds.config import get_round_config from app.rounds.events import broadcaster +from app.rounds.draw import new_server_seed, server_seed_commitment from app.wallet.hd import derive_pool_address +from app.wallet.keystore import encrypt_round_seed logger = logging.getLogger(__name__) @@ -24,6 +26,16 @@ _OPEN_ROUND_ATTEMPTS = 3 # advertised jackpot (rounds.py) and the actual payout (scheduler.py) can't diverge. +def _new_round_secret() -> dict: + """B-73 (RND-34): a fresh secret for a round about to open, as the columns that hold it — + its published commitment and its encrypted form, never the plaintext.""" + server_seed = new_server_seed() + return { + "server_seed_hash": server_seed_commitment(server_seed), + "server_seed_enc": encrypt_round_seed(server_seed), + } + + def winner_share(pool_amount_sats: int) -> int: return pool_amount_sats * 70 // 100 @@ -129,6 +141,10 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None: status="open", duration_seconds=config.round_duration_seconds, cooldown_seconds=config.round_cooldown_seconds, + draw_confirmations=config.draw_confirmations, # B-73 (DAT-61) + # B-73 (RND-34, DAT-64): the commitment, then the secret itself encrypted — the + # plaintext column stays NULL until the reveal. + **_new_round_secret(), ) session.add(round_) try: diff --git a/app/static/admin.html b/app/static/admin.html index 58c0893..f772d98 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -83,6 +83,8 @@ + + diff --git a/app/static/admin.js b/app/static/admin.js index 81a3db3..90b29e0 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -150,6 +150,7 @@ async function adminLoadConfig() { document.getElementById('admin-round-duration').value = data.round_duration_seconds; document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds; document.getElementById('admin-draw-animation').value = data.draw_animation_seconds; + document.getElementById('admin-draw-confirmations').value = data.draw_confirmations; document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb; document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds; renderMaintenanceState(data.paused); @@ -209,6 +210,7 @@ async function adminSave() { round_duration_seconds: parseInt(document.getElementById('admin-round-duration').value, 10), round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10), draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10), + draw_confirmations: parseInt(document.getElementById('admin-draw-confirmations').value, 10), fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10), rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10), }; diff --git a/app/static/i18n.js b/app/static/i18n.js index 4061867..c9fe181 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -149,6 +149,7 @@ const TRANSLATIONS = { 'error.balance_pending_confirmation': 'You have {pending_plm} PLM pending confirmation — it is not spendable yet.', 'error.amount_below_network_fee': 'The amount is too small to cover the network fee.', 'error.invalid_address': 'Not a valid PLM address (it must start with plm1q…).', + 'error.round_not_found': 'Round not found.', 'error.amount_below_minimum': 'The minimum withdrawal amount is {minimum_plm} PLM.', 'error.current_password_incorrect': 'The current password is incorrect.', 'error.password_too_short': 'The new password must be at least {minimum} characters.', @@ -316,6 +317,7 @@ const TRANSLATIONS = { 'error.balance_pending_confirmation': 'Hai {pending_plm} PLM in attesa di conferma — non ancora disponibili per la spesa.', 'error.amount_below_network_fee': "L'importo è troppo basso per coprire la fee di rete.", 'error.invalid_address': 'Indirizzo PLM non valido (deve iniziare con plm1q…).', + 'error.round_not_found': 'Round non trovato.', 'error.amount_below_minimum': "L'importo minimo di prelievo è {minimum_plm} PLM.", 'error.current_password_incorrect': 'La password attuale non è corretta.', 'error.password_too_short': 'La nuova password deve avere almeno {minimum} caratteri.', @@ -483,6 +485,7 @@ const TRANSLATIONS = { 'error.balance_pending_confirmation': 'Tienes {pending_plm} PLM pendientes de confirmación — todavía no se pueden gastar.', 'error.amount_below_network_fee': 'El importe es demasiado pequeño para cubrir la comisión de red.', 'error.invalid_address': 'Dirección PLM no válida (debe empezar por plm1q…).', + 'error.round_not_found': 'Ronda no encontrada.', 'error.amount_below_minimum': 'El importe mínimo de retiro es {minimum_plm} PLM.', 'error.current_password_incorrect': 'La contraseña actual no es correcta.', 'error.password_too_short': 'La nueva contraseña debe tener al menos {minimum} caracteres.', @@ -650,6 +653,7 @@ const TRANSLATIONS = { 'error.balance_pending_confirmation': 'Vous avez {pending_plm} PLM en attente de confirmation — pas encore disponibles.', 'error.amount_below_network_fee': 'Le montant est trop faible pour couvrir les frais de réseau.', 'error.invalid_address': 'Adresse PLM invalide (elle doit commencer par plm1q…).', + 'error.round_not_found': 'Manche introuvable.', 'error.amount_below_minimum': 'Le montant minimum de retrait est de {minimum_plm} PLM.', 'error.current_password_incorrect': "Le mot de passe actuel n'est pas correct.", 'error.password_too_short': 'Le nouveau mot de passe doit comporter au moins {minimum} caractères.', @@ -817,6 +821,7 @@ const TRANSLATIONS = { 'error.balance_pending_confirmation': 'Sie haben {pending_plm} PLM, die noch auf Bestätigung warten — noch nicht verfügbar.', 'error.amount_below_network_fee': 'Der Betrag ist zu klein, um die Netzwerkgebühr zu decken.', 'error.invalid_address': 'Keine gültige PLM-Adresse (sie muss mit plm1q… beginnen).', + 'error.round_not_found': 'Runde nicht gefunden.', 'error.amount_below_minimum': 'Der Mindestauszahlungsbetrag beträgt {minimum_plm} PLM.', 'error.current_password_incorrect': 'Das aktuelle Passwort ist nicht korrekt.', 'error.password_too_short': 'Das neue Passwort muss mindestens {minimum} Zeichen lang sein.', @@ -984,6 +989,7 @@ const TRANSLATIONS = { 'error.balance_pending_confirmation': 'У вас есть {pending_plm} PLM, ожидающих подтверждения — они пока недоступны для расходования.', 'error.amount_below_network_fee': 'Сумма слишком мала, чтобы покрыть комиссию сети.', 'error.invalid_address': 'Некорректный адрес PLM (он должен начинаться с plm1q…).', + 'error.round_not_found': 'Раунд не найден.', 'error.amount_below_minimum': 'Минимальная сумма вывода — {minimum_plm} PLM.', 'error.current_password_incorrect': 'Текущий пароль указан неверно.', 'error.password_too_short': 'Новый пароль должен содержать не менее {minimum} символов.', @@ -1151,6 +1157,7 @@ const TRANSLATIONS = { 'error.balance_pending_confirmation': '您有 {pending_plm} PLM 待确认 —— 尚不可用于支出。', 'error.amount_below_network_fee': '金额太小,不足以支付网络手续费。', 'error.invalid_address': 'PLM 地址无效(必须以 plm1q… 开头)。', + 'error.round_not_found': '未找到该轮次。', 'error.amount_below_minimum': '最低提现金额为 {minimum_plm} PLM。', 'error.current_password_incorrect': '当前密码不正确。', 'error.password_too_short': '新密码长度至少需要 {minimum} 个字符。', diff --git a/app/wallet/keystore.py b/app/wallet/keystore.py index d013713..f6d517b 100644 --- a/app/wallet/keystore.py +++ b/app/wallet/keystore.py @@ -9,3 +9,14 @@ def encrypt_xprv(xprv_base58: str) -> bytes: def decrypt_xprv(token: bytes) -> str: return Fernet(settings.xprv_encryption_key).decrypt(token).decode() + + +def encrypt_round_seed(server_seed_hex: str) -> str: + """B-73 (DAT-64, SEC-56): a round's secret encrypted at rest with the same key that + protects the master xprv, so a copy of the database alone never reveals it. The Fernet + token is ASCII, stored as text.""" + return Fernet(settings.xprv_encryption_key).encrypt(server_seed_hex.encode()).decode() + + +def decrypt_round_seed(token: str) -> str: + return Fernet(settings.xprv_encryption_key).decrypt(token.encode()).decode() diff --git a/app/wallet/psbt_builder.py b/app/wallet/psbt_builder.py index 386709c..83e0994 100644 --- a/app/wallet/psbt_builder.py +++ b/app/wallet/psbt_builder.py @@ -82,6 +82,11 @@ class InsufficientFundsError(Exception): self.params = params +# B-73 (DAT-63): the recipient is always the first output of a single-recipient transaction +# (the change, when there is one, follows it) — which is what a bet records as `bet_vout`. +RECIPIENT_VOUT = 0 + + @dataclass class Utxo: txid: str diff --git a/docs/adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md b/docs/adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md index 0df3fb1..39ad825 100644 --- a/docs/adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md +++ b/docs/adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md @@ -31,5 +31,5 @@ di 2 server, la corroborazione fallisce. scelti dall'operatore ([LIM-11]). Regole in [connessione-electrum.md](../connessione-electrum.md#5-il-quorum) (B-83, B-84). -*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` · `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_reinstatement` · `app/electrum/listener.py:ElectrumListener.confirmation_proven` · `app/main.py:lifespan` · +*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` · `app/electrum/listener.py:ElectrumListener.agreed_block_hash` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_reinstatement` · `app/electrum/listener.py:ElectrumListener.confirmation_proven` · `app/main.py:lifespan` · *Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_confirmation_proof.py` diff --git a/docs/adr/ADR-24-l-estrazione-commit-reveal-fra-server.md b/docs/adr/ADR-24-l-estrazione-commit-reveal-fra-server.md index 70dda5a..807d9c9 100644 --- a/docs/adr/ADR-24-l-estrazione-commit-reveal-fra-server.md +++ b/docs/adr/ADR-24-l-estrazione-commit-reveal-fra-server.md @@ -1,6 +1,6 @@ # ADR-24 — L'estrazione è un commit-reveal fra il server e un blocco futuro -- **Stato:** Accettata — implementazione da completare (B-73) +- **Stato:** Accettata - **Area:** Round ed estrazione - **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*. @@ -54,9 +54,5 @@ pubblicati, esito non calcolabile da chi produce o trasmette i blocchi. [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#3--estrazione), endpoint in [api.md](../api.md) (B-73). -*Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/service.py:open_new_round_if_needed` · -*Test:* `tests/unit/test_draw.py` -*Stato:* da implementare (B-73) · *Oggi:* seme = hash del primo blocco -confermato dopo la chiusura, partecipanti ordinati per `broadcast_at` (dato -solo del server, senza criterio di pareggio), nessun impegno né prova -pubblica. +*Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._complete_draw` · `app/rounds/service.py:open_new_round_if_needed` · `app/api/routes/rounds.py:round_proof` · +*Test:* `tests/unit/test_draw.py`, `tests/unit/test_draw_proof.py` diff --git a/docs/adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md b/docs/adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md index 6b6e469..d5e1d61 100644 --- a/docs/adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md +++ b/docs/adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md @@ -1,6 +1,6 @@ # ADR-25 — Il blocco di estrazione è fissato per altezza, è successivo alla chiusura, e si usa solo quando è confermato e corroborato -- **Stato:** Accettata — implementazione da completare (B-73, B-81) +- **Stato:** Accettata — implementazione da completare (B-81) - **Area:** Round ed estrazione - **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*. @@ -30,9 +30,8 @@ chiusura significa «sconosciuta»: la prima altezza appresa fa da base. - *Alternative scartate:* timeout sull'attesa con ripiego su un altro blocco (riaprirebbe la scelta del blocco). -*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` · `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip` · `app/rounds/scheduler.py:RoundScheduler._tick` · -*Test:* `tests/unit/test_scheduler.py::test_wait_for_next_block_never_seeds_the_draw_from_a_pre_close_block`, `tests/unit/test_scheduler.py::test_wait_for_next_block_retries_after_a_failed_corroboration` -*Stato:* da implementare (B-73, B-81) · *Oggi:* si usa il primo blocco oltre -`tip_at_close` visto dal server attivo, a 1 conferma; dopo una -corroborazione fallita si passa al blocco successivo; `tip_at_close` e i -partecipanti vivono solo in memoria e `_tick` salta i round in `drawing`. +*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._tick` · +*Test:* `tests/unit/test_scheduler.py::test_a_block_relayer_cannot_choose_the_block_that_seeds_the_draw`, `tests/unit/test_scheduler.py::test_a_disagreement_asks_the_same_height_again_and_is_audited_once_per_hash` +*Stato:* da implementare (B-81) · *Oggi:* `draw_height`, `participants_hash` e le +posizioni sono salvati, ma `_tick` salta i round in `drawing`: dopo un errore +o un riavvio il round non riparte. diff --git a/docs/api.md b/docs/api.md index 2a19259..2b3f59f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -34,7 +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` | `/rounds/{round_id}/proof` | nessuna | [API-45](#get-roundsround_idproof) | | `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) | @@ -548,10 +548,10 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. | `draw_block_height` | intero o `null` | altezza del blocco usato per l'estrazione | | `draw_block_hash` | stringa o `null` | hash di quel blocco; è **uno** degli ingressi del seme, non il seme (vedi [API-45](#get-roundsround_idproof)) | | `draw_waiting_since` | stringa ISO o `null` | inizio dell'attesa del blocco; valorizzato **solo** mentre `status` è `drawing` | - | `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)* | + | `server_seed_hash` | stringa o `null` | impegno del server sul segreto del round, pubblicato dall'apertura; `null` per un round aperto prima di B-73 | + | `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` | + | `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` | + | `draw_confirmations` | intero o `null` | conferme richieste sul blocco di estrazione, fissate all'apertura del round | | `pool_address` | stringa o `null` | indirizzo del montepremi del round, verificabile su un esploratore | | `chain_tip_height` | intero o `null` | ultima altezza nota della catena; `null` finché non è nota | | `lottery_paused` | booleano | lotteria in pausa, per qualunque motivo | @@ -577,9 +577,8 @@ 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-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. + *Stato:* da implementare (B-82, B-95), per i campi marcati · *Oggi:* + `bet_amount_sats` è sempre il costo configurato. #### GET /rounds/stream @@ -641,7 +640,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. | `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` | lista | ingresso in `drawing` | elenco in ordine di `draw_position`: `{"draw_position", "txid", "vout", "address", "amount_sats", "status"}`, dove `txid:vout` è l'output della scommessa verso `pool_address`, `address` l'indirizzo del partecipante (dove andrebbe la vincita), `amount_sats` l'importo netto della scommessa 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`) | @@ -675,11 +674,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. `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. + *Codice:* `app/api/routes/rounds.py:round_proof`, + `app/rounds/draw.py:draw_winner` · + *Test:* `tests/unit/test_draw_proof.py` ### QR @@ -763,9 +760,8 @@ dei parametri operativi in "draw_confirmations", "max_round_pool_sats", "paused", "paused_reason"}`. *Codice:* `app/api/routes/admin.py:read_config` · *Test:* `tests/unit/test_admin.py` - *Stato:* da implementare (B-73, B-82, B-85, B-95) · *Oggi:* mancano - `deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats` e - `paused_reason`. + *Stato:* da implementare (B-82, B-95) · *Oggi:* mancano + `deposit_confirmations` e `max_round_pool_sats`. #### PUT /admin/config @@ -783,7 +779,7 @@ dei parametri operativi in | `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)* | + | `draw_confirmations` | 1 – 20 | | `max_round_pool_sats` | `0` (disattivato), oppure da `bet_amount_sats` a 4 000 000 000 000 000 — *da implementare (B-95)* | I limiti sono quelli di [modello-dati.md](modello-dati.md) (tabella @@ -802,8 +798,9 @@ dei parametri operativi in *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. + *Stato:* da implementare (B-82, B-95), per i due campi nuovi e la copia + all'apertura di costo ed `fee_address` · *Oggi:* sono accettati i primi + otto campi. #### POST /admin/pause e POST /admin/resume @@ -891,8 +888,8 @@ dei parametri operativi in completa di un round è in [API-45](#get-roundsround_idproof). *Codice:* `app/api/routes/admin.py:list_rounds` · *Test:* `tests/unit/test_admin.py` - *Stato:* da implementare (B-73, B-79, B-82), per gli ultimi quattro - campi · *Oggi:* la lista si ferma a `pool_address`. + *Stato:* da implementare (B-79, B-82), per gli ultimi due campi · *Oggi:* + la lista si ferma a `draw_height`. #### GET /admin/audit-log @@ -1065,7 +1062,7 @@ dei parametri operativi in | `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | | `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | | `bug_report_not_found` | 404 | `app/api/routes/admin.py` | — | segnalazione inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | -| `round_not_found` | 404 | `app/api/routes/rounds.py` (`GET /rounds/{id}/proof`) | — | round inesistente — *da implementare (B-73)* | no — da aggiungere | +| `round_not_found` | 404 | `app/api/routes/rounds.py` (`GET /rounds/{id}/proof`) | — | round inesistente | sì | | `not_found` | 404 | gestore delle eccezioni HTTP in `app/main.py` | — | percorso inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | | `method_not_allowed` | 405 | gestore delle eccezioni HTTP in `app/main.py` | — | metodo non ammesso — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | | `too_many_streams` | 503 | `app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | diff --git a/docs/architettura.md b/docs/architettura.md index e928069..de3f3ff 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -216,7 +216,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. *Da implementare:* seme commit-reveal e impronta dei partecipanti (B-73) | +| `app/rounds/draw.py` | Matematica degli header (hash, prev-hash, target) e scelta del vincitore. Seme commit-reveal e impronta dei partecipanti | | `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) | @@ -553,7 +553,7 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]). 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` · + `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_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 @@ -621,9 +621,8 @@ La configurazione è divisa in due posti con regole diverse. effetti in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md). *Codice:* `app/rounds/config.py:get_round_config` · `app/db/models.py:RoundConfig` - *Stato:* da implementare (B-73, B-82, B-85, B-95) · *Oggi:* - `deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats` e il - motivo della pausa non esistono. + *Stato:* da implementare (B-82, B-95) · *Oggi:* `deposit_confirmations` e + `max_round_pool_sats` non esistono. - **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili: la ripartizione 70/30, le conferme richieste per le transazioni della piattaforma (1) e per le uscite coinbase (120), i limiti di input e di diff --git a/docs/audit-log.md b/docs/audit-log.md index 7bc75f9..3f89dce 100644 --- a/docs/audit-log.md +++ b/docs/audit-log.md @@ -38,8 +38,8 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in transazione). Per `payout_failed` la scrittura è anche *best effort*: se fallisce, l'errore viene registrato nel log applicativo (`could not record the payout failure of round …`) e ignorato. - *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`, - `app/rounds/scheduler.py:RoundScheduler._adopt_baseline_tip`, + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block`, + `app/rounds/scheduler.py:RoundScheduler._close_and_draw`, `app/rounds/scheduler.py:RoundScheduler._log_payout_failure`, `app/tx/broadcast.py:RbfBumper._tick`, `app/main.py:lifespan`, `app/rounds/scheduler.py:RoundScheduler._tick`, @@ -115,8 +115,9 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* `tests/unit/test_scheduler.py` (solo `payout_failed`) - *Stato:* da implementare (B-87, B-96, B-73), per le ultime tre letture · - *Oggi:* l'unica lettura è quella di `payout_failed`. + *Stato:* da implementare (B-87, B-96), per le ultime due letture · + *Oggi:* le letture sono quelle di `payout_failed` e di + `draw_baseline_tip_unknown`. ## Catalogo degli eventi @@ -186,8 +187,8 @@ flowchart LR riga. `user_id` no, `round_id` no. Stessa transazione della modifica. *Codice:* `app/api/routes/admin.py:update_config` · *Test:* `tests/unit/test_admin.py` - *Stato:* da implementare (B-73, B-82, B-95), per gli ultimi tre campi · - *Oggi:* i campi sono i primi sette. + *Stato:* da implementare (B-82, B-95), per gli ultimi due campi · + *Oggi:* i campi sono i primi otto. - **[AUD-11]** `lottery_paused` — la lotteria è stata messa in pausa, da chiunque. Payload `{"reason"}`, più `"note"` quando l'operatore l'ha @@ -413,9 +414,7 @@ flowchart LR logica rilegge la riga: la ripresa dell'estrazione (RND-36) legge i valori dal round. *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · - *Test:* — - *Stato:* da implementare (B-73) · *Oggi:* l'ingresso in `drawing` non - scrive alcun evento. + *Test:* `tests/unit/test_scheduler.py` - **[AUD-24]** `winner_drawn` — il vincitore è stato estratto e salvato; in questa riga il segreto del round viene **rivelato**. Payload @@ -434,11 +433,8 @@ flowchart LR round e lo porta a `paying_out` ([RND-18](ciclo-di-vita-del-round.md#3--estrazione)): `server_seed` non può quindi comparire nel registro prima che il vincitore sia salvato. - *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · - *Test:* `tests/unit/test_scheduler.py` - *Stato:* da implementare (B-73), per le ultime quattro chiavi · *Oggi:* il - payload ha solo le prime cinque; il round non ha segreto né impronta dei - partecipanti. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._complete_draw` · + *Test:* `tests/unit/test_draw_proof.py` - **[AUD-25]** `draw_baseline_tip_unknown` — quando il round sarebbe pronto per `drawing`, l'altezza della catena non era nota: il round resta in @@ -447,13 +443,8 @@ flowchart LR Una sola riga per round ([AUD-09]). `user_id` no, `round_id` sì. Transazione propria ([AUD-03]). Dovrebbe essere irraggiungibile (lo scheduler non lavora senza un'altezza nota). - *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`, il primo blocco ricevuto è preso come base e l'estrazione - aspetta il blocco successivo; payload `{"baseline_height"}` con l'altezza - adottata. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` - **[AUD-26]** `draw_header_corroboration_failed` — raggiunte le conferme richieste, il quorum dei server non concorda sull'header all'altezza @@ -463,11 +454,8 @@ flowchart LR `{"height", "reported_hash"}`: `height` è sempre `draw_height`, `reported_hash` l'hash riportato dal server attivo. `user_id` no, `round_id` sì. Transazione propria ([AUD-03]). - *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` · - *Test:* `tests/unit/test_scheduler.py` (solo il comportamento attuale) - *Stato:* da implementare (B-73) · *Oggi:* una riga per ogni blocco - candidato rifiutato, con `height` l'altezza di quel candidato; l'estrazione - aspetta poi un blocco ulteriore invece di richiedere la stessa altezza. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` · + *Test:* `tests/unit/test_scheduler.py` - **[AUD-27]** `draw_stalled` — l'estrazione è in stallo secondo la regola di [RND-20](ciclo-di-vita-del-round.md#3--estrazione), che ne è il @@ -476,14 +464,8 @@ flowchart LR "elapsed_seconds"}`: altezza del blocco di estrazione, altezza della catena nota in quel momento, secondi da quando dura la condizione di RND-20. `user_id` no, `round_id` sì. Transazione propria ([AUD-03]). - *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` · - *Test:* `tests/unit/test_scheduler.py` (solo il comportamento attuale) - *Stato:* da implementare (B-73) · *Oggi:* scritto quando l'attesa di un - nuovo blocco supera 360 secondi contati da `drawing_started_at`, e mai - durante una corroborazione; payload `{"tip_at_close", - "current_tip_height", "elapsed_seconds"}`, dove `tip_at_close` è la base - *attuale* dell'attesa, che dopo un `draw_header_corroboration_failed` - diventa l'altezza del candidato rifiutato. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` · + *Test:* `tests/unit/test_scheduler.py` - **[AUD-28]** `payout_failed` — un tentativo di pagamento non è partito. Payload `{"round_id", "reason"}`. `user_id` dipende: il vincitore, tranne @@ -660,10 +642,9 @@ flowchart LR `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/confirmation.py:_on_payout_confirmed`, `app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee` - *Stato:* da implementare (B-73, B-87, B-92, B-94), per i quattro fatti che - escono dall'elenco · *Oggi:* anche l'ingresso in `drawing`, gli RBF - impossibili, la registrazione e il cambio password riuscito non producono - alcuna riga. + *Stato:* da implementare (B-87, B-92, B-94), per i tre fatti che escono + dall'elenco · *Oggi:* gli RBF impossibili, la registrazione e il cambio + password riuscito non producono alcuna riga. ## Log applicativo diff --git a/docs/ciclo-di-vita-del-round.md b/docs/ciclo-di-vita-del-round.md index 8f16f7a..97b42ed 100644 --- a/docs/ciclo-di-vita-del-round.md +++ b/docs/ciclo-di-vita-del-round.md @@ -111,10 +111,9 @@ richiesta di scommessa (vedi round successivo, mai per quello in corso. *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:round_deadline` · *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py` (solo durata e pausa) - *Stato:* da implementare (B-95, 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. + *Stato:* da implementare (B-95) · *Oggi:* sono fotografate durata, pausa e + `draw_confirmations`; costo della scommessa e `fee_address` sono letti dalla + configurazione a ogni uso, `max_round_pool_sats` non esiste. - **[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 @@ -143,10 +142,8 @@ richiesta di scommessa (vedi `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. + *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/draw.py:new_server_seed`, `app/wallet/keystore.py:encrypt_round_seed`, `app/api/routes/rounds.py:current_round` · + *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_draw_proof.py` - **[RND-35]** **Un indirizzo del montepremi per round**: all'apertura il round riceve un proprio indirizzo `m/84'/746'/0'/2/`, salvato in `rounds.pool_address` ed esposto da `GET /rounds/current`. Le scommesse del @@ -241,20 +238,14 @@ giocata, ma il montepremi pubblicizzato non supera mai quello che verrà pagato. 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:* — (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`. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw`, `app/rounds/draw.py:participants_hash` · + *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_draw.py`, `tests/unit/test_draw_proof.py` - **[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. + *Test:* `tests/unit/test_scheduler.py` ## 3 · Estrazione @@ -290,12 +281,11 @@ flowchart TB `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` (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. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` · + *Test:* `tests/unit/test_scheduler.py` + *Stato:* da implementare (B-81) · *Oggi:* la condizione si controlla dentro + `_close_and_draw`, che resta in attesa per tutta la durata senza che + `_tick` la riprenda: dopo un errore o un riavvio non riparte. - **[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 @@ -308,12 +298,8 @@ flowchart TB riportato dal server attivo per quell'altezza, non a ogni richiesta, così un disaccordo che dura non scrive una riga ogni 5 secondi; la durata dell'attesa resta visibile tramite `draw_stalled` ([RND-20]). - *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block`, `app/electrum/listener.py:ElectrumListener.corroborate_header` · - *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. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block`, `app/electrum/listener.py:ElectrumListener.agreed_block_hash` · + *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_electrum_listener.py` - **[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), @@ -321,10 +307,8 @@ flowchart TB 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` (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`. + *Codice:* `app/rounds/draw.py:draw_seed`, `app/rounds/draw.py:draw_index`, `app/rounds/draw.py:draw_winner`, `app/rounds/scheduler.py:RoundScheduler._complete_draw` · + *Test:* `tests/unit/test_draw.py`, `tests/unit/test_draw_proof.py` - **[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 @@ -339,11 +323,8 @@ flowchart TB 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:* — (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. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._complete_draw` · + *Test:* `tests/unit/test_draw_proof.py` - **[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 @@ -351,11 +332,8 @@ flowchart TB `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. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` - **[RND-20]** **Attesa lunga ma visibile**: l'attesa non ha un limite, perché dipende davvero dall'arrivo dei blocchi. Ogni 60 secondi (`_DRAW_PROGRESS_LOG_INTERVAL_SECONDS`) se ne registra l'avanzamento nei @@ -365,11 +343,8 @@ flowchart TB 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` · + *Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_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 @@ -378,12 +353,11 @@ flowchart TB 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. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._complete_draw` · + *Test:* `tests/unit/test_draw_proof.py` (solo `_complete_draw`) + *Stato:* da implementare (B-81) · *Oggi:* tutto ciò che serve è nel database + e `_complete_draw` è idempotente, ma `_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 @@ -392,11 +366,8 @@ flowchart TB 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. + *Codice:* `app/api/routes/rounds.py:round_proof` · + *Test:* `tests/unit/test_draw_proof.py` ### Come si verifica un'estrazione diff --git a/docs/come-funziona.md b/docs/come-funziona.md index be20647..5d1b17d 100644 --- a/docs/come-funziona.md +++ b/docs/come-funziona.md @@ -185,7 +185,7 @@ solo polling. | Partecipanti massimi per round | 400 | 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 sul blocco di estrazione (`draw_confirmations`) | 3 | 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/` (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 | diff --git a/docs/connessione-electrum.md b/docs/connessione-electrum.md index 1416ca8..6dff40e 100644 --- a/docs/connessione-electrum.md +++ b/docs/connessione-electrum.md @@ -275,7 +275,7 @@ server attivo, ed è la riga [ELC-27]. Le conferme delle transazioni | ID | Prima di… | Si verifica che… | |---|---|---| -| **[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-15]** | usare un blocco per l'estrazione | l'header che ciascun server, **attivo compreso**, restituisce per l'altezza di estrazione (`blockchain.block.header`, 80 byte con lavoro valido) abbia lo stesso hash, con almeno 2 risposte dagli altri server e la maggioranza stretta di chi ha risposto; 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, 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 | @@ -283,7 +283,7 @@ server attivo, ed è la riga [ELC-27]. Le conferme delle transazioni | **[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`, +*Codice:* `app/electrum/listener.py:ElectrumListener.agreed_block_hash`, `app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent`, `app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit`, `app/electrum/listener.py:ElectrumListener.corroborate_utxo_reinstatement`, @@ -293,10 +293,6 @@ server attivo, ed è la riga [ELC-27]. Le conferme delle transazioni 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-27] — *Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie diff --git a/docs/guida-admin.md b/docs/guida-admin.md index 5b3a821..144eefd 100644 --- a/docs/guida-admin.md +++ b/docs/guida-admin.md @@ -51,7 +51,8 @@ business — quelli si toccano solo da qui. | **Bet amount (PLM)** | Il costo fisso d'ingresso per round. È anche l'importo minimo prelevabile: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). | | **Durata round (secondi)** | Quanto resta aperto un round prima di chiudersi ed estrarre il vincitore. Il taglio per le nuove giocate scatta esattamente allo scadere di questo tempo (verificato ad ogni bet, non dipende dal ciclo dello scheduler) — è un "semaforo giallo": nessuna nuova entrata, ma le bet già trasmesse prima dello scadere hanno comunque tempo di confermarsi prima che il round chiuda ed estragga. | | **Pausa tra un round e il successivo (secondi)** | Cooldown dopo la chiusura di un round, prima che il successivo si apra — dà tempo ai giocatori di vedere l'esito. | -| **Durata animazione estrazione (secondi)** | Tempo minimo per cui la dashboard di ogni utente mostra l'animazione "Estrazione in corso" dopo la chiusura del round, prima di rivelare il vincitore. È solo un minimo: il processo reale aspetta fino a 3 blocchi in sequenza (conferma dell'ultima bet in sospeso, blocco dell'estrazione, conferma del payout — un blocco ogni 2 minuti in media), quindi l'animazione può durare più a lungo di questo valore, mai meno. | +| **Durata animazione estrazione (secondi)** | Tempo minimo per cui la dashboard di ogni utente mostra l'animazione "Estrazione in corso" dopo la chiusura del round, prima di rivelare il vincitore. È solo un minimo: il processo reale aspetta più blocchi in sequenza (conferma dell'ultima bet in sospeso, il blocco dell'estrazione con le sue conferme, conferma del payout — un blocco ogni 2 minuti in media), quindi l'animazione può durare più a lungo di questo valore, mai meno. | +| **Conferme sul blocco di estrazione** | Da 1 a 20 (predefinito 3): quanti blocchi deve avere la catena a partire dal blocco di estrazione prima che il suo hash scelga il vincitore. Più conferme rendono l'estrazione più robusta a una riorganizzazione della catena e allungano l'attesa di circa 2 minuti per blocco. Si applica dal round successivo. | | **Fee rate di rete (sat/vB)** | Fee per byte usata per costruire bet, payout e prelievi. | | **Timeout prima del fee-bump RBF (secondi)** | Dopo quanto tempo senza conferma una transazione viene ritrasmessa con fee più alta. | diff --git a/docs/guida-utente.md b/docs/guida-utente.md index f35d343..aca93c6 100644 --- a/docs/guida-utente.md +++ b/docs/guida-utente.md @@ -48,17 +48,19 @@ chiuso — attesa conferma puntate…", poi "Estrazione in corso…", poi compare nella dashboard di **ogni** utente, anche di chi non ha giocato in questo round. Questo messaggio resta visibile per l'intera durata della fase (chiusura → estrazione → pagamento), perché sotto la copertina servono -**fino a tre conferme sulla rete PLM in sequenza**, una diversa dall'altra: +**diverse conferme sulla rete PLM in sequenza**, una diversa dall'altra: 1. conferma dell'ultima giocata rimasta in sospeso (se ce n'era una proprio allo scadere del timer — altrimenti questo passo è già superato); -2. un nuovo blocco dopo la chiusura, il cui hash serve a scegliere il - vincitore; +2. un blocco di estrazione, fissato alla chiusura (il primo dopo di essa), che + deve avere in tutto **3 conferme** (il valore è configurabile dall'operatore) + prima che il suo hash, insieme a un segreto del server impegnato + all'apertura del round, scelga il vincitore; 3. la conferma della transazione che paga effettivamente la vincita. Con un blocco PLM ogni ~2 minuti, il tempo reale dall'azzeramento del -timer all'accredito della vincita è quindi in media **4-6 minuti** (se -c'era una giocata da confermare all'ultimo istante) o **2-4 minuti** (se +timer all'accredito della vincita è quindi in media **8-10 minuti** (se +c'era una giocata da confermare all'ultimo istante) o **6-8 minuti** (se tutte le giocate erano già confermate prima dello zero) — non pochi secondi, ed è normale. diff --git a/docs/limiti-noti.md b/docs/limiti-noti.md index b9b848c..139a61b 100644 --- a/docs/limiti-noti.md +++ b/docs/limiti-noti.md @@ -47,9 +47,9 @@ implementato il [piano delle correzioni](piano-correzioni.md). - **[LIM-01]** **Lo stato `drawing` non riprende dopo un errore o un riavvio.** Lo scheduler gestisce `open`, `closing` e `paying_out` a ogni giro - e salta `drawing`; l'attesa del blocco (`_wait_for_next_block`) vive solo - dentro la chiamata a `_close_and_draw` che l'ha avviata, con `tip_at_close` e - la lista dei partecipanti in memoria. Non serve un riavvio: **qualunque + e salta `drawing`; l'attesa del blocco (`_wait_for_draw_block`) vive solo + dentro la chiamata a `_close_and_draw` che l'ha avviata (altezza, elenco e + segreto sono ormai salvati nel database, ma nulla li rilegge). Non serve un riavvio: **qualunque eccezione** durante l'attesa (un `database is locked` scrivendo una riga di audit, un header malformato) la interrompe, e nessuno la riprende. Il terzo audit (2026-08-03) lo ha ribadito come problema **critico**, la revisione @@ -69,7 +69,7 @@ implementato il [piano delle correzioni](piano-correzioni.md). Con l'altezza fissa e il seme impegnato di [ADR-24](adr/ADR-24-l-estrazione-commit-reveal-fra-server.md)/[ADR-25](adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md), un'estrazione ripresa dà sempre lo stesso vincitore. - *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` + *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` - **[LIM-02]** **L'RBF gestisce una sola forma di transazione**: un'unica uscita di resto verso il mittente, abbastanza grande da assorbire l'aumento. Non c'è @@ -314,8 +314,8 @@ implementato il [piano delle correzioni](piano-correzioni.md). - *Distorsione del modulo:* un seme da 256 bit modulo al più 400 partecipanti introduce una distorsione inferiore a 400/2²⁵⁶, accettata anche dopo il piano. - - *Stato:* da chiudere con B-73. - - *Per chiuderlo:* il commit-reveal di [ADR-24](adr/ADR-24-l-estrazione-commit-reveal-fra-server.md) con l'altezza fissa e le + - *Stato:* chiuso da B-73. + - *Come è chiuso:* il commit-reveal di [ADR-24](adr/ADR-24-l-estrazione-commit-reveal-fra-server.md) con l'altezza fissa e le conferme di [ADR-25](adr/ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md): seme che dipende da un segreto impegnato all'apertura, partecipanti ordinati per txid e fissati da un'impronta, prova pubblica. Resta accettata la collusione fra operatore e minatore diff --git a/docs/modello-dati.md b/docs/modello-dati.md index 641f819..8dc93ec 100644 --- a/docs/modello-dati.md +++ b/docs/modello-dati.md @@ -431,19 +431,19 @@ restano nella tabella come storico. | `cooldown_seconds` | Integer | no | `30` (anche `server_default`) | Pausa dopo **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` | | `bet_amount_sats` | BigInteger | no | backfill da `round_config` nella migrazione | Costo della scommessa di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` | | `fee_address` | String(128) | no | backfill da `round_config` nella migrazione | Destinatario della commissione di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` | -| `draw_confirmations` | Integer | no | `3` (`server_default`) | Conferme richieste sul blocco di estrazione di **questo** round, copiate all'apertura ([DAT-61]) — *da implementare (B-73, B-95)* | `open_new_round_if_needed` | +| `draw_confirmations` | Integer | no | `3` (`server_default`) | Conferme richieste sul blocco di estrazione di **questo** round, copiate all'apertura ([DAT-61]) | `open_new_round_if_needed` | | `pool_address` | String(128) | no | `""` solo fino alla fine della transazione di apertura; backfill con l'indirizzo di riserva per i round esistenti | Indirizzo del montepremi di **questo** round ([DAT-60]) | `open_new_round_if_needed` | -| `server_seed_enc` | Text | sì | `NULL` | Il segreto del round cifrato con Fernet, con la stessa chiave della master xprv; scritto all'apertura ([DAT-64]) — *da implementare (B-73)* | `open_new_round_if_needed` | -| `server_seed` | String(64) | sì | `NULL` | Il segreto in chiaro (32 byte in esadecimale), scritto **solo** alla rivelazione ([DAT-64]) — *da implementare (B-73)* | `RoundScheduler._close_and_draw` | -| `server_seed_hash` | String(64) | sì | `NULL` | `sha256(server_seed)` in esadecimale, pubblicato dall'apertura ([DAT-64]) — *da implementare (B-73)* | `open_new_round_if_needed` | -| `participants_hash` | String(64) | sì | `NULL` | Impronta dell'elenco ordinato dei partecipanti ([DAT-65]) — *da implementare (B-73, B-81)* | `RoundScheduler._close_and_draw` | -| `draw_height` | Integer | sì | `NULL` | Altezza del blocco di estrazione, fissata all'ingresso in `drawing` ([DAT-65]) — *da implementare (B-73, B-81)* | `_close_and_draw` | +| `server_seed_enc` | Text | sì | `NULL` | Il segreto del round cifrato con Fernet, con la stessa chiave della master xprv; scritto all'apertura ([DAT-64]) | `open_new_round_if_needed` | +| `server_seed` | String(64) | sì | `NULL` | Il segreto in chiaro (32 byte in esadecimale), scritto **solo** alla rivelazione ([DAT-64]) | `RoundScheduler._complete_draw`; `RoundScheduler._close_and_draw` (round senza partecipanti) | +| `server_seed_hash` | String(64) | sì | `NULL` | `sha256(server_seed)` in esadecimale, pubblicato dall'apertura ([DAT-64]) | `open_new_round_if_needed` | +| `participants_hash` | String(64) | sì | `NULL` | Impronta dell'elenco ordinato dei partecipanti ([DAT-65]) | `RoundScheduler._close_and_draw` | +| `draw_height` | Integer | sì | `NULL` | Altezza del blocco di estrazione, fissata all'ingresso in `drawing` ([DAT-65]) | `_close_and_draw` | | `drawing_started_at` | DateTime | sì | `NULL` | Istante del passaggio a `drawing` | `RoundScheduler._close_and_draw` | -| `draw_block_height` | Integer | sì | `NULL` | Altezza del blocco usato per l'estrazione; nello schema atteso è sempre uguale a `draw_height` | `_close_and_draw` | -| `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_close_and_draw` | -| `seed_int` | String(128) | sì | `NULL` | Il seme in decimale (fino a 78 cifre, per questo è una stringa). Atteso: il valore intero di `sha256(server_seed ‖ block_hash ‖ participants_hash)`, con la codifica definita in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-73); oggi `int(draw_block_hash, 16)` | `_close_and_draw` | -| `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_close_and_draw` | -| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`; `RoundScheduler._trigger_payout` (atteso, B-82) | +| `draw_block_height` | Integer | sì | `NULL` | Altezza del blocco usato per l'estrazione; è sempre uguale a `draw_height` | `_complete_draw` | +| `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_complete_draw` | +| `seed_int` | String(128) | sì | `NULL` | Il seme in decimale (fino a 78 cifre, per questo è una stringa). Il valore intero di `sha256(server_seed ‖ block_hash ‖ participants_hash)`, con la codifica definita in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (RND-17) | `_complete_draw` | +| `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_complete_draw` | +| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`, `_complete_draw`; `RoundScheduler._trigger_payout` (atteso, B-82) | | `winner_amount_sats` | BigInteger | sì | `NULL` | Quanto riceve il vincitore (70% meno la commissione di rete) | `RoundScheduler._trigger_payout` | | `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` nello schema atteso | `_trigger_payout` | | `bump_reserve_sats` | BigInteger | sì | `NULL` | Output di riserva per l'RBF del pagamento, verso l'indirizzo di riserva ([DAT-73]) — *da implementare (B-79)* | `_trigger_payout`; `app/tx/broadcast.py:bump_fee` | @@ -575,7 +575,8 @@ restano nella tabella come storico. *Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* — - *Stato:* da implementare (B-95) · *Oggi:* `place_bet` e `_trigger_payout` + *Stato:* da implementare (B-95) · *Oggi:* `draw_confirmations` è già copiato + all'apertura (B-73); `place_bet` e `_trigger_payout` leggono `bet_amount_sats` e `fee_address` da `round_config` al momento dell'uso, quindi una modifica a metà round cambia il round in corso. @@ -598,10 +599,9 @@ restano nella tabella come storico. ([sicurezza.md](sicurezza.md#sec-49) SEC-49). I round aperti prima della migrazione hanno le tre colonne a `NULL`. *Codice:* `app/rounds/service.py:open_new_round_if_needed` · - `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · - `app/api/routes/rounds.py:current_round` · *Test:* — - *Stato:* da implementare (B-73) · *Oggi:* le colonne non esistono; il seme - è l'hash del blocco da solo. + `app/rounds/scheduler.py:RoundScheduler._complete_draw` · + `app/api/routes/rounds.py:round_proof` · + *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_draw_proof.py` - **[DAT-65]** All'ingresso in `drawing`, nella **stessa transazione** che cambia lo stato, si scrivono `draw_height`, `participants_hash` e il @@ -612,9 +612,8 @@ restano nella tabella come storico. dei tre cambia più. Sono ciò che permette di riprendere l'estrazione dopo un errore o un riavvio ottenendo lo stesso vincitore (B-81): la ripresa legge questi valori dal database e non ricalcola nulla. - *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* — - *Stato:* da implementare (B-73, B-81) · *Oggi:* le colonne non esistono; - l'elenco dei partecipanti e la punta alla chiusura restano solo in memoria. + *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · + *Test:* `tests/unit/test_scheduler.py` - **[DAT-67]** Prima di costruire il pagamento, gli outpoint delle scommesse vengono verificati sulla rete; i partecipanti la cui scommessa non c'è più @@ -662,10 +661,10 @@ Una scommessa: un utente in un round. | `user_id` | FK `users.id`, indicizzata | no | — | Chi scommette | `place_bet` | | `bet_amount_sats` | BigInteger | no | — | Quanto arriva davvero al pool: costo della scommessa **meno** la commissione di rete | `place_bet` | | `bet_txid` | String(64) | no | — | Txid corrente della scommessa; dopo la conferma, il txid della versione confermata ([DAT-14]) | `place_bet`; `app/tx/broadcast.py:_retarget_txid_references` | -| `bet_vout` | Integer | no | `0` (`server_default`, backfill `0`) | Posizione dell'output verso il montepremi nella transazione della scommessa ([DAT-63]) — *da implementare (B-73)* | `place_bet` | +| `bet_vout` | Integer | no | `0` (`server_default`, backfill `0`) | Posizione dell'output verso il montepremi nella transazione della scommessa ([DAT-63]) | `place_bet` | | `broadcast_at` | DateTime | no | `utcnow` | Istante della fase 1 di `place_bet`, vedi [DAT-25] | `place_bet` | | `confirmed_at` | DateTime | sì | `NULL` | Istante in cui la scommessa è risultata confermata | `app/bets/confirmation.py:_on_bet_confirmed` | -| `draw_position` | Integer | sì | `NULL` | Posizione (da 0) nell'elenco ordinato dell'estrazione; valorizzata solo per i partecipanti `confirmed` all'ingresso in `drawing` ([DAT-66]) — *da implementare (B-81)* | `app/rounds/scheduler.py:RoundScheduler._close_and_draw` | +| `draw_position` | Integer | sì | `NULL` | Posizione (da 0) nell'elenco ordinato dell'estrazione; valorizzata solo per i partecipanti `confirmed` all'ingresso in `drawing` ([DAT-66]) | `app/rounds/scheduler.py:RoundScheduler._close_and_draw` | | `status` | String(16) | no | `"broadcast"` (mai usato: si scrive sempre esplicitamente) | Vedi [DAT-24] | vedi diagramma | - **[DAT-23]** Al massimo una scommessa per utente per round: vincolo unico su @@ -727,8 +726,6 @@ Una scommessa: un utente in un round. *Codice:* `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · *Test:* `tests/unit/test_scheduler.py` - *Stato:* da implementare (B-73) · *Oggi:* l'estrazione ordina i partecipanti - confermati per `broadcast_at` crescente, senza criterio di parità. - **[DAT-63]** `bet_vout` è l'indice dell'output della scommessa che paga `rounds.pool_address`; con il costruttore attuale è sempre `0` @@ -736,9 +733,7 @@ Una scommessa: un utente in un round. l'outpoint che l'elenco dell'estrazione pubblica e che il pagamento spende ([DAT-11]). *Codice:* `app/bets/service.py:place_bet` · - `app/wallet/psbt_builder.py:build_signed_transaction` · *Test:* — - *Stato:* da implementare (B-73) · *Oggi:* la colonna non esiste; - l'outpoint della scommessa non è registrato. + `app/wallet/psbt_builder.py:RECIPIENT_VOUT` · *Test:* `tests/unit/test_bets.py` - **[DAT-66]** `draw_position` si scrive una sola volta, all'ingresso in `drawing` ([DAT-65]), per ogni partecipante `confirmed` del round: i @@ -749,9 +744,8 @@ Una scommessa: un utente in un round. dal vincolo. `draw_position` non cambia nemmeno quando la riga passa a `reorged_out`. *Codice:* `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · - `app/db/models.py:RoundParticipant` · *Test:* — - *Stato:* da implementare (B-73, B-81) · *Oggi:* la colonna non esiste; - l'ordine esiste solo in memoria durante l'attesa del blocco. + `app/db/models.py:RoundParticipant` · + *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_migrations.py` - **[DAT-26]** `bet_amount_sats` è l'importo del primo output della scommessa (`recipient_sats`), cioè il costo della scommessa del round @@ -793,7 +787,7 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). | `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (10 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` | | `rbf_timeout_seconds` | Integer | `900` | 60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) | | `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) — *da implementare (B-82)* | Subito, per gli accrediti successivi; gli output già accreditati restano | -| `draw_confirmations` | Integer | `3` | 1 – 20 | Conferme richieste sul blocco di estrazione ([DAT-62]) — *da implementare (B-73)* | Dal round **successivo**, copiato all'apertura ([DAT-61]) | +| `draw_confirmations` | Integer | `3` | 1 – 20 | Conferme richieste sul blocco di estrazione ([DAT-62]) | Dal round **successivo**, copiato all'apertura ([DAT-61]) | | `max_round_pool_sats` | BigInteger | `0` (tetto disattivato) | `0`, oppure `≥ bet_amount_sats` e `≤ 4 000 000 000 000 000` (400 × 100 000 PLM) | Tetto alla somma delle scommesse di un round ([DAT-27], [DAT-62]) — *da implementare (B-95)* | Subito, anche per il round in corso: vale per le scommesse successive | | `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa della lotteria | Subito per l'apertura del round successivo; il round in corso non è toccato | | `paused_reason` | String(16) | `NULL` | non modificabile da `PUT /admin/config` | Perché la lotteria è in pausa ([DAT-69]) | Insieme a `paused` | @@ -817,8 +811,8 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). valore uguale a quello attuale non conta come modifica. *Codice:* `app/api/routes/admin.py:update_config` · `app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py` - *Stato:* da implementare (B-73, B-82, B-95), per le ultime tre colonne · - *Oggi:* le colonne modificabili sono le prime sette. + *Stato:* da implementare (B-82, B-95), per le ultime due colonne · + *Oggi:* le colonne modificabili sono le prime otto. - **[DAT-30]** `paused` e `paused_reason` si modificano solo con `POST /admin/pause` e `POST /admin/resume`, che scrivono `lottery_paused` e @@ -841,9 +835,10 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). [scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95). La migrazione le aggiunge con un `server_default` temporaneo pari al predefinito ([DAT-55]). *Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` · - *Test:* — - *Stato:* da implementare (B-73, B-82, B-95) · *Oggi:* le colonne non - esistono; depositi ed estrazione usano 1 conferma e non c'è tetto. + *Test:* `tests/unit/test_admin.py` + *Stato:* da implementare (B-82, B-95) · *Oggi:* `deposit_confirmations` e + `max_round_pool_sats` non esistono: i depositi usano 1 conferma e non c'è + tetto; `draw_confirmations` esiste. - **[DAT-69]** `paused_reason` è `NULL` esattamente quando `paused` è falso; con `paused` vero vale uno di questi valori, e nessun altro: @@ -1214,7 +1209,7 @@ Tabella di servizio di Alembic: una riga con la revisione applicata | `ix_utxo_events_user_id` | `utxo_events` | indice su `user_id` | calcolo del saldo e selezione degli UTXO per utente | | `ix_rounds_single_active` | `rounds` | `UNIQUE ((1)) WHERE status IN ('open','closing','drawing','paying_out')` | al massimo un round attivo, [DAT-17] (B-09) | | unico (`round_id`, `user_id`) | `round_participants` | `UNIQUE` | una scommessa per utente per round, [DAT-23] | -| unico (`round_id`, `draw_position`) | `round_participants` | `UNIQUE` (i `NULL` non collidono) | una posizione di estrazione per partecipante, [DAT-66] — *da implementare (B-81)* | +| unico (`round_id`, `draw_position`) | `round_participants` | `UNIQUE` (i `NULL` non collidono) | una posizione di estrazione per partecipante, [DAT-66]; creato come indice (`ux_round_participants_round_draw_position`) | | unico `txid` | `pending_transaction_txids` | `UNIQUE` | un txid appartiene a una sola transazione, [DAT-70] | | `ix_pending_transaction_txids_pending_transaction_id` | `pending_transaction_txids` | indice | versioni di una transazione | | `ix_round_participants_round_id` | `round_participants` | indice | partecipanti di un round | diff --git a/docs/operativita.md b/docs/operativita.md index 85d3e03..6df69ed 100644 --- a/docs/operativita.md +++ b/docs/operativita.md @@ -521,7 +521,7 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). `open`, quindi nessuna scommessa nuova può entrare. *Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`, `app/rounds/scheduler.py:RoundScheduler._close_and_draw`, - `app/rounds/scheduler.py:RoundScheduler._wait_for_next_block` + `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` ### Round bloccato in `closing` diff --git a/docs/processo-di-modifica.md b/docs/processo-di-modifica.md index 78259cd..51d964c 100644 --- a/docs/processo-di-modifica.md +++ b/docs/processo-di-modifica.md @@ -80,7 +80,7 @@ fa il codice oggi: `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 + *Stato:* da implementare (B-nn) · *Oggi:* si usa la punta vista al primo controllo utile, che può essere un blocco successivo. ``` diff --git a/docs/sicurezza.md b/docs/sicurezza.md index 5e3006c..6f547b7 100644 --- a/docs/sicurezza.md +++ b/docs/sicurezza.md @@ -64,8 +64,6 @@ ricompensa di quel blocco. motivo di scartarlo; un server che trasmette non può scegliere il blocco, perché l'altezza è fissata e l'header a quell'altezza deve essere confermato dal quorum ([ELC-15](connessione-electrum.md#5-il-quorum)). - *Oggi* (B-73): il seme è l'hash del primo blocco visto dopo la chiusura, - senza segreto né impegno, e l'estrazione è manipolabile da chi mina. - **Header falsi.** Il bersaglio che un header dichiara nel campo `bits` lo sceglie chi fabbrica l'header (con `bits = 0x207fffff` un header mai minato passava con probabilità 1/2): per questo dal B-74 il @@ -107,10 +105,10 @@ Restano **accettati**, e descritti in | Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore | | JWT dell'utente | Browser, `localStorage` (`plm_token`) | Emesso al login/registrazione | Sessione dell'utente fino alla scadenza (24h) o al cambio password | — | Cambio password o reset dall'operatore | -*Oggi* (B-91, B-73): la chiave di cifratura è la variabile d'ambiente +*Oggi* (B-91): la chiave di cifratura è la variabile d'ambiente `XPRV_ENCRYPTION_KEY` in `.env`, visibile a chiunque esegua `docker inspect` sul container; `.env` e i file di chiave hanno i permessi predefiniti del -sistema (di norma `0644`, leggibili da tutti); il round non ha alcun segreto. +sistema (di norma `0644`, leggibili da tutti). `JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente anche dopo B-91: le vede con `docker inspect` solo chi ha accesso a Docker, che è già fidato come l'host. @@ -834,9 +832,8 @@ La struttura completa delle tabelle è in [modello-dati.md](modello-dati.md). ([operativita.md](operativita.md) OPS-06). All'avvio la verifica di [SEC-49] decifra anche il segreto dei round non ancora rivelati: se non ci riesce, il server non parte. - *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/wallet/hd.py` · - *Test:* — - *Stato:* da implementare (B-73) · *Oggi:* il segreto non esiste. + *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/wallet/keystore.py:encrypt_round_seed`, `app/wallet/keystore.py:decrypt_round_seed` · + *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_draw_proof.py` - **[SEC-48]** Nessun segreto finisce nei log o nell'audit log: né password (nemmeno quella generata dal reset), né WIF, né token, né la master key o la sua chiave di cifratura, né `server_seed` prima della rivelazione. Il diff --git a/docs/storico-bug.md b/docs/storico-bug.md index abb7065..d518f3e 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -221,7 +221,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). | B-nn | Difetto | Gravità | Commit | Test di regressione | Area | |---|---|---|---|---|---| -| B-73 | L'estrazione dipende solo dall'hash del blocco: un minatore con biglietti o il server Electrum attivo possono pilotarla; ordine dei partecipanti non verificabile | Critica | **aperto** | — | RND, ELC | +| B-73 | L'estrazione dipende solo dall'hash del blocco: un minatore con biglietti o il server Electrum attivo possono pilotarla; ordine dei partecipanti non verificabile | Critica | *da registrare* | `test_draw.py`, `test_draw_proof.py`, `test_scheduler.py`, `test_electrum_listener.py`, `test_rounds_service.py`, `test_migrations.py` | RND, ELC | | B-74 | Header accettati senza controllo della difficoltà minima; un salto di più blocchi non viene collegato; una riorganizzazione di un blocco chiude la sessione | Critica | *da registrare* | `test_electrum_listener.py`, `test_draw.py` | ELC | | B-75 | Una risposta Electrum oltre 64 KiB chiude la connessione, e la cronologia dell'indirizzo del montepremi la supera dopo ~800 transazioni | Critica | *da registrare* | `test_electrum_client.py`, `test_deposits.py`, `test_psbt_builder.py` | ELC, TX | | B-76 | Un invio ambiguo (timeout, connessione caduta) viene annullato come se fosse rifiutato | Critica | *da registrare* | `test_broadcast_outcome.py`, `test_electrum_client.py` | TX, BET, WDR | @@ -252,9 +252,9 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). - **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera - come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86. + come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73. - **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e - B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (4 critici, + B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (3 critici, 4 alti, 6 medi), da implementare nell'ordine di [piano-correzioni.md](piano-correzioni.md). - **Prossimo numero libero:** `B-99`. diff --git a/migrations/versions/d3e8b15a7c42_commit_reveal_draw_columns.py b/migrations/versions/d3e8b15a7c42_commit_reveal_draw_columns.py new file mode 100644 index 0000000..2ab2c38 --- /dev/null +++ b/migrations/versions/d3e8b15a7c42_commit_reveal_draw_columns.py @@ -0,0 +1,68 @@ +"""commit-reveal draw: round secret, participant order and draw height (B-73) + +Revision ID: d3e8b15a7c42 +Revises: b7d2e41c9a05 +Create Date: 2026-10-14 16:40:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd3e8b15a7c42' +down_revision: Union[str, Sequence[str], None] = 'b7d2e41c9a05' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_ACTIVE = "'open', 'closing', 'drawing', 'paying_out'" + + +def upgrade() -> None: + """Upgrade schema. + + B-73 (DAT-61 … DAT-66): the round's secret (`server_seed_enc`, `server_seed`, + `server_seed_hash`), the fixed participant list (`participants_hash`, `draw_height`, + `round_participants.draw_position`) and the number of confirmations the draw block + needs (`draw_confirmations` on the round, copied from `round_config` at open). + `bet_vout` records which output of a bet pays the pool; every existing bet pays it + with its first output. + + Rounds that already exist keep NULL in the three secret columns (they were drawn, + or are drawn, on the block hash alone) and take the default of 3 confirmations. + + Plain add_column / create_index calls — never batch_alter_table, which on SQLite + rebuilds the table and silently drops the expression indexes (B-97, DAT-56). + """ + op.add_column("rounds", sa.Column("draw_confirmations", sa.Integer(), server_default="3", nullable=False)) + op.add_column("rounds", sa.Column("server_seed_enc", sa.Text(), nullable=True)) + op.add_column("rounds", sa.Column("server_seed", sa.String(length=64), nullable=True)) + op.add_column("rounds", sa.Column("server_seed_hash", sa.String(length=64), nullable=True)) + op.add_column("rounds", sa.Column("participants_hash", sa.String(length=64), nullable=True)) + op.add_column("rounds", sa.Column("draw_height", sa.Integer(), nullable=True)) + + op.add_column("round_participants", sa.Column("bet_vout", sa.Integer(), server_default="0", nullable=False)) + op.add_column("round_participants", sa.Column("draw_position", sa.Integer(), nullable=True)) + op.create_index( + "ux_round_participants_round_draw_position", + "round_participants", + ["round_id", "draw_position"], + unique=True, + ) + + op.add_column("round_config", sa.Column("draw_confirmations", sa.Integer(), server_default="3", nullable=False)) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("round_config", "draw_confirmations") + op.drop_index("ux_round_participants_round_draw_position", table_name="round_participants") + op.drop_column("round_participants", "draw_position") + op.drop_column("round_participants", "bet_vout") + for column in ("draw_height", "participants_hash", "server_seed_hash", "server_seed", "server_seed_enc", "draw_confirmations"): + op.drop_column("rounds", column) + # drop_column on SQLite is a table rebuild: recreate the expression index (B-97). + op.execute("DROP INDEX IF EXISTS ix_rounds_single_active") + op.execute(f"CREATE UNIQUE INDEX ix_rounds_single_active ON rounds ((1)) WHERE status IN ({_ACTIVE})") diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py index d534469..2df26da 100644 --- a/tests/unit/test_admin.py +++ b/tests/unit/test_admin.py @@ -263,6 +263,8 @@ async def test_config_update_without_changes_logs_nothing(client): {"round_duration_seconds": 0}, # a round that expires the instant it opens {"bet_amount_sats": -1}, {"rbf_timeout_seconds": 1}, + {"draw_confirmations": 0}, # B-73: a draw with no confirmations is a draw on the tip + {"draw_confirmations": 21}, ], ) async def test_config_rejects_unusable_values(client, payload): @@ -390,3 +392,27 @@ async def test_admin_list_rounds_shows_each_rounds_pool_address(client): by_id = {row["id"]: row for row in resp.json()} assert by_id[1]["pool_address"] == derive_pool_address(1) assert by_id[2]["pool_address"] is None + + +async def test_draw_confirmations_default_to_3_and_are_editable_within_1_to_20(client): + """B-73 (DAT-62, API-30, API-31): the number of blocks the draw waits for on its block.""" + headers = {"X-Admin-Token": "test-admin-token"} + assert (await client.get("/admin/config", headers=headers)).json()["draw_confirmations"] == 3 + + resp = await client.put("/admin/config", headers=headers, json={"draw_confirmations": 6}) + assert resp.status_code == 200 and resp.json()["draw_confirmations"] == 6 + assert (await client.put("/admin/config", headers=headers, json={"draw_confirmations": 1})).status_code == 200 + assert (await client.put("/admin/config", headers=headers, json={"draw_confirmations": 20})).status_code == 200 + + +async def test_admin_round_list_shows_the_commitment_but_never_the_secret(client): + """B-73 (API-36): server_seed_hash and draw_height are listed, server_seed is not.""" + from app.db import base as db_base + from app.db.models import Round + + async with db_base.AsyncSessionLocal() as session: + session.add(Round(status="closed", server_seed_hash="ab" * 32, server_seed="cd" * 32, draw_height=77)) + await session.commit() + rounds = (await client.get("/admin/rounds", headers={"X-Admin-Token": "test-admin-token"})).json() + assert rounds[0]["server_seed_hash"] == "ab" * 32 and rounds[0]["draw_height"] == 77 + assert "server_seed" not in rounds[0] and "cd" * 32 not in str(rounds[0]) diff --git a/tests/unit/test_bets.py b/tests/unit/test_bets.py index a1a0914..82795bf 100644 --- a/tests/unit/test_bets.py +++ b/tests/unit/test_bets.py @@ -1,6 +1,8 @@ from datetime import datetime, timedelta, timezone import pytest +from embit import script +from embit.transaction import Transaction from sqlalchemy import func, select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine @@ -81,6 +83,12 @@ async def test_place_bet_broadcasts_and_records_participant(session_factory): assert client.broadcasted # a raw tx was broadcast assert participant.status == "broadcast" assert participant.bet_txid + # B-73 (DAT-63): the outpoint the draw list publishes — the output paying the round's pool + # address in the transaction that was actually broadcast. + bet_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0])) + async with session_factory() as session: + pool_address = (await session.get(Round, participant.round_id)).pool_address + assert bet_tx.vout[participant.bet_vout].script_pubkey == script.Script.from_address(pool_address) async with session_factory() as session: utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one() diff --git a/tests/unit/test_draw.py b/tests/unit/test_draw.py index c3a43e2..1629d2b 100644 --- a/tests/unit/test_draw.py +++ b/tests/unit/test_draw.py @@ -47,20 +47,91 @@ def test_header_prev_hash_matches_the_known_previous_block(): assert header_prev_hash(_REAL_HEADER_HEX) == "000000000000170c8179321dfd323075bfe7365f7c10f51402d10052e49ec3fb" +# --- B-73 (RND-14, RND-17, RND-34): the commit-reveal draw ------------------------------- + +_SEED = "11" * 32 +_BLOCK = "00" * 28 + "deadbeef" +_LIST_HASH = "22" * 32 + + +def test_draw_seed_is_the_sha256_of_the_three_32_byte_values_read_big_endian(): + """RND-17: the algorithm, written out here independently of the code under test.""" + import hashlib + + from app.rounds.draw import draw_seed + + expected = int.from_bytes( + hashlib.sha256(bytes.fromhex(_SEED) + bytes.fromhex(_BLOCK) + bytes.fromhex(_LIST_HASH)).digest(), "big" + ) + assert draw_seed(_SEED, _BLOCK, _LIST_HASH) == expected + + +def test_draw_seed_depends_on_all_three_inputs(): + from app.rounds.draw import draw_seed + + base = draw_seed(_SEED, _BLOCK, _LIST_HASH) + assert draw_seed("12" * 32, _BLOCK, _LIST_HASH) != base # without the secret nobody can predict it + assert draw_seed(_SEED, "00" * 28 + "deadbeee", _LIST_HASH) != base + assert draw_seed(_SEED, _BLOCK, "23" * 32) != base # nor can the list change after the block + + def test_draw_winner_is_deterministic_and_within_range(): + from app.rounds.draw import draw_index, draw_seed + participants = ["addrA", "addrB", "addrC"] - block_hash = "00" * 31 + "05" # seed = 5, index = 5 % 3 = 2 - assert draw_winner(participants, block_hash) == "addrC" + winner = draw_winner(participants, _SEED, _BLOCK, _LIST_HASH) + assert winner == participants[draw_seed(_SEED, _BLOCK, _LIST_HASH) % 3] + assert winner == draw_winner(participants, _SEED, _BLOCK, _LIST_HASH) + assert 0 <= draw_index(draw_seed(_SEED, _BLOCK, _LIST_HASH), 3) < 3 def test_draw_winner_single_participant_always_wins(): - block_hash = "ff" * 32 - assert draw_winner(["only"], block_hash) == "only" + assert draw_winner(["only"], _SEED, "ff" * 32, _LIST_HASH) == "only" def test_draw_winner_raises_on_empty_participants(): with pytest.raises(ValueError): - draw_winner([], "00" * 32) + draw_winner([], _SEED, _BLOCK, _LIST_HASH) + + +def test_equal_odds_every_position_can_win(): + """RND-17: one position per participant, whatever the amount — the index is uniform + over 0..n-1 (checked over many seeds, not exactly).""" + from collections import Counter + + from app.rounds.draw import draw_index, draw_seed + + wins = Counter(draw_index(draw_seed(f"{i:064x}", _BLOCK, _LIST_HASH), 4) for i in range(2000)) + assert set(wins) == {0, 1, 2, 3} + assert min(wins.values()) > 400 # about 500 each + + +def test_server_seed_commitment_is_sha256_of_the_32_bytes(): + import hashlib + + from app.rounds.draw import server_seed_commitment + + assert server_seed_commitment(_SEED) == hashlib.sha256(bytes.fromhex(_SEED)).hexdigest() + + +def test_new_server_seeds_are_32_random_bytes_in_hex(): + from app.rounds.draw import new_server_seed + + seeds = {new_server_seed() for _ in range(20)} + assert len(seeds) == 20 + assert all(len(seed) == 64 and bytes.fromhex(seed) for seed in seeds) + + +def test_participants_hash_encodes_one_txid_vout_address_line_per_participant(): + """RND-14: sha256 of `txid:vout:address\n` lines in draw_position order, UTF-8.""" + import hashlib + + from app.rounds.draw import participants_hash + + rows = [("aa" * 32, 0, "plm1qfirst"), ("bb" * 32, 1, "plm1qsecond")] + text = f"{'aa' * 32}:0:plm1qfirst\n{'bb' * 32}:1:plm1qsecond\n" + assert participants_hash(rows) == hashlib.sha256(text.encode("utf-8")).hexdigest() + assert participants_hash(list(reversed(rows))) != participants_hash(rows) # the order is part of it # --- B-74 (ELC-11): the claimed target must not be easier than the powLimit ------------ diff --git a/tests/unit/test_draw_proof.py b/tests/unit/test_draw_proof.py new file mode 100644 index 0000000..a0fb615 --- /dev/null +++ b/tests/unit/test_draw_proof.py @@ -0,0 +1,348 @@ +"""B-73 (RND-34, RND-37, API-45, DAT-64, SEC-56): the commit-reveal draw, end to end, and +what the public can and cannot see of it. + +A round is opened by the real service (so it carries its real encrypted secret), filled with +confirmed bets, closed and drawn by the real scheduler against a listener that answers the +quorum, and then read back through `GET /rounds/{id}/proof` — where the winner is recomputed +here, from public data only and with the algorithm written out again independently of the +code under test. +""" + +import hashlib +import json +import logging +import re +import sqlite3 +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select + +from app.db.models import AuditLog, Round, RoundConfig, RoundParticipant, User +from app.rounds.scheduler import RoundScheduler +from app.rounds.service import open_new_round_if_needed +from app.wallet.hd import derive_pool_address +from app.wallet.keystore import decrypt_round_seed +from tests.unit.test_rounds_route import client # noqa: F401 (fixture) + +_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n" +_BLOCK_HASH = "00000000" + "5a" * 28 + + +class _Listener: + client = object() + tip_height = 500 + tip_header_hex = "00" + + def __init__(self, blocks: dict[int, str]): + self.blocks = blocks + self.asked: list[int] = [] + + async def agreed_block_hash(self, height: int): + self.asked.append(height) + return self.blocks.get(height), self.blocks.get(height) + + +async def _noop_payout(round_id: int) -> None: + return None + + +def _chain_advances_while_waiting(monkeypatch, listener, to_height: int = 503) -> None: + """The draw is entered at tip 500 (draw height 501); its three confirmations arrive while + it waits — modelled by making the scheduler's sleep move the chain on instead of sleeping.""" + import app.rounds.scheduler as scheduler_module + + async def fake_sleep(_seconds): + listener.tip_height = to_height + + monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep) + + +async def _open_round_with_bets(session_factory, n: int) -> int: + async with session_factory() as session: + session.add(RoundConfig(fee_address=_FEE_ADDRESS, draw_confirmations=3)) + await session.commit() + round_ = await open_new_round_if_needed(session) + await session.commit() + round_.status = "closing" + round_.closed_at = datetime.now(timezone.utc) + for i in range(n): + user = User(username=f"p{i}", password_hash="x", derivation_index=i, address=derive_pool_address(2000 + i)) + session.add(user) + await session.flush() + session.add( + RoundParticipant( + round_id=round_.id, + user_id=user.id, + bet_amount_sats=999_000_000, + bet_txid=hashlib.sha256(f"bet{i}".encode()).hexdigest(), + bet_vout=0, + status="confirmed", + broadcast_at=datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=n - i), + ) + ) + await session.commit() + return round_.id + + +async def _all_text_in_the_database(session_factory) -> list[str]: + """Every string stored in any table — columns and audit payloads alike.""" + from app.db import base as db_base + + path = db_base.engine.url.database + with sqlite3.connect(path) as conn: + tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")] + values: list[str] = [] + for table in tables: + for row in conn.execute(f'SELECT * FROM "{table}"'): + values.extend(str(v) for v in row if isinstance(v, (str, bytes))) + return values + + +def _independent_winner(proof: dict) -> tuple[int, str]: + """The verification of docs/ciclo-di-vita-del-round.md, steps 1-6, from the proof alone.""" + assert hashlib.sha256(bytes.fromhex(proof["server_seed"])).hexdigest() == proof["server_seed_hash"] + participants = proof["participants"] + assert [p["draw_position"] for p in participants] == list(range(len(participants))) + assert [p["txid"] for p in participants] == sorted(p["txid"] for p in participants) + lines = "".join(f"{p['txid']}:{p['vout']}:{p['address']}\n" for p in participants) + assert hashlib.sha256(lines.encode("utf-8")).hexdigest() == proof["participants_hash"] + material = ( + bytes.fromhex(proof["server_seed"]) + + bytes.fromhex(proof["draw_block_hash"]) + + bytes.fromhex(proof["participants_hash"]) + ) + seed = int.from_bytes(hashlib.sha256(material).digest(), "big") + assert str(seed) == proof["seed"] + index = seed % len(participants) + return index, participants[index]["address"] + + +async def test_a_finished_draw_can_be_recomputed_from_the_public_proof(client, caplog, monkeypatch): + """RND-17 / RND-37 / API-45: anyone can redo the calculation from the proof and get the + winner the server stored. Also SEC-48: the secret never reaches the application log.""" + ac, session_factory = client + round_id = await _open_round_with_bets(session_factory, n=5) + listener = _Listener({501: _BLOCK_HASH}) + _chain_advances_while_waiting(monkeypatch, listener) + scheduler = RoundScheduler(session_factory, listener) + scheduler._trigger_payout = _noop_payout + + with caplog.at_level(logging.DEBUG, logger="app"): + await scheduler._close_and_draw(round_id) + + assert listener.asked == [501] # tip 500 + 1, asked for exactly that height + response = await ac.get(f"/rounds/{round_id}/proof") + assert response.status_code == 200 + proof = response.json() + assert proof["algorithm"] == "commit-reveal-v1" + assert proof["status"] == "paying_out" + assert proof["draw_height"] == 501 and proof["draw_confirmations"] == 3 + assert proof["draw_block_hash"] == _BLOCK_HASH + assert len(proof["participants"]) == 5 + assert all(p["vout"] == 0 and p["status"] == "confirmed" for p in proof["participants"]) + + index, address = _independent_winner(proof) + assert (proof["winner_index"], proof["winner_address"]) == (index, address) + async with session_factory() as session: + round_ = await session.get(Round, round_id) + winner = await session.get(User, round_.winner_user_id) + assert winner.address == address + assert proof["pool_address"] == round_.pool_address + + application_log = "\n".join(r.getMessage() for r in caplog.records if r.name.startswith("app")) + assert application_log # something was logged ... + assert proof["server_seed"] not in application_log # ... and never the secret (SEC-48) + + +async def test_the_secret_is_nowhere_in_the_database_or_the_api_before_the_reveal(client): + """B-73 / DAT-64 / SEC-56: from the opening to the moment the winner is stored, the + plaintext secret is in no database column and no audit row, and no endpoint returns it — + only its encrypted form and its hash are stored. Checked at every stage.""" + ac, session_factory = client + round_id = await _open_round_with_bets(session_factory, n=3) + async with session_factory() as session: + seed = decrypt_round_seed((await session.get(Round, round_id)).server_seed_enc) + + async def assert_hidden(stage: str) -> None: + for value in await _all_text_in_the_database(session_factory): + assert seed not in value, f"the plaintext secret is stored in the database ({stage})" + for path in ("/rounds/current", f"/rounds/{round_id}/proof"): + body = (await ac.get(path)).text + assert seed not in body, f"{path} returns the secret ({stage})" + proof = (await ac.get(f"/rounds/{round_id}/proof")).json() + assert proof["server_seed"] is None + assert proof["server_seed_hash"] == hashlib.sha256(bytes.fromhex(seed)).hexdigest() + + await assert_hidden("closing") # the commitment is public from the opening + + # Entering `drawing` and waiting for the block: the draw's wait is interrupted before + # the winner is stored. + scheduler = RoundScheduler(session_factory, _Listener({})) + + async def stop_before_the_draw(_round_id: int) -> None: + return None + + scheduler._complete_draw = stop_before_the_draw + await scheduler._close_and_draw(round_id) + async with session_factory() as session: + assert (await session.get(Round, round_id)).status == "drawing" + await assert_hidden("drawing") + + # Now the draw completes: the secret appears, and only now. + listener = _Listener({501: _BLOCK_HASH}) + listener.tip_height = 503 + finishing = RoundScheduler(session_factory, listener) + finishing._trigger_payout = _noop_payout + await finishing._complete_draw(round_id) + proof = (await ac.get(f"/rounds/{round_id}/proof")).json() + assert proof["server_seed"] == seed + async with session_factory() as session: + rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "winner_drawn"))).all() + payload = json.loads(rows[0].payload_json) + assert payload["server_seed"] == seed # AUD-24: revealed in the same transaction as the winner + assert payload["draw_height"] == payload["block_height"] == 501 + assert re.fullmatch(r"[0-9a-f]{64}", payload["seed"]) + assert int(payload["seed"], 16) == int(proof["seed"]) + + +async def test_the_proof_of_a_round_in_drawing_has_the_committed_list_but_no_result(client): + """RND-37: from the entry in `drawing` the list, its fingerprint and the height are + public; the block hash, the seed and the winner are not yet.""" + ac, session_factory = client + round_id = await _open_round_with_bets(session_factory, n=3) + scheduler = RoundScheduler(session_factory, _Listener({})) + + async def stop(_round_id: int) -> None: + return None + + scheduler._complete_draw = stop + await scheduler._close_and_draw(round_id) + + proof = (await ac.get(f"/rounds/{round_id}/proof")).json() + assert proof["status"] == "drawing" + assert proof["draw_height"] == 501 + assert len(proof["participants"]) == 3 and proof["participants_hash"] + for key in ("server_seed", "draw_block_hash", "seed", "winner_index", "winner_address", "payout_txid"): + assert proof[key] is None + + +async def test_the_proof_of_a_round_still_open_has_only_the_commitment(client): + ac, session_factory = client + async with session_factory() as session: + session.add(RoundConfig(fee_address=_FEE_ADDRESS)) + await session.commit() + round_ = await open_new_round_if_needed(session) + await session.commit() + round_id = round_.id + proof = (await ac.get(f"/rounds/{round_id}/proof")).json() + assert proof["status"] == "open" and re.fullmatch(r"[0-9a-f]{64}", proof["server_seed_hash"]) + assert proof["participants"] == [] and proof["participants_hash"] is None and proof["draw_height"] is None + assert proof["server_seed"] is None and proof["pool_address"] + + +async def test_the_proof_needs_no_authentication_and_404s_for_an_unknown_round(client): + ac, _ = client + response = await ac.get("/rounds/999/proof") + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "round_not_found" + assert (await ac.get("/rounds/not-a-number/proof")).status_code == 422 + + +async def test_a_round_from_before_the_commitment_has_an_incomplete_block_hash_proof(client): + """API-45: a round without a commitment is labelled `block-hash-v0` and carries no list.""" + ac, session_factory = client + async with session_factory() as session: + session.add(Round(id=7, status="closed", draw_block_hash="ab" * 32, seed_int="12345")) + await session.commit() + proof = (await ac.get("/rounds/7/proof")).json() + assert proof["algorithm"] == "block-hash-v0" + assert proof["server_seed_hash"] is None and proof["participants"] == [] and proof["winner_address"] is None + assert proof["draw_block_hash"] == "ab" * 32 and proof["seed"] == "12345" + + +async def test_current_round_publishes_the_commitment_and_what_is_fixed_for_the_draw(client): + """API-25: server_seed_hash from the opening; participants_hash and draw_height once in + drawing; draw_confirmations always — and never the secret.""" + ac, session_factory = client + round_id = await _open_round_with_bets(session_factory, n=2) + current = (await ac.get("/rounds/current")).json() + assert re.fullmatch(r"[0-9a-f]{64}", current["server_seed_hash"]) + assert current["draw_confirmations"] == 3 + assert current["participants_hash"] is None and current["draw_height"] is None + assert "server_seed" not in current + + scheduler = RoundScheduler(session_factory, _Listener({})) + + async def stop(_round_id: int) -> None: + return None + + scheduler._complete_draw = stop + await scheduler._close_and_draw(round_id) + current = (await ac.get("/rounds/current")).json() + assert current["draw_height"] == 501 and re.fullmatch(r"[0-9a-f]{64}", current["participants_hash"]) + + +async def test_a_drawing_round_is_completed_by_a_second_pass_with_the_same_winner(client, monkeypatch): + """RND-36: everything the draw needs is in the database, so running the completion again + — after an exception, in a new scheduler — gives the same winner, and a round that was + already drawn is not drawn again.""" + ac, session_factory = client + round_id = await _open_round_with_bets(session_factory, n=6) + + class _Failing(_Listener): + async def agreed_block_hash(self, height): + raise RuntimeError("database is locked") # e.g. while writing an audit row + + failing = _Failing({}) + _chain_advances_while_waiting(monkeypatch, failing) + first = RoundScheduler(session_factory, failing) + first._trigger_payout = _noop_payout + try: + await first._close_and_draw(round_id) + except RuntimeError: + pass + async with session_factory() as session: + assert (await session.get(Round, round_id)).status == "drawing" + + ok = _Listener({501: _BLOCK_HASH}) + ok.tip_height = 503 + second = RoundScheduler(session_factory, ok) + second._trigger_payout = _noop_payout + await second._complete_draw(round_id) + proof = (await ac.get(f"/rounds/{round_id}/proof")).json() + _, address = _independent_winner(proof) + assert proof["winner_address"] == address + + other = _Listener({501: "ff" * 32}) # a different block is offered + other.tip_height = 503 + third = RoundScheduler(session_factory, other) + third._trigger_payout = _noop_payout + await third._complete_draw(round_id) + again = (await ac.get(f"/rounds/{round_id}/proof")).json() + assert again["winner_address"] == proof["winner_address"] and again["draw_block_hash"] == _BLOCK_HASH + + +async def test_a_different_block_gives_a_different_draw_but_never_a_different_list(client): + """Why the order is public: the same participants with a different block at the draw + height can produce a different winner, while the list itself is fixed beforehand.""" + ac, session_factory = client + round_id = await _open_round_with_bets(session_factory, n=8) + scheduler = RoundScheduler(session_factory, _Listener({})) + + async def stop(_round_id: int) -> None: + return None + + scheduler._complete_draw = stop + await scheduler._close_and_draw(round_id) + list_hash = (await ac.get(f"/rounds/{round_id}/proof")).json()["participants_hash"] + + winners = set() + for block in ("01" * 32, "02" * 32, "03" * 32, "04" * 32, "05" * 32, "06" * 32): + async with session_factory() as session: + round_ = await session.get(Round, round_id) + from app.rounds.draw import draw_index, draw_seed + + seed = decrypt_round_seed(round_.server_seed_enc) + winners.add(draw_index(draw_seed(seed, block, round_.participants_hash), 8)) + assert len(winners) > 1 # the block matters ... + assert (await ac.get(f"/rounds/{round_id}/proof")).json()["participants_hash"] == list_hash # ... the list does not move diff --git a/tests/unit/test_electrum_listener.py b/tests/unit/test_electrum_listener.py index 7a4c29d..5e80341 100644 --- a/tests/unit/test_electrum_listener.py +++ b/tests/unit/test_electrum_listener.py @@ -293,91 +293,6 @@ def _mine_chain(first_height: int, length: int, prev_hash: str = "00" * 32, *, s return chain -async def _endpoint_client_factory(responses: dict[str, object]): - """Builds a client_factory whose fake clients answer blockchain.block.header - per-endpoint according to `responses`: a header hex string to agree/disagree - with, `None` to simulate an unreachable server, or an Exception instance to - simulate a request failure.""" - - class _FakeClient: - def __init__(self, answer): - self._answer = answer - self.closed = False - - async def connect(self): - if isinstance(self._answer, Exception): - raise self._answer - - async def request(self, method, params): - assert method == "blockchain.block.header" - if self._answer is None: - raise ConnectionRefusedError("unreachable") - return self._answer - - async def close(self): - self.closed = True - - def factory(endpoint): - return _FakeClient(responses[endpoint.host]) - - return factory - - -async def test_corroborate_header_true_with_no_other_servers_only_under_allow_no_quorum(session_factory): - """ELC-14 / ELC-19 / B-84: this used to be the rule for everyone. Now only the - development override passes with nothing to corroborate against.""" - single = [ElectrumEndpoint("only.example", 50002, True)] - listener = ElectrumListener(lambda endpoint: None, session_factory, single, allow_no_quorum=True) - assert await listener.corroborate_header(100, "deadbeef") is True - - -async def test_corroborate_header_true_when_others_agree(session_factory): - header_hex = _mine_header("00" * 32) - expected_hash = header_hex_to_block_hash(header_hex) - factory = await _endpoint_client_factory( - {"first.example": header_hex, "second.example": header_hex, "third.example": header_hex} - ) - listener = ElectrumListener(factory, session_factory, _ENDPOINTS) - - assert await listener.corroborate_header(100, expected_hash) is True - - -async def test_corroborate_header_never_asks_the_currently_active_endpoint(session_factory): - """The active connection is exactly what a hostile server or a MITM would - control — corroborating against it too would defeat the point.""" - header_hex = _mine_header("00" * 32) - expected_hash = header_hex_to_block_hash(header_hex) - # first.example (the active endpoint) would raise if ever queried. - factory = await _endpoint_client_factory( - {"first.example": RuntimeError("must not be called"), "second.example": header_hex, "third.example": header_hex} - ) - listener = ElectrumListener(factory, session_factory, _ENDPOINTS) - assert listener.current_endpoint.host == "first.example" - - assert await listener.corroborate_header(100, expected_hash) is True - - -async def test_corroborate_header_false_when_majority_disagrees(session_factory): - header_hex = _mine_header("00" * 32) - expected_hash = header_hex_to_block_hash(header_hex) - disagreeing_hex = _mine_header("11" * 32) - factory = await _endpoint_client_factory( - {"first.example": header_hex, "second.example": disagreeing_hex, "third.example": disagreeing_hex} - ) - listener = ElectrumListener(factory, session_factory, _ENDPOINTS) - - assert await listener.corroborate_header(100, expected_hash) is False - - -async def test_corroborate_header_false_when_nobody_responds(session_factory): - factory = await _endpoint_client_factory( - {"first.example": "irrelevant", "second.example": None, "third.example": ConnectionRefusedError("down")} - ) - listener = ElectrumListener(factory, session_factory, _ENDPOINTS) - - assert await listener.corroborate_header(100, "deadbeef") is False - - # --- B-29: a UTXO absent from our own connection's listunspent must be # corroborated by other configured servers before it's treated as genuinely spent # outside the platform. ------------------------------------------------------------ @@ -845,38 +760,9 @@ async def test_corroboration_fails_with_no_other_server_configured(session_facto """ELC-14 / B-84: nothing to corroborate against is a failure, not a pass.""" single = [ElectrumEndpoint("only.example", 50002, True)] listener = ElectrumListener(lambda endpoint: None, session_factory, single) - assert await listener.corroborate_header(100, "deadbeef") is False assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False -async def test_a_single_responding_server_does_not_decide(session_factory): - """ELC-14 / B-84: two others are configured but only one answers — even though it - agrees, one answer is not a quorum. Before B-84 this passed.""" - header_hex = _mine_header("00" * 32) - expected_hash = header_hex_to_block_hash(header_hex) - factory = await _endpoint_client_factory( - {"first.example": "irrelevant", "second.example": header_hex, "third.example": None} - ) - listener = ElectrumListener(factory, session_factory, _ENDPOINTS) - assert await listener.corroborate_header(100, expected_hash) is False - - # The development override restores the reduced rule. - relaxed = ElectrumListener(factory, session_factory, _ENDPOINTS, allow_no_quorum=True) - assert await relaxed.corroborate_header(100, expected_hash) is True - - -async def test_two_answers_must_both_agree_for_a_strict_majority(session_factory): - """ELC-14 / B-84: with exactly two answers, 1 agree + 1 disagree is not a majority.""" - header_hex = _mine_header("00" * 32) - expected_hash = header_hex_to_block_hash(header_hex) - other_hex = _mine_header("11" * 32) - factory = await _endpoint_client_factory( - {"first.example": "irrelevant", "second.example": header_hex, "third.example": other_hex} - ) - listener = ElectrumListener(factory, session_factory, _ENDPOINTS) - assert await listener.corroborate_header(100, expected_hash) is False - - async def test_a_credit_is_not_corroborated_by_one_server_alone(session_factory): """ELC-17 / B-84: the same minimum applies to every kind of corroboration.""" entry = {"tx_hash": "77" * 32, "tx_pos": 0, "value": 5_000_000, "height": 10} @@ -1389,3 +1275,135 @@ async def test_a_run_once_session_ends_when_the_active_server_stalls(session_fac clock["now"] = 361.0 assert await asyncio.wait_for(task, timeout=3) is True assert listener.client is None + + +# --- B-73 (RND-16, ELC-15): the draw block's hash, asked of every server at a fixed height -- + + +class _BlockHeaderClient: + """A server's answer to `blockchain.block.header`: a header hex, None for unreachable, or an + exception to raise from connect().""" + + def __init__(self, answer, asked: list | None = None, name: str = ""): + self._answer = answer + self._asked = asked if asked is not None else [] + self._name = name + + async def connect(self): + if isinstance(self._answer, Exception): + raise self._answer + + async def get_block_header(self, height: int): + self._asked.append((self._name, height)) + if self._answer is None: + raise ConnectionRefusedError("unreachable") + return self._answer + + async def close(self): + pass + + +def _header_listener(session_factory, answers: dict[str, object], *, allow_no_quorum=False, endpoints=None): + """A listener whose active server is `first.example` and whose others answer `answers`.""" + asked: list = [] + listener = ElectrumListener( + lambda endpoint: _BlockHeaderClient(answers[endpoint.host], asked, endpoint.host), + session_factory, + endpoints or _ENDPOINTS, + allow_no_quorum=allow_no_quorum, + ) + listener.client = _BlockHeaderClient(answers["first.example"], asked, "active") + listener.asked = asked + return listener + + +async def test_the_draw_block_hash_is_returned_when_every_server_agrees(session_factory): + header = _mine_header("00" * 32) + listener = _header_listener( + session_factory, {"first.example": header, "second.example": header, "third.example": header} + ) + assert await listener.agreed_block_hash(100) == (header_hex_to_block_hash(header),) * 2 + assert sorted(h for _name, h in listener.asked) == [100, 100, 100] # the same height, from all three + + +async def test_the_draw_block_hash_asks_the_active_server_too(session_factory): + """RND-16: unlike the other corroborations, the active server is one of the voices — + what it reports is also what the audit row records on a disagreement.""" + header = _mine_header("00" * 32) + listener = _header_listener( + session_factory, {"first.example": header, "second.example": header, "third.example": header} + ) + await listener.agreed_block_hash(100) + assert "active" in {name for name, _h in listener.asked} + + +async def test_a_lying_active_server_is_outvoted_by_the_others(session_factory): + """ELC-15: the active server announces another block at that height; the majority wins, + and the active server's answer is reported separately so the lie is visible.""" + real = _mine_header("00" * 32) + fake = _mine_header("11" * 32) + listener = _header_listener( + session_factory, {"first.example": fake, "second.example": real, "third.example": real} + ) + agreed, reported = await listener.agreed_block_hash(100) + assert agreed == header_hex_to_block_hash(real) + assert reported == header_hex_to_block_hash(fake) + + +async def test_no_strict_majority_means_no_hash(session_factory): + """RND-16: three servers, three different answers — nothing is agreed, and the caller + asks again for the same height.""" + a, b, c = (_mine_header(prev) for prev in ("00" * 32, "11" * 32, "22" * 32)) + listener = _header_listener(session_factory, {"first.example": a, "second.example": b, "third.example": c}) + agreed, reported = await listener.agreed_block_hash(100) + assert agreed is None and reported == header_hex_to_block_hash(a) + + +async def test_one_other_server_answering_is_not_a_quorum(session_factory): + """ELC-14 / B-84: two others are configured but only one answers — even in agreement + with the active server, that is not enough. ALLOW_NO_QUORUM relaxes it.""" + header = _mine_header("00" * 32) + answers = {"first.example": header, "second.example": header, "third.example": None} + listener = _header_listener(session_factory, answers) + assert (await listener.agreed_block_hash(100))[0] is None + relaxed = _header_listener(session_factory, answers, allow_no_quorum=True) + assert (await relaxed.agreed_block_hash(100))[0] == header_hex_to_block_hash(header) + + +async def test_two_others_agreeing_decide_even_when_the_active_server_is_silent(session_factory): + header = _mine_header("00" * 32) + listener = _header_listener( + session_factory, {"first.example": None, "second.example": header, "third.example": header} + ) + agreed, reported = await listener.agreed_block_hash(100) + assert agreed == header_hex_to_block_hash(header) and reported is None + + +async def test_nobody_answering_agrees_on_nothing(session_factory): + listener = _header_listener( + session_factory, + {"first.example": None, "second.example": None, "third.example": ConnectionRefusedError("down")}, + ) + assert await listener.agreed_block_hash(100) == (None, None) + + +async def test_a_header_without_valid_work_is_not_an_answer(session_factory, monkeypatch): + """ELC-11 applies to the draw block too: a junk header is discarded, not counted.""" + from app.rounds import draw + + real = _mine_header("00" * 32) + monkeypatch.setattr(draw, "POW_LIMIT", draw.MAINNET_POW_LIMIT) # real == easy header: refused + listener = _header_listener( + session_factory, {"first.example": real, "second.example": real, "third.example": real} + ) + assert await listener.agreed_block_hash(100) == (None, None) + + +async def test_without_other_servers_only_allow_no_quorum_lets_the_active_one_decide(session_factory): + """ELC-14 / ELC-19: the development override, and only it, takes the lone active answer.""" + single = [ElectrumEndpoint("first.example", 50002, True)] + header = _mine_header("00" * 32) + strict = _header_listener(session_factory, {"first.example": header}, endpoints=single) + assert (await strict.agreed_block_hash(100))[0] is None + dev = _header_listener(session_factory, {"first.example": header}, allow_no_quorum=True, endpoints=single) + assert (await dev.agreed_block_hash(100))[0] == header_hex_to_block_hash(header) diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py index 3dc339f..88c2dad 100644 --- a/tests/unit/test_migrations.py +++ b/tests/unit/test_migrations.py @@ -160,3 +160,39 @@ def test_failed_at_and_paused_reason_are_backfilled_for_existing_rows(tmp_path): assert reason == "operator" assert _index_sql(db_path, "ix_rounds_single_active") is not None assert _index_sql(db_path, "ix_users_username_lower") is not None + + +def test_the_commit_reveal_columns_and_the_draw_position_index_exist_at_head(migrated_db): + """B-73 (DAT-62 … DAT-66): the new columns, their defaults and the unique position index.""" + with sqlite3.connect(migrated_db) as conn: + rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")} + for column in ("server_seed_enc", "server_seed", "server_seed_hash", "participants_hash", "draw_height"): + assert column in rounds and rounds[column][3] == 0 # nullable + assert rounds["draw_confirmations"][3] == 1 and rounds["draw_confirmations"][4] in ("3", "'3'") + participants = {r[1]: r for r in conn.execute("PRAGMA table_info(round_participants)")} + assert participants["bet_vout"][3] == 1 and participants["bet_vout"][4] in ("0", "'0'") + assert participants["draw_position"][3] == 0 + config = {r[1]: r for r in conn.execute("PRAGMA table_info(round_config)")} + assert config["draw_confirmations"][4] in ("3", "'3'") + + sql = _index_sql(migrated_db, "ux_round_participants_round_draw_position") + assert sql is not None and "unique" in sql and "(round_id, draw_position)" in sql + + +def test_two_participants_cannot_share_a_draw_position_but_null_positions_coexist(migrated_db): + with sqlite3.connect(migrated_db) as conn: + conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closed', '2026-01-01 00:00:00')") + round_id = conn.execute("SELECT max(id) FROM rounds").fetchone()[0] + columns = "(round_id, user_id, bet_amount_sats, bet_txid, broadcast_at, status, draw_position)" + + def insert(user_id, position): + conn.execute( + f"INSERT INTO round_participants {columns} VALUES (?, ?, 1, 'tx', '2026-01-01 00:00:00', 'confirmed', ?)", + (round_id, user_id, position), + ) + + insert(1, None) + insert(2, None) # NULLs do not collide + insert(3, 0) + with pytest.raises(sqlite3.IntegrityError): + insert(4, 0) diff --git a/tests/unit/test_rounds_service.py b/tests/unit/test_rounds_service.py index dfcad3b..ae43aa6 100644 --- a/tests/unit/test_rounds_service.py +++ b/tests/unit/test_rounds_service.py @@ -288,3 +288,64 @@ async def test_cooldown_comes_from_the_round_that_closed(session_factory): async with session_factory() as session: assert await open_new_round_if_needed(session) is None # still cooling down + + +# --- B-73 (RND-34, DAT-61, DAT-64, SEC-56): the commitment made when a round opens -------- + + +async def test_a_new_round_commits_to_an_encrypted_secret_and_copies_draw_confirmations(session_factory): + """The hash is published, the secret is stored only encrypted (and decrypts to the + preimage of the hash), the plaintext column stays empty, and the number of confirmations + is copied from the config at open — an edit applies from the next round.""" + import hashlib + + from app.wallet.keystore import decrypt_round_seed + + async with session_factory() as session: + (await session.scalars(select(RoundConfig))).one().draw_confirmations = 5 + await session.commit() + async with session_factory() as session: + round_ = await open_new_round_if_needed(session) + await session.commit() + round_id = round_.id + + async with session_factory() as session: + round_ = await session.get(Round, round_id) + assert round_.draw_confirmations == 5 + assert round_.server_seed is None # DAT-64: no plaintext before the reveal + assert round_.server_seed_enc and round_.server_seed_hash + seed = decrypt_round_seed(round_.server_seed_enc) + assert len(bytes.fromhex(seed)) == 32 + assert hashlib.sha256(bytes.fromhex(seed)).hexdigest() == round_.server_seed_hash + assert seed not in round_.server_seed_enc # an encrypted token, not the secret in disguise + (await session.scalars(select(RoundConfig))).one().draw_confirmations = 9 # edited mid-round + await session.commit() + assert (await session.get(Round, round_id)).draw_confirmations == 5 + + +async def test_every_round_gets_its_own_secret(session_factory): + seeds = set() + for _ in range(3): + async with session_factory() as session: + round_ = await open_new_round_if_needed(session) + round_.status = "closed" + round_.closed_at = datetime.now(timezone.utc) - timedelta(days=1) + seeds.add(round_.server_seed_hash) + await session.commit() + assert len(seeds) == 3 + + +async def test_the_secret_cannot_be_decrypted_with_another_key(session_factory, monkeypatch): + """SEC-56: a copy of the database without XPRV_ENCRYPTION_KEY does not reveal it.""" + from cryptography.fernet import Fernet, InvalidToken + + from app.config import settings + from app.wallet.keystore import decrypt_round_seed + + async with session_factory() as session: + round_ = await open_new_round_if_needed(session) + await session.commit() + token = round_.server_seed_enc + monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode()) + with pytest.raises(InvalidToken): + decrypt_round_seed(token) diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index d6ac9e7..a02584a 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -399,165 +399,380 @@ async def test_tick_retries_once_the_throttle_window_has_elapsed(payout_session_ assert pending.status == "pending" -# --- B-28: the draw must not seed itself from an uncorroborated header ----------- +# --- B-73 (RND-14 … RND-20, RND-33, RND-34): the commit-reveal draw ------------------------ + +_TIP = 100 -class CorroboratingListener: - """A fake listener whose tip advances the moment a corroboration attempt - fails, simulating a further block arriving — lets tests drive - _wait_for_next_block's retry loop deterministically without real sleeps.""" +class DrawListener: + """What the draw asks of the listener: a tip that tests move, and the quorum's answer for a + height (`blocks`: height -> hash, or None for "no agreement"). Every question is + recorded so a test can assert it was always about the *fixed* draw height.""" - def __init__(self, *, responses: dict[int, bool], advance_to: dict[int, tuple[int, str]] | None = None): - self.tip_height, self.tip_header_hex = next(iter(responses)), "aa" - self._responses = dict(responses) - self._advance_to = advance_to or {} - self.corroboration_calls: list[int] = [] + client = object() - async def corroborate_header(self, height: int, expected_hash: str) -> bool: - self.corroboration_calls.append(height) - result = self._responses[height] - if not result and height in self._advance_to: - self.tip_height, self.tip_header_hex = self._advance_to[height] - return result + def __init__(self, tip_height: int = _TIP, blocks: dict[int, str | None] | None = None): + self.tip_height = tip_height + self.tip_header_hex = "00" + self.blocks = blocks if blocks is not None else {} + self.asked: list[int] = [] + self.reported: dict[int, str | None] = {} + + async def agreed_block_hash(self, height: int) -> tuple[str | None, str | None]: + self.asked.append(height) + return self.blocks.get(height), self.reported.get(height, self.blocks.get(height)) -async def test_wait_for_next_block_accepts_an_immediately_corroborated_block(session_factory): - listener = CorroboratingListener(responses={101: True}) - scheduler = RoundScheduler(session_factory, listener) - - height, block_hash = await scheduler._wait_for_next_block( - round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc) - ) - - assert height == 101 - assert listener.corroboration_calls == [101] +def _bet_txid(i: int) -> str: + return f"{(0xA0 + i * 7) % 256:02x}" * 32 -async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_factory): - """B-28: an uncorroborated header must never be used — the wait keeps going - until a later block's header *is* corroborated, logging why each time.""" - listener = CorroboratingListener( - responses={101: False, 102: True}, advance_to={101: (102, "bb")} - ) - scheduler = RoundScheduler(session_factory, listener) - - height, block_hash = await scheduler._wait_for_next_block( - round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc) - ) - - assert height == 102 - assert listener.corroboration_calls == [101, 102] +async def _seed_closing_round(session_factory, *, n: int = 3, draw_confirmations: int = 3, txids=None) -> int: + """A real round opened by `open_new_round_if_needed` (so it carries its real encrypted + secret), closed, with `n` confirmed participants whose txids are NOT in broadcast order.""" + from app.rounds.service import open_new_round_if_needed async with session_factory() as session: - events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] - assert events == ["draw_header_corroboration_failed"] + session.add(RoundConfig(fee_address=_FEE_ADDRESS, draw_confirmations=draw_confirmations)) + await session.commit() + round_ = await open_new_round_if_needed(session) + await session.commit() + round_.status = "closing" + round_.closed_at = datetime.now(timezone.utc) + txids = txids or [_bet_txid(i) for i in range(n)] + for i in range(n): + user = User(username=f"player{i}", password_hash="x", derivation_index=i, address=_address(i)) + session.add(user) + await session.flush() + session.add( + RoundParticipant( + round_id=round_.id, + user_id=user.id, + bet_amount_sats=1_000_000_000 - 5_000 * i, + bet_txid=txids[i], + bet_vout=0, + # broadcast order is the *reverse* of txid order for some of them, so an + # implementation still sorting by broadcast_at would get a different list + broadcast_at=datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=i), + status="confirmed", + ) + ) + await session.commit() + return round_.id -# --- B-63: an unknown tip at closing time must not become the draw's seed -------- +def _address(i: int) -> str: + return derive_pool_address(1000 + i) # any distinct, valid PLM addresses -class LateTipListener: - """A listener that doesn't know the tip yet and learns it only once asked — - the state the old code could observe while `client` already looked alive.""" +async def _enter_drawing_only(scheduler) -> list[int]: + """Run `_close_and_draw` up to (not including) the draw itself.""" + entered: list[int] = [] - def __init__(self, *, learns: tuple[int, str], then_advances_to: tuple[int, str]): - self.tip_height = 0 - self.tip_header_hex = None - self._learns = learns - self._then_advances_to = then_advances_to - self.corroboration_calls: list[int] = [] + async def record(round_id: int) -> None: + entered.append(round_id) - def learn_tip(self) -> None: - self.tip_height, self.tip_header_hex = self._learns - - async def corroborate_header(self, height: int, expected_hash: str) -> bool: - self.corroboration_calls.append(height) - return True + scheduler._complete_draw = record + return entered -async def test_wait_for_next_block_never_seeds_the_draw_from_a_pre_close_block( - session_factory, monkeypatch -): # B-63 - """A tip_at_close of 0 means the tip was *unknown* when the round closed, not - that the chain was at height zero. The first header we then learn describes a - block that may well predate the close — whose hash was public while bets were - still open — so it must become the baseline, never the seed: the draw waits for a - block strictly after it.""" - import app.rounds.scheduler as scheduler_module - - listener = LateTipListener(learns=(500, "aa"), then_advances_to=(501, "bb")) +async def test_closing_with_participants_commits_the_draw_and_enters_drawing(session_factory): + """B-73 (RND-14, RND-33, DAT-65, AUD-37): in one transaction the list is ordered by bet txid + and numbered, its fingerprint and the draw height (tip + 1) are stored, and the round + enters `drawing` — before any block of the draw exists.""" + round_id = await _seed_closing_round(session_factory, n=3) + listener = DrawListener(tip_height=_TIP) scheduler = RoundScheduler(session_factory, listener) + entered = await _enter_drawing_only(scheduler) - async def fake_sleep(_seconds): - # First sleep: the tip becomes known (height 500, the pre-close block). - # Second: a genuinely new block arrives on top of it. - if listener.tip_height == 0: - listener.learn_tip() - else: - listener.tip_height, listener.tip_header_hex = listener._then_advances_to + await scheduler._close_and_draw(round_id) - monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep) + assert entered == [round_id] + async with session_factory() as session: + round_ = await session.get(Round, round_id) + assert round_.status == "drawing" + assert round_.draw_height == _TIP + 1 + rows = ( + await session.execute( + select(RoundParticipant.draw_position, RoundParticipant.bet_txid, RoundParticipant.user_id) + .where(RoundParticipant.round_id == round_id) + .order_by(RoundParticipant.draw_position) + ) + ).all() + assert [r.draw_position for r in rows] == [0, 1, 2] + assert [r.bet_txid for r in rows] == sorted(_bet_txid(i) for i in range(3)) # ascending txid + users = {u.id: u.address for u in (await session.scalars(select(User))).all()} + # an independent recomputation of the fingerprint (RND-14) + import hashlib - height, _block_hash = await scheduler._wait_for_next_block( - round_id=1, tip_at_close=0, waiting_since=datetime.now(timezone.utc) - ) + text = "".join(f"{r.bet_txid}:0:{users[r.user_id]}\n" for r in rows) + assert round_.participants_hash == hashlib.sha256(text.encode()).hexdigest() + assert round_.pool_amount_sats == sum(1_000_000_000 - 5_000 * i for i in range(3)) + assert round_.drawing_started_at is not None - assert height == 501 # the block *after* the one we first learned about - assert listener.corroboration_calls == [501] # 500 was never even a candidate + committed = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_committed"))).all() + assert len(committed) == 1 and committed[0].round_id == round_id + import json + + assert json.loads(committed[0].payload_json) == { + "participants_hash": round_.participants_hash, + "draw_height": _TIP + 1, + "participants": 3, + "server_seed_hash": round_.server_seed_hash, + } + + +async def test_participants_with_equal_broadcast_times_are_still_ordered_by_txid(session_factory): + """RND-14 / DAT-66: the order is a function of public data only. The former order by + `broadcast_at` had no tie-break: bets stamped with the same instant could be drawn in + either order, so the draw could not be reproduced.""" + txids = ["cc" * 32, "aa" * 32, "bb" * 32] + round_id = await _seed_closing_round(session_factory, n=3, txids=txids) + async with session_factory() as session: + for p in (await session.scalars(select(RoundParticipant))).all(): + p.broadcast_at = datetime(2026, 1, 1, tzinfo=timezone.utc) # all identical + await session.commit() + scheduler = RoundScheduler(session_factory, DrawListener()) + await _enter_drawing_only(scheduler) + + await scheduler._close_and_draw(round_id) async with session_factory() as session: - events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] - assert events == ["draw_baseline_tip_unknown"] # explainable from /admin + ordered = ( + await session.scalars( + select(RoundParticipant.bet_txid) + .where(RoundParticipant.round_id == round_id) + .order_by(RoundParticipant.draw_position) + ) + ).all() + assert ordered == sorted(txids) -# --- B-36: a stalled draw must be visible, not a silent frozen wait -------------- +async def test_the_draw_height_is_fixed_when_entering_drawing_and_never_moves(session_factory): + """RND-33: tip at closing + 1, stored on the round — the chain advancing afterwards does + not change it.""" + round_id = await _seed_closing_round(session_factory) + listener = DrawListener(tip_height=250) + scheduler = RoundScheduler(session_factory, listener) + await _enter_drawing_only(scheduler) + await scheduler._close_and_draw(round_id) + listener.tip_height = 400 + async with session_factory() as session: + assert (await session.get(Round, round_id)).draw_height == 251 -class StallingListener: - """A tip that never advances until the test decides it should — used to drive - _wait_for_next_block's stall-detection past _DRAW_STALL_THRESHOLD_SECONDS - without a real 6-minute wait.""" +async def test_an_unknown_tip_keeps_the_round_closing_and_writes_one_audit_row(session_factory): + """RND-19 / AUD-25: a tip of 0 is "unknown", not a base for the draw height. The round + stays `closing` and is retried at the next step; the audit row is written once.""" + round_id = await _seed_closing_round(session_factory) + listener = DrawListener(tip_height=0) + scheduler = RoundScheduler(session_factory, listener) + entered = await _enter_drawing_only(scheduler) - def __init__(self): - self.tip_height = 100 - self.tip_header_hex = None + await scheduler._close_and_draw(round_id) + await scheduler._close_and_draw(round_id) - async def corroborate_header(self, height: int, expected_hash: str) -> bool: - return True + assert entered == [] + async with session_factory() as session: + round_ = await session.get(Round, round_id) + assert round_.status == "closing" and round_.draw_height is None + rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_baseline_tip_unknown"))).all() + assert len(rows) == 1 and rows[0].payload_json == "{}" + listener.tip_height = 80 # the height becomes known: the next step enters drawing + await scheduler._close_and_draw(round_id) + async with session_factory() as session: + assert (await session.get(Round, round_id)).draw_height == 81 -async def test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold(session_factory, monkeypatch): +async def test_a_round_without_participants_closes_and_reveals_its_secret(session_factory): + """DAT-64: nothing was drawn, so the secret is revealed at the close.""" + round_id = await _seed_closing_round(session_factory, n=0) + scheduler = RoundScheduler(session_factory, DrawListener()) + await scheduler._close_and_draw(round_id) + async with session_factory() as session: + round_ = await session.get(Round, round_id) + assert round_.status == "closed" + assert round_.server_seed is not None + from app.rounds.draw import server_seed_commitment + + assert server_seed_commitment(round_.server_seed) == round_.server_seed_hash + + +async def test_a_round_opened_before_the_commitment_existed_gets_a_secret_when_it_enters_drawing(session_factory): + """Upgrade path: a round with no `server_seed_enc` (opened before B-73) must not wedge — it + is given a secret as it enters `drawing`, still before any block of the draw exists.""" + round_id = await _seed_closing_round(session_factory) + async with session_factory() as session: + round_ = await session.get(Round, round_id) + round_.server_seed_enc = round_.server_seed_hash = None + await session.commit() + scheduler = RoundScheduler(session_factory, DrawListener()) + await _enter_drawing_only(scheduler) + await scheduler._close_and_draw(round_id) + async with session_factory() as session: + round_ = await session.get(Round, round_id) + assert round_.status == "drawing" and round_.server_seed_enc and round_.server_seed_hash + assert round_.server_seed is None + + +def _fake_clock(monkeypatch, listener, on_sleep=None): + """Drive `_wait_for_draw_block` without real time: `datetime.now` is a clock that only + `asyncio.sleep` advances, and `on_sleep(now)` lets the test move the chain.""" import app.rounds.scheduler as scheduler_module - listener = StallingListener() - scheduler = RoundScheduler(session_factory, listener) start = datetime.now(timezone.utc) - class _FakeClock: + class _Clock: now = start - def fake_now(tz=None): - return _FakeClock.now + monkeypatch.setattr( + scheduler_module, "datetime", type("_D", (), {"now": staticmethod(lambda tz=None: _Clock.now)}) + ) async def fake_sleep(seconds: float) -> None: - _FakeClock.now += timedelta(seconds=seconds) - # Past the stall threshold, but before it would repeat: unblock the wait - # by making a (corroborated) block appear, so the test terminates. - if _FakeClock.now >= start + timedelta(seconds=scheduler_module._DRAW_STALL_THRESHOLD_SECONDS + 30): - listener.tip_height = 101 - listener.tip_header_hex = "aa" + _Clock.now += timedelta(seconds=seconds) + if on_sleep is not None: + on_sleep(_Clock.now - start) - monkeypatch.setattr(scheduler_module, "datetime", type("_D", (), {"now": staticmethod(fake_now)})) monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep) + return start - height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100, waiting_since=start) - assert height == 101 +async def test_the_draw_waits_for_the_confirmations_on_the_draw_block(session_factory, monkeypatch): + """RND-15: draw_height 101 with 3 confirmations needs a chain at 103 or more. The quorum is + not even asked before that — and then it is asked about 101, not about the tip.""" + listener = DrawListener(tip_height=101, blocks={101: "ab" * 32}) + scheduler = RoundScheduler(session_factory, listener) + + def on_sleep(elapsed): + if elapsed >= timedelta(seconds=10): + listener.tip_height = 102 + if elapsed >= timedelta(seconds=20): + listener.tip_height = 103 + + start = _fake_clock(monkeypatch, listener, on_sleep) + block_hash = await scheduler._wait_for_draw_block(1, 101, 3, start) + + assert block_hash == "ab" * 32 + assert listener.asked == [101] # only once the chain reached 103 — and about the fixed height + + +async def test_a_disagreement_asks_the_same_height_again_and_is_audited_once_per_hash(session_factory, monkeypatch): + """RND-16 / AUD-26: no agreement means waiting and asking for the *same* height — never + moving to another block. The audit row is written once per distinct hash the active server + reports, not on every request.""" + listener = DrawListener(tip_height=105, blocks={101: None}) + listener.reported[101] = "ee" * 32 + scheduler = RoundScheduler(session_factory, listener) + + def on_sleep(elapsed): + if elapsed >= timedelta(seconds=30): + listener.blocks[101] = "cd" * 32 # the quorum finally agrees + + start = _fake_clock(monkeypatch, listener, on_sleep) + block_hash = await scheduler._wait_for_draw_block(1, 101, 3, start) + + assert block_hash == "cd" * 32 + assert set(listener.asked) == {101} and len(listener.asked) > 2 + async with session_factory() as session: + rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_header_corroboration_failed"))).all() + import json + + assert [json.loads(r.payload_json) for r in rows] == [{"height": 101, "reported_hash": "ee" * 32}] + + # a different hash reported later is a new fact worth a row of its own + listener.blocks[101] = None + listener.reported[101] = "ff" * 32 + + def on_sleep_again(elapsed): + if elapsed >= timedelta(seconds=15): + listener.blocks[101] = "cd" * 32 + + _fake_clock(monkeypatch, listener, on_sleep_again) + await scheduler._wait_for_draw_block(1, 101, 3, datetime.now(timezone.utc)) + async with session_factory() as session: + rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_header_corroboration_failed"))).all() + assert [json.loads(r.payload_json)["reported_hash"] for r in rows] == ["ee" * 32, "ff" * 32] + + +async def test_a_block_relayer_cannot_choose_the_block_that_seeds_the_draw(session_factory, monkeypatch): + """B-73: the active server announces tip 104 with a block of its choosing (and withholds + the real one) while the quorum has nothing yet for the fixed draw height 101: the draw + must not take 104's hash, nor 102's — it waits for the quorum at 101, however long.""" + listener = DrawListener(tip_height=104, blocks={101: None, 102: "11" * 32, 104: "44" * 32}) + scheduler = RoundScheduler(session_factory, listener) + + def on_sleep(elapsed): + if elapsed >= timedelta(seconds=60): + listener.blocks[101] = "01" * 32 + + start = _fake_clock(monkeypatch, listener, on_sleep) + block_hash = await scheduler._wait_for_draw_block(1, 101, 3, start) + + assert block_hash == "01" * 32 # the hash at 101, whatever the others looked like + assert set(listener.asked) == {101} + + +async def test_the_stall_clock_runs_only_while_the_chain_does_not_advance(session_factory, monkeypatch): + """RND-20: with 3 confirmations a round waits ~3 blocks. A chain producing a block every + 120s must not look stalled (the old rule counted from the start and fired in almost every + round).""" + listener = DrawListener(tip_height=101, blocks={101: "ab" * 32}) + scheduler = RoundScheduler(session_factory, listener) + + def on_sleep(elapsed): + listener.tip_height = 101 + int(elapsed.total_seconds() // 300) # a block every 300s + + start = _fake_clock(monkeypatch, listener, on_sleep) + await scheduler._wait_for_draw_block(1, 101, 3, start) # needs tip 103: ~600s > 360s overall async with session_factory() as session: - entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all() - assert len(entries) == 1 - assert entries[0].round_id == 1 + assert (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all() == [] + + +async def test_a_chain_that_stops_advancing_writes_draw_stalled_and_repeats(session_factory, monkeypatch): + """RND-20 / AUD-27: payload {draw_height, current_tip_height, elapsed_seconds}; one row per + threshold for as long as the stall lasts.""" + import app.rounds.scheduler as scheduler_module + + listener = DrawListener(tip_height=101, blocks={101: "ab" * 32}) + scheduler = RoundScheduler(session_factory, listener) + threshold = scheduler_module._DRAW_STALL_THRESHOLD_SECONDS + + def on_sleep(elapsed): + if elapsed >= timedelta(seconds=2 * threshold + 30): + listener.tip_height = 103 + + start = _fake_clock(monkeypatch, listener, on_sleep) + await scheduler._wait_for_draw_block(1, 101, 3, start) + + async with session_factory() as session: + import json + + rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all() + payloads = [json.loads(r.payload_json) for r in rows] + assert len(payloads) == 2 + assert all(p["draw_height"] == 101 and p["current_tip_height"] == 101 for p in payloads) + assert payloads[0]["elapsed_seconds"] >= threshold + + +async def test_a_quorum_that_never_agrees_writes_draw_stalled(session_factory, monkeypatch): + """RND-20: "oppure il quorum continua a non concordare" — the confirmations are there but + the servers disagree for the whole threshold.""" + import app.rounds.scheduler as scheduler_module + + listener = DrawListener(tip_height=110, blocks={101: None}) + scheduler = RoundScheduler(session_factory, listener) + threshold = scheduler_module._DRAW_STALL_THRESHOLD_SECONDS + + def on_sleep(elapsed): + if elapsed >= timedelta(seconds=threshold + 20): + listener.blocks[101] = "ab" * 32 + + start = _fake_clock(monkeypatch, listener, on_sleep) + await scheduler._wait_for_draw_block(1, 101, 3, start) + async with session_factory() as session: + assert len((await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()) == 1 async def test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check(session_factory): # B-53