Resume a round in drawing from the database after an exception or a restart (B-81, RND-01, RND-15, RND-36, ARC-27, OPS-09, OPS-13, LIM-01)
`_tick` skipped `drawing`: the wait for the draw block lived only inside the `_close_and_draw` call that entered it, so any exception (a locked database writing an audit row, a malformed header) or a restart left the round in `drawing` for ever with the pool inside and no new round able to open. Since B-73 everything the draw needs is committed on entering `drawing` (encrypted secret, ordered list with `draw_position`, `participants_hash`, `draw_height`, `draw_confirmations`), so `_tick` now calls `_complete_draw` for a round in `drawing`. `_complete_draw` rereads that state, is idempotent (a round no longer `drawing`, or with a winner, is left alone; the winner write is re-checked under BEGIN IMMEDIATE) and runs inside the single scheduler loop, so the inline call from `_close_and_draw` and the tick can never run concurrently. Tests (test_draw_proof.py): an exception in the quorum query, then a tick, completes with the same height, list, fingerprint and the winner the public proof recomputes; a brand-new scheduler (restart) resumes and a second tick draws and triggers nothing again; a failure in the very transaction that stores the winner leaves no winner and no revealed secret, and the next tick completes. Mutation-checked: without the `_tick` branch all three fail. Docs: the B-81 `*Stato:*`/`*Oggi:*` lines are gone (RND-01, RND-15, RND-36, ARC-27, OPS-09, ADR-12, ADR-25), OPS-13 no longer prescribes the manual `closing` workaround, LIM-01 is struck, storico-bug.md and BUGS.md updated. No existing test changed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
db24287ea5
commit
473d541ab5
12 files changed
+230
-126
No files matched your search
@@ -117,17 +117,6 @@ round of 1 or of `MAX_PARTICIPANTS_PER_ROUND` participants could not cover winne
|
||||
fee + reserve + dust commission, and `PUT /admin/config` refuses a bet amount or
|
||||
fee rate that would fail the same check (spec: RND-40).
|
||||
|
||||
### B-81 — `drawing` wedges on any exception, not only on restart
|
||||
|
||||
The participant snapshot and `tip_at_close` live only in memory; `_tick` skips
|
||||
`drawing`. Any exception during the wait (`database is locked` writing an audit row,
|
||||
a malformed header) leaves the round in `drawing` forever.
|
||||
|
||||
Fix: persist `draw_height`, `participants_hash` and each participant's
|
||||
`draw_position` when entering `drawing`; `_tick` re-enters the wait for a round in
|
||||
`drawing`, idempotently. Together with B-73's fixed height and committed seed, a
|
||||
resumed draw always yields the same winner.
|
||||
|
||||
### B-82 — A deposit reorged out after a bet leaves the round unpayable
|
||||
|
||||
Deposits are credited at 1 confirmation. A user can bet, then (if they lost)
|
||||
|
||||
@@ -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 — 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.
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 620 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 617 tests
|
||||
python -m pytest # all 620 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
|
||||
```
|
||||
@@ -71,7 +71,7 @@ docker compose down
|
||||
|
||||
`SITE_ADDRESS` unset → `localhost`, Caddy issues a self-signed cert from its internal CA (browser warning on first visit is expected; `curl -k`). `SITE_ADDRESS=lottery.example.com docker compose up -d` → real Let's Encrypt cert, automatically renewed (needs DNS pointing here and ports 80+443 reachable).
|
||||
|
||||
The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and a CSP scoped to `default-src 'self'` plus the Google Fonts `@import` in `style.css`/`admin.css`. It also overwrites `X-Forwarded-For` with the real peer (`header_up X-Forwarded-For {remote_host}`, B-54) — Caddy otherwise *appends* to whatever the client sent, which made every IP-keyed control (the B-33 throttles, B-38's SSE cap) bypassable; `app/api/client_ip.py` independently reads the *last* hop, so either half closes it. `script-src`/`style-src` need `'unsafe-inline'` because both SPAs use inline `onclick` handlers and `style=""` attributes throughout — removing those is a separate, larger refactor, not a header change. `restart: unless-stopped` on `app` means a mid-round crash auto-restarts: `closing` and `paying_out` resume on their own, `drawing` does not (see Known gaps).
|
||||
The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and a CSP scoped to `default-src 'self'` plus the Google Fonts `@import` in `style.css`/`admin.css`. It also overwrites `X-Forwarded-For` with the real peer (`header_up X-Forwarded-For {remote_host}`, B-54) — Caddy otherwise *appends* to whatever the client sent, which made every IP-keyed control (the B-33 throttles, B-38's SSE cap) bypassable; `app/api/client_ip.py` independently reads the *last* hop, so either half closes it. `script-src`/`style-src` need `'unsafe-inline'` because both SPAs use inline `onclick` handlers and `style=""` attributes throughout — removing those is a separate, larger refactor, not a header change. `restart: unless-stopped` on `app` means a mid-round crash auto-restarts: `closing`, `drawing` and `paying_out` all resume on their own (B-81: `_tick` re-enters `_complete_draw`, which rebuilds everything from the database, so a resumed draw yields the same winner).
|
||||
|
||||
## Tech stack
|
||||
|
||||
@@ -176,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; **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`.
|
||||
- *Algorithm* (deliberately simple, meant to be replaced; **commit-reveal since B-73**, spec RND-14 … RND-20, RND-34 … RND-37): at open the round gets a 32-byte `server_seed` — stored only **Fernet-encrypted** with the master-xprv key (`server_seed_enc`; the plaintext column `server_seed` stays NULL until the reveal) — and publishes `server_seed_hash = sha256(seed)`. Entering `drawing` fixes, in one transaction with the status change, the confirmed participants **ordered by bet txid ascending** (public data, no ties — never `broadcast_at`), each one's `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n` lines; `bet_vout` is the output paying the round's pool address), and `draw_height = tip_at_close + 1`, and writes `draw_committed`. The draw waits until the chain has `draw_confirmations` blocks from `draw_height` (snapshotted at open from `round_config`, default 3), then asks **every** server, the active one included, for the header *at that exact height* (`ElectrumListener.agreed_block_hash`: valid work, at least 2 other answers, strict majority of all answers) — no agreement means the same height is asked again, never another block, so whoever mines or relays blocks cannot pick the one that seeds the draw (`draw_header_corroboration_failed`, once per distinct hash the active server reported). `seed = sha256(server_seed ‖ block_hash ‖ participants_hash)` as a big-endian integer, `index = seed mod n`, winner = participant with that `draw_position`; equal probability for everyone, regardless of amount. The reveal (`server_seed` column + `winner_drawn` payload) happens in the same transaction that stores the winner (or when a round closes with no participants), and `GET /rounds/{id}/proof` (public) returns everything needed to recompute it. A tip of 0 at closing means *unknown*: the round stays `closing` (`draw_baseline_tip_unknown`, once per round, B-63) because `draw_height` must come from a height we actually knew. `draw_stalled` fires when the chain has not advanced for 360s while waiting for confirmations, or the quorum keeps failing to agree. `_complete_draw` reads everything it needs from the database (`_close_and_draw` enters `drawing` and then calls it, and since B-81 `_tick` calls it again for any round found in `drawing` — after an exception or a restart — so it is idempotent: a round no longer in `drawing`, or already holding a winner, is left alone); a round opened before B-73 (no `server_seed_enc`) is given a secret as it enters `drawing`.
|
||||
- *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/<round_id>`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **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`.
|
||||
@@ -262,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 `_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.
|
||||
- **The `drawing` wait is unbounded by design** (the draw's entropy genuinely depends on a future block). It resumes on its own after an exception or a restart (B-81), and is observable rather than silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36).
|
||||
- **RBF handles one shape only**: a single 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.
|
||||
|
||||
@@ -62,7 +62,7 @@ python -m pytest # all tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
```
|
||||
|
||||
617 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
620 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
|
||||
|
||||
+13
-1
@@ -97,8 +97,20 @@ class RoundScheduler:
|
||||
await self._retry_payout_if_due(round_id)
|
||||
return
|
||||
|
||||
if status == "drawing":
|
||||
# B-81 (RND-36, ARC-27): a round in `drawing` is picked up here at every step, not
|
||||
# only from inside the _close_and_draw call that entered it. Everything the draw
|
||||
# needs — secret, ordered list with positions, fingerprint, height, confirmations
|
||||
# — is in the database since the transition (B-73), so an exception in an earlier
|
||||
# step (a locked database while writing an audit row, a malformed header) or a
|
||||
# restart leaves nothing behind that this could not rebuild, and the resumed draw
|
||||
# gives the same winner. The wait stays inside this step: nothing else can run
|
||||
# until the round is closed anyway, since no other round may open.
|
||||
await self._complete_draw(round_id)
|
||||
return
|
||||
|
||||
if status not in ("open", "closing"):
|
||||
return # "drawing" — progress happens inside the in-flight _close_and_draw call
|
||||
return
|
||||
|
||||
if status == "open":
|
||||
if datetime.now(timezone.utc) < deadline:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-12 — Il server gira solo in Docker, in sviluppo come in produzione
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-81)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Configurazione e ambiente
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -19,5 +19,3 @@ script della chiave.
|
||||
|
||||
*Codice:* `Dockerfile` · `docker-compose.yml` · `Caddyfile` ·
|
||||
*Test:* `tests/unit/test_docs_no_bare_uvicorn.py`
|
||||
*Stato:* da implementare (B-81) · *Oggi:* dopo un crash `closing` e
|
||||
`paying_out` riprendono da soli, `drawing` no ([LIM-01]).
|
||||
@@ -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-81)
|
||||
- **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*.
|
||||
|
||||
@@ -32,6 +32,3 @@ chiusura significa «sconosciuta»: la prima altezza appresa fa da base.
|
||||
|
||||
*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.
|
||||
@@ -554,12 +554,7 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
`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
|
||||
`_close_and_draw`, quindi un'eccezione o un riavvio lasciano il round in
|
||||
`drawing` per sempre
|
||||
([LIM-01](limiti-noti.md#robustezza-del-ciclo-di-vita)).
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_draw_proof.py`
|
||||
- **[ARC-28]** **`ConfirmationPoller`** (10s), **`RbfBumper`** (30s) e
|
||||
**`PendingTransactionReconciler`** (al primo giro subito, poi ogni 120s)
|
||||
lavorano sulla tabella `pending_transactions` (e sulla tabella
|
||||
|
||||
@@ -23,10 +23,7 @@ adesso; l'ordine di implementazione è in
|
||||
il passo successivo riparte dallo stato salvato nel database, **in
|
||||
qualunque stato sia il round**, `drawing` compreso ([RND-36]).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler.run`, `app/rounds/scheduler.py:RoundScheduler._tick` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`
|
||||
*Stato:* da implementare (B-81) · *Oggi:* un errore durante l'attesa del
|
||||
blocco lascia il round in `drawing` per sempre, perché `_tick` non fa nulla
|
||||
sui round in quello stato.
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_draw_proof.py::test_a_tick_resumes_a_drawing_round_after_an_exception_with_the_same_draw`
|
||||
|
||||
## Gli stati
|
||||
|
||||
@@ -281,11 +278,8 @@ 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_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.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block`, `app/rounds/scheduler.py:RoundScheduler._tick` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_draw_proof.py::test_a_restarted_scheduler_resumes_a_drawing_round_and_a_second_tick_draws_nothing`
|
||||
- **[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
|
||||
@@ -354,10 +348,7 @@ flowchart TB
|
||||
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._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.
|
||||
*Test:* `tests/unit/test_draw_proof.py::test_a_drawing_round_is_completed_by_a_second_pass_with_the_same_winner`, `tests/unit/test_draw_proof.py::test_a_tick_resumes_a_drawing_round_after_an_exception_with_the_same_draw`, `tests/unit/test_draw_proof.py::test_a_restarted_scheduler_resumes_a_drawing_round_and_a_second_tick_draws_nothing`, `tests/unit/test_draw_proof.py::test_resuming_after_a_failure_inside_the_draw_transaction_leaves_no_half_state`
|
||||
- **[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
|
||||
@@ -625,6 +616,4 @@ Due livelli indipendenti:
|
||||
## Limite noto
|
||||
|
||||
- ~~[RND-32]~~ Assorbito da [RND-36]: nel comportamento atteso un round in
|
||||
`drawing` riprende da solo dopo un riavvio o un errore. Finché B-81 non è
|
||||
implementato il limite resta: lo scheduler riprende i round in `closing` e
|
||||
`paying_out`, ma su un round in `drawing` non fa nulla.
|
||||
`drawing` riprende da solo dopo un riavvio o un errore (B-81).
|
||||
+4
-26
@@ -32,7 +32,7 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
|
||||
| Limite | Blocca il non presidiato oggi? | Dopo il piano |
|
||||
|---|---|---|
|
||||
| [LIM-01] `drawing` non riprende dopo un errore o un riavvio | **Sì** — è lo stato che resta fermo con denaro in gioco | Chiuso da B-81 |
|
||||
| ~~[LIM-01]~~ `drawing` non riprende dopo un errore o un riavvio | Non più (chiuso da B-81) | — |
|
||||
| [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** | Chiuso da B-79 e B-87 |
|
||||
| [LIM-03] Attesa in `closing` senza limite di tempo | **Sì, in caso di congestione** | Resta senza limite di tempo per scelta ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)); con B-78 e B-87 finisce quando la rete decide |
|
||||
| [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** | Da chiudere con la prova su rete reale ([operativita.md](operativita.md#prova-su-rete-reale)) prima di aprire al pubblico |
|
||||
@@ -45,31 +45,9 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
|
||||
### Robustezza del ciclo di vita
|
||||
|
||||
- **[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_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
|
||||
del 2026-09-28 lo ha numerato (B-81).
|
||||
- *Impatto:* il round resta in `drawing` per sempre con il montepremi dentro;
|
||||
poiché un nuovo round si apre solo a round chiuso, **l'intera lotteria si
|
||||
ferma**. Con `restart: unless-stopped` un crash in quella finestra è
|
||||
esattamente il caso che non si ripara da solo.
|
||||
- *Mitigazione attuale:* l'attesa è osservabile — log periodici, evento
|
||||
`draw_stalled` dopo 360s (e poi a ogni intervallo), `draw_waiting_since` su
|
||||
`GET /rounds/current`. Il recupero è manuale (vedi
|
||||
[operativita.md](operativita.md)).
|
||||
- *Stato:* da chiudere con B-81.
|
||||
- *Per chiuderlo:* al passaggio in `drawing` si salvano `draw_height`,
|
||||
`participants_hash` e la posizione di estrazione di ogni partecipante;
|
||||
`_tick` rientra nell'attesa per un round in `drawing`, in modo idempotente.
|
||||
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_draw_block`
|
||||
- ~~[LIM-01]~~ Rimosso: chiuso da B-81. Un round in `drawing` riprende da solo
|
||||
dopo un errore o un riavvio, con lo stesso blocco, gli stessi partecipanti e lo
|
||||
stesso vincitore ([RND-36](ciclo-di-vita-del-round.md)).
|
||||
|
||||
- **[LIM-02]** **L'RBF gestisce una sola forma di transazione**: un'unica uscita
|
||||
di resto verso il mittente, abbastanza grande da assorbire l'aumento. Non c'è
|
||||
|
||||
+21
-50
@@ -200,8 +200,8 @@ denaro di oggi.
|
||||
*dopo* il backup non sono tra i partecipanti, ma i loro soldi sono nel
|
||||
montepremi: annotarli per un rimborso manuale (nessuna procedura
|
||||
supportata, vedi [OPS-24]);
|
||||
- un round in `drawing` **non va mai sbloccato con [OPS-13]** prima di
|
||||
questo controllo: se è già stato pagato, va chiuso come sopra.
|
||||
- un round in `drawing` va controllato allo stesso modo prima di far
|
||||
ripartire l'app: se è già stato pagato, va chiuso come sopra.
|
||||
Annotare ogni modifica (data, id, motivo): nessuna lascia una riga di
|
||||
audit.
|
||||
7. **Controllare gli indirizzi riassegnabili** con [OPS-28], sempre ad app
|
||||
@@ -320,10 +320,7 @@ modifica di `.env` e da solo dopo un crash (`restart: unless-stopped`).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due`,
|
||||
`app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_reconcile.py`
|
||||
*Stato:* da implementare (B-81) · *Oggi:* un round in `drawing`
|
||||
**non riprende**: resta in `drawing` per sempre e nessun nuovo round si apre
|
||||
(serve [OPS-13]).
|
||||
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_reconcile.py`, `tests/unit/test_draw_proof.py`
|
||||
|
||||
- **[OPS-10]** **Riavvio sicuro e deploy** (nuova versione, modifica di
|
||||
`.env`, rotazione di un segreto):
|
||||
@@ -353,7 +350,7 @@ modifica di `.env` e da solo dopo un crash (`restart: unless-stopped`).
|
||||
deploy è quindi, per il codice, un crash: durante `drawing` blocca il round
|
||||
([OPS-13]), durante un invio lascia una riga `building`. Dopo B-93 l'arresto
|
||||
chiude gli stream e le attività entro 30 secondi; il riavvio
|
||||
durante `drawing` diventa sicuro con B-81 ([OPS-09]).
|
||||
durante `drawing` è sicuro dopo B-81 ([OPS-09]).
|
||||
|
||||
La pausa non blocca scommesse e prelievi sul round in corso, né
|
||||
registrazioni e depositi.
|
||||
@@ -444,8 +441,7 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
4. Se i server configurati sono davvero tutti inutilizzabili, aggiungerne
|
||||
altri in `ELECTRUM_FALLBACK_SERVERS` (elenco di riferimento:
|
||||
`ChainProfiles.Mainnet.BootstrapServers` del repository PalladiumWallet) e
|
||||
riavviare. **Se un round è in `drawing`, oggi il riavvio lo blocca**:
|
||||
seguire poi [OPS-13].
|
||||
riavviare. Un round in `drawing` riprende da solo dopo il riavvio ([OPS-13]).
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.run`,
|
||||
`app/electrum/listener.py:ElectrumListener._keepalive`,
|
||||
`app/api/routes/bets.py`, `app/api/routes/withdrawals.py` ·
|
||||
@@ -477,50 +473,25 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
sale (Electrum, vedi [OPS-11]);
|
||||
- righe `draw_header_corroboration_failed`, una per blocco: i blocchi
|
||||
arrivano ma gli altri server non li confermano (vedi [OPS-12]);
|
||||
- **nessuna riga nuova e nessun messaggio `still waiting for a block` nel
|
||||
log**: l'attesa non è più in corso. Succede dopo un riavvio del processo,
|
||||
oppure dopo un'eccezione durante l'attesa (messaggio
|
||||
`round scheduler tick failed`, per esempio un database bloccato mentre si
|
||||
scriveva `draw_stalled`). In questo caso il round non si sbloccherà mai da
|
||||
solo.
|
||||
- nessuna riga nuova per più di qualche minuto, con `round scheduler tick
|
||||
failed` nel log: un'eccezione durante l'attesa (per esempio un database
|
||||
bloccato mentre si scriveva `draw_stalled`) ha interrotto il passo. Non
|
||||
richiede un intervento: dal passo successivo (5 secondi) lo scheduler
|
||||
riprende l'estrazione dello stesso blocco ([RND-36]); se l'eccezione si
|
||||
ripete, la causa è nella riga di log che la accompagna.
|
||||
|
||||
**Causa.** L'attesa del blocco di estrazione vive solo dentro la chiamata
|
||||
dello scheduler che ha portato il round in `drawing`; a ogni ciclo successivo
|
||||
lo scheduler vede `drawing` e non fa nulla. Nessun codice riprende l'attesa.
|
||||
Dopo B-81 lo scheduler riprende da solo un round in `drawing` e il terzo
|
||||
caso scompare.
|
||||
**Causa.** L'attesa del blocco di estrazione è un passo dello scheduler che
|
||||
rilegge tutto dal database. Dopo un riavvio o un'eccezione riparte da sola:
|
||||
`draw_height`, elenco, `participants_hash` e segreto sono già salvati dal
|
||||
passaggio in `drawing`, quindi l'estrazione ripresa dà lo stesso vincitore
|
||||
(B-81).
|
||||
|
||||
**Cosa fare.** Nei primi due casi basta risolvere la causa (Electrum,
|
||||
server di riserva): l'estrazione avviene da sola al primo blocco valido.
|
||||
Nel terzo caso **non esiste una procedura supportata dal codice**. L'unico
|
||||
intervento possibile è una modifica manuale del database che riporti il round
|
||||
a `closing`, lo stato da cui lo scheduler rifà l'estrazione da capo.
|
||||
**Vale solo per un round bloccato nell'istanza in esercizio: un round in
|
||||
`drawing` arrivato da un backup ripristinato si tratta con [OPS-07],
|
||||
passo 6**, perché potrebbe essere già stato pagato.
|
||||
|
||||
1. Annotare l'id del round (dal pannello, sezione Round) e controllare che
|
||||
sia davvero in `drawing` e che `winner_user_id` sia vuoto.
|
||||
2. `docker compose stop app`.
|
||||
3. Aggiornare lo stato:
|
||||
```bash
|
||||
docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); n=c.execute(\"UPDATE rounds SET status='closing' WHERE id=<ID> AND status='drawing' AND winner_user_id IS NULL\").rowcount; c.commit(); print(n, 'riga aggiornata')"
|
||||
```
|
||||
Deve stampare `1 riga aggiornata`.
|
||||
4. `docker compose start app`.
|
||||
5. Al primo ciclo lo scheduler rilegge i partecipanti confermati, riporta il
|
||||
round in `drawing` con un nuovo `drawing_started_at` e aspetta un blocco
|
||||
successivo all'altezza *attuale*; poi estrae e paga normalmente
|
||||
(`winner_drawn`, `payout_sent`).
|
||||
|
||||
Conseguenze da sapere: l'estrazione usa un blocco successivo al momento
|
||||
dello sblocco invece del primo blocco dopo la chiusura, quindi quale blocco
|
||||
decide il vincitore dipende da quando l'operatore interviene; la modifica non
|
||||
lascia alcuna riga nel registro di audit, quindi va annotata altrove, con
|
||||
data, id del round e motivo. I partecipanti non cambiano: il round è fuori da
|
||||
`open`, quindi nessuna scommessa nuova può entrare.
|
||||
**Cosa fare.** Risolvere la causa visibile (Electrum, server di riserva):
|
||||
l'estrazione avviene da sola al primo blocco valido. **Non** riportare il
|
||||
round a `closing` a mano: cambierebbe il blocco che decide il vincitore, e
|
||||
non serve più.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._close_and_draw`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._complete_draw`,
|
||||
`app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block`
|
||||
|
||||
### Round bloccato in `closing`
|
||||
|
||||
+3
-3
@@ -229,7 +229,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
| B-78 | Una transazione viene abbandonata sulla base del silenzio di un solo server | Critica | *da registrare* | `test_reconcile_proof.py`, `test_reconcile.py`, `test_pause_reason.py`, `test_migrations.py` | TX, ELC |
|
||||
| B-79 | Il pagamento di norma non ha resto, quindi non si può accelerare e può bloccare la lotteria | Critica | **aperto** | — | RND, TX |
|
||||
| B-80 | Un solo indirizzo del montepremi per tutti i round | Critica | *da registrare* | `test_round_pool_address.py`, `test_pending_address.py`, `test_scheduler.py` | RND, BET, WDR |
|
||||
| B-81 | `drawing` resta bloccato dopo qualsiasi eccezione, non solo dopo un riavvio | Critica | **aperto** | — | RND |
|
||||
| B-81 | `drawing` resta bloccato dopo qualsiasi eccezione, non solo dopo un riavvio | Critica | *da registrare* | `test_draw_proof.py` | RND |
|
||||
| B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | **aperto** | — | DEP, RND |
|
||||
| B-83 | Conferme e reintegri si basano solo sul server attivo | Alta | *da registrare* | `test_confirmation_proof.py`, `test_confirmation.py` | ELC, TX, DEP |
|
||||
| B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | *da registrare* | `test_electrum_client.py`, `test_electrum_listener.py` | ELC, SEC |
|
||||
@@ -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, B-73.
|
||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81.
|
||||
- **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 (3 critici,
|
||||
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (2 critici,
|
||||
4 alti, 6 medi), da implementare nell'ordine di
|
||||
[piano-correzioni.md](piano-correzioni.md).
|
||||
- **Prossimo numero libero:** `B-99`.
|
||||
@@ -346,3 +346,178 @@ async def test_a_different_block_gives_a_different_draw_but_never_a_different_li
|
||||
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
|
||||
|
||||
|
||||
# ---- B-81 (RND-01, RND-15, RND-36, ARC-27, OPS-09): `_tick` resumes a round in `drawing` ----
|
||||
|
||||
|
||||
class _DrawListener(_Listener):
|
||||
"""A chain whose tip is already past the confirmations, so a resumed draw needs no waiting."""
|
||||
|
||||
tip_height = 503
|
||||
|
||||
|
||||
async def _draw_state(session_factory, round_id: int) -> dict:
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(RoundParticipant)
|
||||
.where(RoundParticipant.round_id == round_id)
|
||||
.order_by(RoundParticipant.draw_position)
|
||||
)
|
||||
).all()
|
||||
return {
|
||||
"status": round_.status,
|
||||
"draw_height": round_.draw_height,
|
||||
"participants_hash": round_.participants_hash,
|
||||
"positions": [(p.user_id, p.draw_position, p.bet_txid) for p in rows],
|
||||
"winner": round_.winner_user_id,
|
||||
"seed_hash": round_.server_seed_hash,
|
||||
}
|
||||
|
||||
|
||||
async def test_a_tick_resumes_a_drawing_round_after_an_exception_with_the_same_draw(client):
|
||||
"""B-81 (RND-36, ARC-27): an exception in the middle of the draw (here the quorum query
|
||||
failing, as a locked database would) leaves the round in `drawing` with everything fixed;
|
||||
the next tick — same scheduler, nothing remembered in memory — completes it with the same
|
||||
height, the same list and the winner the public proof recomputes. Without the `drawing`
|
||||
branch of `_tick` the round stays in `drawing` for ever."""
|
||||
ac, session_factory = client
|
||||
round_id = await _open_round_with_bets(session_factory, n=6)
|
||||
|
||||
class _Flaky(_DrawListener):
|
||||
failing = True
|
||||
|
||||
async def agreed_block_hash(self, height):
|
||||
if self.failing:
|
||||
raise RuntimeError("database is locked")
|
||||
return await super().agreed_block_hash(height)
|
||||
|
||||
listener = _Flaky({501: _BLOCK_HASH})
|
||||
listener.tip_height = 500
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
scheduler._trigger_payout = _noop_payout
|
||||
|
||||
async def fast_sleep(_seconds):
|
||||
listener.tip_height = 503
|
||||
|
||||
import app.rounds.scheduler as scheduler_module
|
||||
|
||||
original_sleep = scheduler_module.asyncio.sleep
|
||||
scheduler_module.asyncio.sleep = fast_sleep
|
||||
try:
|
||||
try:
|
||||
await scheduler._close_and_draw(round_id)
|
||||
raise AssertionError("the injected failure should have escaped")
|
||||
except RuntimeError:
|
||||
pass
|
||||
before = await _draw_state(session_factory, round_id)
|
||||
assert before["status"] == "drawing" and before["winner"] is None
|
||||
|
||||
listener.failing = False
|
||||
await scheduler._tick() # the run() loop's next step
|
||||
finally:
|
||||
scheduler_module.asyncio.sleep = original_sleep
|
||||
|
||||
after = await _draw_state(session_factory, round_id)
|
||||
assert after["status"] == "paying_out" and after["winner"] is not None
|
||||
for fixed in ("draw_height", "participants_hash", "positions", "seed_hash"):
|
||||
assert after[fixed] == before[fixed]
|
||||
proof = (await ac.get(f"/rounds/{round_id}/proof")).json()
|
||||
_, address = _independent_winner(proof)
|
||||
assert proof["winner_address"] == address
|
||||
|
||||
|
||||
async def test_a_restarted_scheduler_resumes_a_drawing_round_and_a_second_tick_draws_nothing(client):
|
||||
"""B-81 (RND-36, OPS-09): a brand-new scheduler (the process restarted) picks the round up
|
||||
from the database alone. Once it is `paying_out` a further tick neither draws again nor
|
||||
starts a second payout trigger from the draw path, and the winner does not change."""
|
||||
ac, session_factory = client
|
||||
round_id = await _open_round_with_bets(session_factory, n=4)
|
||||
|
||||
entry_listener = _DrawListener({})
|
||||
entry_listener.tip_height = 500
|
||||
entry = RoundScheduler(session_factory, entry_listener)
|
||||
|
||||
async def stop(_round_id: int) -> None:
|
||||
return None
|
||||
|
||||
entry._complete_draw = stop # the process "dies" right after the transition
|
||||
await entry._close_and_draw(round_id)
|
||||
before = await _draw_state(session_factory, round_id)
|
||||
assert before["status"] == "drawing"
|
||||
|
||||
listener = _DrawListener({501: _BLOCK_HASH})
|
||||
restarted = RoundScheduler(session_factory, listener)
|
||||
payouts: list[int] = []
|
||||
|
||||
async def record_payout(rid: int) -> None:
|
||||
payouts.append(rid)
|
||||
|
||||
restarted._trigger_payout = record_payout
|
||||
retries: list[int] = []
|
||||
|
||||
async def record_retry(rid: int) -> None:
|
||||
retries.append(rid)
|
||||
|
||||
restarted._retry_payout_if_due = record_retry
|
||||
await restarted._tick()
|
||||
after_first = await _draw_state(session_factory, round_id)
|
||||
assert after_first["status"] == "paying_out"
|
||||
assert after_first["draw_height"] == before["draw_height"] == 501
|
||||
assert after_first["participants_hash"] == before["participants_hash"]
|
||||
assert after_first["positions"] == before["positions"]
|
||||
assert payouts == [round_id]
|
||||
asked_after_first = list(listener.asked)
|
||||
|
||||
await restarted._tick() # paying_out now: handled by the payout retry, not by a new draw
|
||||
assert listener.asked == asked_after_first
|
||||
assert payouts == [round_id] and retries == [round_id]
|
||||
assert (await _draw_state(session_factory, round_id))["winner"] == after_first["winner"]
|
||||
|
||||
|
||||
async def test_resuming_after_a_failure_inside_the_draw_transaction_leaves_no_half_state(client, monkeypatch):
|
||||
"""B-81 (RND-36): the exception comes from the very step that stores the winner. The
|
||||
transaction rolls back as a whole — no winner, no revealed secret, still `drawing` — and
|
||||
the next tick completes it."""
|
||||
ac, session_factory = client
|
||||
round_id = await _open_round_with_bets(session_factory, n=3)
|
||||
listener = _DrawListener({501: _BLOCK_HASH})
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
scheduler._trigger_payout = _noop_payout
|
||||
|
||||
entry_listener = _DrawListener({})
|
||||
entry_listener.tip_height = 500
|
||||
entry = RoundScheduler(session_factory, entry_listener)
|
||||
|
||||
async def stop(_round_id: int) -> None:
|
||||
return None
|
||||
|
||||
entry._complete_draw = stop
|
||||
await entry._close_and_draw(round_id)
|
||||
|
||||
import app.rounds.scheduler as scheduler_module
|
||||
|
||||
real_audit = scheduler_module.write_audit_log
|
||||
|
||||
async def failing_audit(session, event_type, *args, **kwargs):
|
||||
if event_type == "winner_drawn":
|
||||
raise RuntimeError("database is locked")
|
||||
return await real_audit(session, event_type, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "write_audit_log", failing_audit)
|
||||
try:
|
||||
await scheduler._tick()
|
||||
raise AssertionError("the injected failure should have escaped")
|
||||
except RuntimeError:
|
||||
pass
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
assert (round_.status, round_.winner_user_id, round_.server_seed) == ("drawing", None, None)
|
||||
|
||||
monkeypatch.setattr(scheduler_module, "write_audit_log", real_audit)
|
||||
await scheduler._tick()
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
assert round_.status == "paying_out" and round_.winner_user_id is not None and round_.server_seed
|
||||
Reference in new issue
Block a user