Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)

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 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 16:29:09 +02:00
1 parent 82d078e2d6
commit db24287ea5
42 files changed
+1715 -620

No files matched your search

-22
View File
@@ -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
+8 -7
View File
@@ -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/<round_id>`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **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.
+1 -1
View File
@@ -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
+7
View File
@@ -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
]
+100 -1
View File
@@ -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,
)
+2
View File
@@ -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",
)
+34 -2
View File
@@ -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
+42 -25
View File
@@ -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
+54 -9
View File
@@ -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))]
+202 -116
View File
@@ -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.
+16
View File
@@ -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:
+2
View File
@@ -83,6 +83,8 @@
<input id="admin-round-cooldown" inputmode="numeric" placeholder="es. 30">
<label for="admin-draw-animation">Durata animazione estrazione (secondi)</label>
<input id="admin-draw-animation" inputmode="numeric" placeholder="es. 20">
<label for="admin-draw-confirmations">Conferme sul blocco di estrazione (1–20, dal round successivo)</label>
<input id="admin-draw-confirmations" inputmode="numeric" placeholder="es. 3">
<label for="admin-fee-rate">Fee rate di rete (sat/vB)</label>
<input id="admin-fee-rate" inputmode="numeric" placeholder="es. 1">
<label for="admin-rbf-timeout">Timeout prima del fee-bump RBF (secondi)</label>
+2
View File
@@ -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),
};
+7
View File
@@ -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} 个字符。',
+11
View File
@@ -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()
+5
View File
@@ -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
@@ -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`
@@ -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`
@@ -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.
+20 -23
View File
@@ -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 |
+4 -5
View File
@@ -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
+19 -38
View File
@@ -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
+29 -58
View File
@@ -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/<round_id>`, 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
+1 -1
View File
@@ -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/<id_round>` (B-80) | no, è nel codice |
| Riserva per l'RBF sul pagamento | `max(294 sat, dimensione stimata × 10 sat/vB)`, tolta dalla quota della piattaforma, verso `m/84'/746'/0'/1/0` (B-79) | no, è nel codice |
+2 -6
View File
@@ -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
+2 -1
View File
@@ -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. |
+7 -5
View File
@@ -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.
+6 -6
View File
@@ -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
+31 -36
View File
@@ -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 |
+1 -1
View File
@@ -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`
+1 -1
View File
@@ -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.
```
+4 -7
View File
@@ -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
+3 -3
View File
@@ -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`.
@@ -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})")
+26
View File
@@ -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])
+8
View File
@@ -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()
+76 -5
View File
@@ -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 ------------
+348
View File
@@ -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
+132 -114
View File
@@ -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)
+36
View File
@@ -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)
+61
View File
@@ -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)
+327 -112
View File
@@ -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