From 8b182ac8b62ac21989f550a1b35de994468f50f5 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 16 Oct 2026 15:12:00 +0200 Subject: [PATCH] Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16) Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto the round when it opened. The cost of a bet and the fee address were read live, so an edit from /admin in the middle of a round gave the players of that round different stakes for the same chance and redirected the commission of the round in progress. - `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column, existing rounds backfilled with the current configuration, i.e. what they were using; the expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the round's fee address (an empty one on the row still stops the payout, as before), `GET /rounds/current` advertises the round's cost while one is active and the configured one otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10). - `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included, has reached the round's cap, before anything moves; the pool can exceed it by one bet at most. A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`. - PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the bet cost the round in progress opened with, which can be lower than the configured one. - `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the seven translations are generic. Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and "applies immediately, also to the round in progress" in the round_config table of modello-dati; the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line). Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and the old "fee address missing" payout test now empties the round's address (clearing it in the configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open, edits reaching only the next round, every bet of a round paying the same stake whether the cost is raised or lowered meanwhile, the payout following the round's address (and surviving it being cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap, a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route, the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the migration backfill. Mutation-checked: charging the live cost, paying the live address, not copying the cap and `>` instead of `>=` each fail tests. Co-Authored-By: Claude Sonnet 5.5 --- BUGS.md | 10 - CLAUDE.md | 10 +- README.md | 2 +- app/api/routes/admin.py | 31 ++- app/api/routes/rounds.py | 2 +- app/bets/service.py | 22 +- app/db/models.py | 11 + app/rounds/scheduler.py | 2 +- app/rounds/service.py | 5 + app/static/admin.html | 2 + app/static/admin.js | 2 + app/static/i18n.js | 14 +- ...arametri-round-fotografati-all-apertura.md | 8 +- ...tto-prezzo-fisso-scommessa-utente-round.md | 3 +- docs/api.md | 26 +- docs/architettura.md | 1 - docs/audit-log.md | 2 - docs/ciclo-di-vita-del-round.md | 17 +- docs/guida-admin.md | 5 +- docs/limiti-noti.md | 17 +- docs/modello-dati.md | 56 ++-- docs/scommesse-e-prelievi.md | 7 +- docs/storico-bug.md | 6 +- .../a7d3e9f15b48_snapshot_round_parameters.py | 53 ++++ tests/unit/test_admin.py | 48 ++++ tests/unit/test_round_snapshot.py | 249 ++++++++++++++++++ tests/unit/test_rounds_route.py | 24 ++ tests/unit/test_scheduler.py | 6 +- 28 files changed, 520 insertions(+), 121 deletions(-) create mode 100644 migrations/versions/a7d3e9f15b48_snapshot_round_parameters.py create mode 100644 tests/unit/test_round_snapshot.py diff --git a/BUGS.md b/BUGS.md index 453c035..5d5f287 100644 --- a/BUGS.md +++ b/BUGS.md @@ -169,16 +169,6 @@ Fix: throttle and audit (`password_changed`) the password change, cap every credential field at 256, rate-limit bug reports per IP, paginate the user list, structured errors everywhere, `Cache-Control: no-cache` on static files. -### B-95 — Round parameters read live instead of snapshotted - -Only duration and cooldown are snapshotted at open; bet amount, `fee_address` and the -new `draw_confirmations` are read live, so an edit mid-round changes the round in -progress (e.g. redirects its commission). - -Fix: snapshot `bet_amount_sats`, `fee_address` and `draw_confirmations` on the round -at open. Optional per-round cap `max_round_pool_sats` (round_config, 0 = disabled): -once reached, further bets get `round_full`. - ### B-96 — Smaller state inconsistencies A reduced full-balance withdrawal is not re-checked against the minimum; an abandoned diff --git a/CLAUDE.md b/CLAUDE.md index 70b7bc4..1ee18aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin ## Project status -All 10 stages of the original build order are code-complete and unit-tested — 681 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 — 697 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 (at the time, 1-conf; deposits now need `deposit_confirmations`), 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 681 tests +python -m pytest # all 697 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 ``` @@ -98,11 +98,13 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW | Parameter | Value | Where | |---|---|---| -| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable | +| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable, **snapshotted onto `Round.bet_amount_sats` when a round opens** (B-95) — an edit applies from the next round | | Prize split | **70% winner / 30% platform**, rounding remainder to the platform; the platform's share pays the **bump reserve** (`max(294, vsize(inputs, 3 outputs) × 10)` sat, B-79) to the reserve address `1/0` and the rest goes to `fee_address` | **hardcoded** in `rounds/service.py:winner_share` / `wallet/psbt_builder.py:payout_bump_reserve` — 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) | +| Commission address | `fee_address` | `RoundConfig`, admin-editable, **snapshotted onto `Round.fee_address` at open** (B-95): the payout pays the round's own, never the live one | +| Pool cap | `max_round_pool_sats`, 0 = none (otherwise ≥ the bet cost, ≤ 4·10^15) | `RoundConfig`, **snapshotted onto `Round.max_round_pool_sats` at open** (B-95); `place_bet` refuses with `round_full` (params `max_round_pool_sats`) once the sum of the round's participant rows has *reached* it, in-flight bets included — the pool can end up above the cap by at most one bet | | Fee rate / RBF timeout | 1 sat/vB / 900s | `RoundConfig` | | Min withdrawal | = current `bet_amount_sats` (no separate field) | `withdrawals/service.py` | | Min deposit | none | — | @@ -118,7 +120,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW **Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner, with `paused_reason` (B-78, DAT-69): `operator`, or an automatic reason the system wrote itself (`resurrected_tx`, `reorg`, `payout_blocked`, `rounds/pause.py`) — an operator pause never overwrites an automatic one, and resume clears whichever it is. -**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time. Since B-79 (RND-40) that includes the amounts: `payout_config_problem(bet_amount_sats, fee_rate_sat_vb)` checks a round of 1 and of `MAX_PARTICIPANTS_PER_ROUND` participants against the winner's network fee, the bump reserve and dust outputs, and `PUT /admin/config` refuses a bet amount or fee rate that fails it with `422 config_not_payable` (the payout reads `fee_rate_sat_vb` live, so raising it mid-round is exactly what could otherwise strand the round in progress). +**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time. Since B-79 (RND-40) that includes the amounts: `payout_config_problem(bet_amount_sats, fee_rate_sat_vb)` checks a round of 1 and of `MAX_PARTICIPANTS_PER_ROUND` participants against the winner's network fee, the bump reserve and dust outputs, and `PUT /admin/config` refuses a bet amount or fee rate that fails it with `422 config_not_payable` (judged on the resulting values and, for a fee-rate change, on the in-progress round's own snapshotted cost as well; B-95 adds `422 max_round_pool_below_bet_amount` for a cap below the cost of one bet) (the payout reads `fee_rate_sat_vb` live, so raising it mid-round is exactly what could otherwise strand the round in progress). ## Code map diff --git a/README.md b/README.md index e125206..85a8fb8 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ python -m pytest # all tests python -m pytest tests/unit/test_hd.py # one file ``` -681 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +697 unit tests cover HD derivation, PSBT building, the Electrum client, bets, deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config, the pending-inclusive balance calculation, and the SSE push channel. No automated integration tests against a live Electrum connection — mainnet diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 7f2ceb8..fc15748 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -15,7 +15,7 @@ from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User from app.db.session import get_session from app.rounds.config import get_round_config from app.rounds.pause import AUTOMATIC_REASONS, OPERATOR_REASON -from app.rounds.service import payout_config_problem +from app.rounds.service import get_active_round, payout_config_problem from app.wallet.address import is_valid_plm_address from app.wallet.hd import derive_user_wif from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB @@ -49,6 +49,7 @@ _CONFIG_FIELDS = ( "draw_animation_seconds", "deposit_confirmations", # B-82 (DAT-62) "draw_confirmations", # B-73 (DAT-62) + "max_round_pool_sats", # B-95 (DAT-62) ) @@ -62,6 +63,7 @@ class RoundConfigResponse(BaseModel): draw_animation_seconds: int deposit_confirmations: int # B-82 (DAT-62) draw_confirmations: int # B-73 (DAT-62) + max_round_pool_sats: int # B-95 (DAT-62) paused: bool paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused @@ -85,6 +87,7 @@ class RoundConfigUpdate(BaseModel): draw_animation_seconds: int | None = Field(default=None, ge=0, le=600) deposit_confirmations: int | None = Field(default=None, ge=1, le=100) # B-82 (DAT-62) draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62) + max_round_pool_sats: int | None = Field(default=None, ge=0, le=4_000_000_000_000_000) # B-95 (DAT-62) @field_validator("fee_address") @classmethod @@ -137,7 +140,16 @@ async def update_config( # refused before anything is written — the payout reads fee_rate_sat_vb at build time, so # a rate raised mid-round could otherwise make the round in progress unpayable. if "bet_amount_sats" in changes or "fee_rate_sat_vb" in changes: - problem = payout_config_problem(config.bet_amount_sats, config.fee_rate_sat_vb) + amounts = {config.bet_amount_sats} + if "fee_rate_sat_vb" in changes: + # B-95: the round in progress is paid with the bet amount it opened with, which may + # differ from the one configured now — a higher fee rate has to leave *it* payable too. + active = await get_active_round(session) + if active is not None: + amounts.add(active.bet_amount_sats) + problem = next( + (p for p in (payout_config_problem(a, config.fee_rate_sat_vb) for a in sorted(amounts)) if p), None + ) if problem is not None: await session.rollback() raise http_error( @@ -148,6 +160,21 @@ async def update_config( reason=problem, ) + # B-95 (DAT-62): a cap below the price of one bet would refuse every bet of every round. + if ( + ("max_round_pool_sats" in changes or "bet_amount_sats" in changes) + and config.max_round_pool_sats + and config.max_round_pool_sats < config.bet_amount_sats + ): + offending = {"max_round_pool_sats": config.max_round_pool_sats, "bet_amount_sats": config.bet_amount_sats} + await session.rollback() + raise http_error( + 422, + "max_round_pool_below_bet_amount", + "max_round_pool_sats must be 0 (no cap) or at least the bet amount", + **offending, + ) + if changes: await write_audit_log(session, "config_updated", changes) await session.commit() diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index ed991ca..482070d 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -222,7 +222,7 @@ async def current_round( opened_at=opened_at.isoformat(), closes_at=closes_at.isoformat(), participant_count=participant_count, - bet_amount_sats=config.bet_amount_sats, + bet_amount_sats=round_.bet_amount_sats, # B-95 (API-21): the round's own, fixed at open jackpot_sats=jackpot_sats, pending_participant_count=all_count, pending_jackpot_sats=winner_share(all_pool_sats), diff --git a/app/bets/service.py b/app/bets/service.py index a5c3427..7343781 100644 --- a/app/bets/service.py +++ b/app/bets/service.py @@ -93,12 +93,32 @@ async def place_bet( max_participants=MAX_PARTICIPANTS_PER_ROUND, ) + # B-95 (BET-20, DAT-27): the round's own cap on the pool, when it has one. The sum counts + # every participant row (a bet still in flight may yet confirm — the conservative side), and + # a bet is refused once the sum has *reached* the cap, since its own net amount is not known + # before it is built: the pool can therefore end up above the cap by at most one bet. + if round_.max_round_pool_sats: + pool_so_far = await session.scalar( + select(func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0)).where( + RoundParticipant.round_id == round_.id + ) + ) + if pool_so_far >= round_.max_round_pool_sats: + raise BetError( + "round_full", + "this round has reached its limit, wait for the next one", + max_participants=MAX_PARTICIPANTS_PER_ROUND, + max_round_pool_sats=round_.max_round_pool_sats, + ) + if not round_.pool_address: # B-80: open_new_round_if_needed always writes it; a row without one cannot # take money that would then have nowhere verifiable to go. raise RuntimeError(f"round {round_.id} has no pool address") - bet_amount = config.bet_amount_sats + # B-95 (BET-21): the stake of the round — what every player of it pays — not the value in + # round_config now, which an operator may have edited since the round opened. + bet_amount = round_.bet_amount_sats unspent = ( await session.scalars( diff --git a/app/db/models.py b/app/db/models.py index 1e5b872..5cffa7b 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -128,6 +128,14 @@ class Round(Base): # 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") + # B-95 (DAT-61, RND-05): the rest of what governs this round, copied from round_config in the + # transaction that opens it and never changed — the cost of a bet, where the commission goes, + # and the cap on the pool (0 = none). Read live, a mid-round edit gave players of the same + # round different stakes and redirected a round's commission. The fee *rate* stays live: it + # belongs to a transaction, not to the rules of the round. + bet_amount_sats: Mapped[int] = mapped_column(BigInteger, default=1_000_000_000, server_default="1000000000") + fee_address: Mapped[str] = mapped_column(String(128), default="", server_default="") + max_round_pool_sats: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") 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) @@ -190,6 +198,9 @@ class RoundConfig(Base): # B-82 (DAT-62, DEP-03): confirmations after which an output paying a user is credited # as a deposit (platform transactions keep 1, coinbase outputs 120). deposit_confirmations: Mapped[int] = mapped_column(default=6, server_default="6") + # B-95 (DAT-27, DAT-62): cap on the sum of a round's bets, 0 = no cap; copied onto each round + # when it opens. Past it a bet is refused with `round_full`. + max_round_pool_sats: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") fee_rate_sat_vb: Mapped[int] = mapped_column(default=1) rbf_timeout_seconds: Mapped[int] = mapped_column(default=900) # Maintenance switch: when true, the round currently in progress still runs to diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index 80681ee..ff17821 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -505,7 +505,7 @@ class RoundScheduler: return reserved_outpoints = await _reserved_payout_outpoints(session) config = await get_round_config(session) - fee_address = config.fee_address + fee_address = round_.fee_address # B-95 (DAT-61): the round's own, fixed when it opened fee_rate = config.fee_rate_sat_vb pool_amount_sats = round_.pool_amount_sats round_pool_address = round_.pool_address # B-80 ("" fails in derive_round_pool_key below) diff --git a/app/rounds/service.py b/app/rounds/service.py index 16fdd53..08b9e34 100644 --- a/app/rounds/service.py +++ b/app/rounds/service.py @@ -174,6 +174,11 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None: duration_seconds=config.round_duration_seconds, cooldown_seconds=config.round_cooldown_seconds, draw_confirmations=config.draw_confirmations, # B-73 (DAT-61) + # B-95 (DAT-61, RND-05): everything else that governs the round, fixed now — a later + # edit from /admin applies to the next round, never to this one. + bet_amount_sats=config.bet_amount_sats, + fee_address=config.fee_address.strip(), + max_round_pool_sats=config.max_round_pool_sats, # B-73 (RND-34, DAT-64): the commitment, then the secret itself encrypted — the # plaintext column stays NULL until the reveal. **_new_round_secret(), diff --git a/app/static/admin.html b/app/static/admin.html index aa6979d..406c78d 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -87,6 +87,8 @@ + + diff --git a/app/static/admin.js b/app/static/admin.js index 54c7bb7..9182da2 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -152,6 +152,7 @@ async function adminLoadConfig() { document.getElementById('admin-draw-animation').value = data.draw_animation_seconds; document.getElementById('admin-draw-confirmations').value = data.draw_confirmations; document.getElementById('admin-deposit-confirmations').value = data.deposit_confirmations; + document.getElementById('admin-max-pool').value = data.max_round_pool_sats / SATS_PER_PLM; document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb; document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds; renderMaintenanceState(data.paused); @@ -213,6 +214,7 @@ async function adminSave() { draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10), draw_confirmations: parseInt(document.getElementById('admin-draw-confirmations').value, 10), deposit_confirmations: parseInt(document.getElementById('admin-deposit-confirmations').value, 10), + max_round_pool_sats: Math.round(parseFloat(document.getElementById('admin-max-pool').value || '0') * SATS_PER_PLM), fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10), rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10), }; diff --git a/app/static/i18n.js b/app/static/i18n.js index 30c37c4..d3d0ecf 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -142,7 +142,7 @@ const TRANSLATIONS = { 'error.network_unavailable': 'Not connected to the network, please try again shortly.', 'error.no_round_open': 'No round is open right now, please try again shortly.', 'error.round_closing': 'The current round is closing, please try again shortly.', - 'error.round_full': 'This round has reached its maximum of {max_participants} players — wait for the next one, it opens shortly.', + 'error.round_full': 'This round is full (player or prize-pool limit reached) — wait for the next one, it opens shortly.', 'error.already_betting': 'You already have an active bet in the current round.', 'error.insufficient_balance': 'Insufficient balance.', 'error.balance_leaves_no_change': 'Your balance is too close to the bet amount: {required_extra_plm} PLM more are needed so the transaction keeps a change output and can be fee-bumped if the network is slow.', @@ -310,7 +310,7 @@ const TRANSLATIONS = { 'error.network_unavailable': 'Nessuna connessione alla rete, riprova tra poco.', 'error.no_round_open': 'Nessun round aperto in questo momento, riprova tra poco.', 'error.round_closing': 'Il round corrente si sta chiudendo, riprova tra poco.', - 'error.round_full': 'Questo round ha raggiunto il massimo di {max_participants} giocatori — aspetta il prossimo, si apre tra poco.', + 'error.round_full': 'Questo round è al completo (limite di giocatori o di montepremi raggiunto) — aspetta il prossimo, si apre tra poco.', 'error.already_betting': 'Hai già una bet attiva nel round corrente.', 'error.insufficient_balance': 'Saldo insufficiente.', 'error.balance_leaves_no_change': 'Il tuo saldo è troppo vicino all\'importo della giocata: servono {required_extra_plm} PLM in più perché la transazione mantenga un resto e possa essere rilanciata con fee più alta se la rete è lenta.', @@ -478,7 +478,7 @@ const TRANSLATIONS = { 'error.network_unavailable': 'Sin conexión con la red, inténtalo de nuevo en un momento.', 'error.no_round_open': 'No hay ninguna ronda abierta ahora mismo, inténtalo de nuevo en un momento.', 'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.', - 'error.round_full': 'Esta ronda ha alcanzado su máximo de {max_participants} jugadores: espera la siguiente, se abre en breve.', + 'error.round_full': 'Esta ronda está completa (límite de jugadores o de bote alcanzado): espera la siguiente, se abre en breve.', 'error.already_betting': 'Ya tienes una apuesta activa en la ronda actual.', 'error.insufficient_balance': 'Saldo insuficiente.', 'error.balance_leaves_no_change': 'Tu saldo está demasiado cerca del importe de la apuesta: hacen falta {required_extra_plm} PLM más para que la transacción conserve un cambio y pueda relanzarse con más comisión si la red va lenta.', @@ -646,7 +646,7 @@ const TRANSLATIONS = { 'error.network_unavailable': 'Pas de connexion au réseau, réessayez dans un instant.', 'error.no_round_open': "Aucun round n'est ouvert pour le moment, réessayez dans un instant.", 'error.round_closing': 'Le round en cours est en train de se fermer, réessayez dans un instant.', - 'error.round_full': 'Ce tour a atteint son maximum de {max_participants} joueurs — attendez le prochain, il ouvre dans un instant.', + 'error.round_full': 'Ce tour est complet (limite de joueurs ou de cagnotte atteinte) — attendez le prochain, il ouvre dans un instant.', 'error.already_betting': 'Vous avez déjà une mise active dans le round en cours.', 'error.insufficient_balance': 'Solde insuffisant.', 'error.balance_leaves_no_change': 'Votre solde est trop proche du montant de la mise : il faut {required_extra_plm} PLM de plus pour que la transaction garde une monnaie de rendu et puisse être relancée avec des frais plus élevés si le réseau est lent.', @@ -814,7 +814,7 @@ const TRANSLATIONS = { 'error.network_unavailable': 'Keine Verbindung zum Netzwerk, bitte versuche es gleich erneut.', 'error.no_round_open': 'Derzeit ist keine Runde offen, bitte versuche es gleich erneut.', 'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.', - 'error.round_full': 'Diese Runde hat ihr Maximum von {max_participants} Spielern erreicht — warten Sie auf die nächste, sie beginnt in Kürze.', + 'error.round_full': 'Diese Runde ist voll (Spieler- oder Preispool-Limit erreicht) — warten Sie auf die nächste, sie beginnt in Kürze.', 'error.already_betting': 'Du hast bereits eine aktive Wette in der laufenden Runde.', 'error.insufficient_balance': 'Nicht genügend Guthaben.', 'error.balance_leaves_no_change': 'Ihr Guthaben liegt zu nah am Einsatzbetrag: Es werden {required_extra_plm} PLM mehr benötigt, damit die Transaktion einen Wechselgeld-Ausgang behält und bei langsamem Netz mit höherer Gebühr neu gesendet werden kann.', @@ -982,7 +982,7 @@ const TRANSLATIONS = { 'error.network_unavailable': 'Нет соединения с сетью, повторите попытку чуть позже.', 'error.no_round_open': 'Сейчас нет открытого раунда, повторите попытку чуть позже.', 'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.', - 'error.round_full': 'В этом раунде достигнут максимум участников ({max_participants}) — дождитесь следующего, он начнётся совсем скоро.', + 'error.round_full': 'Этот раунд заполнен (достигнут лимит игроков или призового фонда) — дождитесь следующего, он начнётся совсем скоро.', 'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.', 'error.insufficient_balance': 'Недостаточно средств.', 'error.balance_leaves_no_change': 'Ваш баланс слишком близок к сумме ставки: нужно ещё {required_extra_plm} PLM, чтобы в транзакции остался выход сдачи и её можно было переотправить с более высокой комиссией, если сеть работает медленно.', @@ -1150,7 +1150,7 @@ const TRANSLATIONS = { 'error.network_unavailable': '未连接到网络,请稍后重试。', 'error.no_round_open': '当前没有开放的回合,请稍后重试。', 'error.round_closing': '当前回合正在结束,请稍后重试。', - 'error.round_full': '本轮已达到 {max_participants} 名玩家的上限 —— 请等待下一轮,很快就会开始。', + 'error.round_full': '本轮已满(达到玩家人数或奖池上限)—— 请等待下一轮,很快就会开始。', 'error.already_betting': '你在当前回合已有一笔有效下注。', 'error.insufficient_balance': '余额不足。', 'error.balance_leaves_no_change': '您的余额与投注金额过于接近:还需要 {required_extra_plm} PLM,交易才能保留找零输出,并在网络拥堵时提高手续费重新广播。', diff --git a/docs/adr/ADR-10-parametri-round-fotografati-all-apertura.md b/docs/adr/ADR-10-parametri-round-fotografati-all-apertura.md index bac1d77..4795901 100644 --- a/docs/adr/ADR-10-parametri-round-fotografati-all-apertura.md +++ b/docs/adr/ADR-10-parametri-round-fotografati-all-apertura.md @@ -1,6 +1,6 @@ # ADR-10 — I parametri del round sono fotografati all'apertura -- **Stato:** Accettata — implementazione da completare (B-95) +- **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*. @@ -8,7 +8,7 @@ **I parametri del round sono fotografati all'apertura.** Durata, pausa successiva, costo della scommessa (`bet_amount_sats`), -`fee_address` e `draw_confirmations` vengono copiati sulla riga del round +`fee_address`, `draw_confirmations` e tetto al montepremi (`max_round_pool_sats`) vengono copiati sulla riga del round quando si apre; una modifica vale dal round successivo, mai per quello in corso. La pausa fra due round è quella annunciata dal round appena chiuso. Il fee rate resta letto dal vivo: riguarda la singola transazione, non le @@ -22,6 +22,4 @@ regole del round. [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#0--apertura) (B-95). *Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:round_deadline` · -*Test:* `tests/unit/test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing`, `tests/unit/test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`, `tests/unit/test_rounds_service.py::test_cooldown_comes_from_the_round_that_closed` -*Stato:* da implementare (B-95) · *Oggi:* sono fotografati solo durata e -pausa; costo, `fee_address` e fee rate sono letti dal vivo ([LIM-16]). +*Test:* `tests/unit/test_round_snapshot.py::test_a_round_copies_cost_fee_address_and_cap_when_it_opens`, `tests/unit/test_round_snapshot.py::test_an_edit_from_admin_never_reaches_the_round_in_progress_but_the_next_one`, `tests/unit/test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing`, `tests/unit/test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`, `tests/unit/test_rounds_service.py::test_cooldown_comes_from_the_round_that_closed` diff --git a/docs/adr/ADR-15-biglietto-prezzo-fisso-scommessa-utente-round.md b/docs/adr/ADR-15-biglietto-prezzo-fisso-scommessa-utente-round.md index f3f875b..0a4db88 100644 --- a/docs/adr/ADR-15-biglietto-prezzo-fisso-scommessa-utente-round.md +++ b/docs/adr/ADR-15-biglietto-prezzo-fisso-scommessa-utente-round.md @@ -11,8 +11,7 @@ probabilità uguale per tutti** indipendentemente dall'importo effettivo. Nessun deposito minimo. - *Conseguenze:* l'estrazione sceglie fra partecipanti, non pesa gli importi. Il costo è fotografato all'apertura del round ([ADR-10](ADR-10-parametri-round-fotografati-all-apertura.md)), così - tutti i partecipanti di un round pagano la stessa posta (B-95; oggi è letto - dal vivo, [LIM-16]). + tutti i partecipanti di un round pagano la stessa posta (B-95). *Codice:* `app/bets/service.py:place_bet` · `app/rounds/draw.py:draw_winner` · *Test:* `tests/unit/test_bets.py::test_place_bet_rejects_second_bet_same_round` diff --git a/docs/api.md b/docs/api.md index c8a82a3..3b86f4a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -467,10 +467,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. casi (successo, rifiuto, esito ambiguo) e all'apertura del round. *Codice:* `app/api/routes/bets.py:create_bet`, `app/bets/service.py:place_bet` · - *Test:* `tests/unit/test_bets.py` - *Stato:* da implementare (B-95, B-96) · *Oggi:* un costo coperto solo da resto non confermato - risponde `insufficient_balance`; non c'è tetto al montepremi; il costo è - quello configurato al momento della scommessa, non quello del round. + *Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py` + *Stato:* da implementare (B-96) · *Oggi:* un costo coperto solo da resto non + confermato risponde `insufficient_balance`. #### POST /withdrawals @@ -537,7 +536,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. | `opened_at` | stringa ISO o `null` | apertura del round | | `closes_at` | stringa ISO o `null` | scadenza per le scommesse: `opened_at` + la durata **fissata all'apertura del round** (non quella attuale di configurazione) | | `participant_count` | intero | partecipanti **confermati**, cioè quelli tra cui si estrae | - | `bet_amount_sats` | intero | costo che pagherebbe una nuova scommessa: con un round attivo il costo **fissato all'apertura del round** (`rounds.bet_amount_sats`), senza round attivo quello configurato — *da implementare (B-95); oggi sempre quello configurato* | + | `bet_amount_sats` | intero | costo che pagherebbe una nuova scommessa: con un round attivo il costo **fissato all'apertura del round** (`rounds.bet_amount_sats`), senza round attivo quello configurato | | `jackpot_sats` | intero | 70% (arrotondato per difetto) della somma degli importi effettivi delle scommesse confermate; limite superiore, perché la commissione del pagamento è ancora da sottrarre | | `pending_participant_count` | intero | tutti i partecipanti, confermati **e** in volo (totale, non differenza) | | `pending_jackpot_sats` | intero | 70% della somma di tutte le scommesse, confermate e in volo | @@ -577,8 +576,6 @@ 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-95), per i campi marcati · *Oggi:* - `bet_amount_sats` è sempre il costo configurato. #### GET /rounds/stream @@ -760,7 +757,6 @@ 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-95) · *Oggi:* manca `max_round_pool_sats`. #### PUT /admin/config @@ -779,7 +775,7 @@ dei parametri operativi in | `draw_animation_seconds` | 0 – 600 | | `deposit_confirmations` | 1 – 100 | | `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)* | + | `max_round_pool_sats` | `0` (disattivato), oppure da `bet_amount_sats` a 4 000 000 000 000 000 | I limiti sono quelli di [modello-dati.md](modello-dati.md) (tabella `round_config`), che ne è il proprietario. `paused` e `paused_reason` @@ -793,18 +789,18 @@ dei parametri operativi in dà `422` con `detail = {"code": "config_not_payable", "message", "params": {"reason"}}`, dove `reason` è `winner_share_below_network_fee` o `commission_below_dust_limit`; il controllo usa i valori risultanti dopo - la modifica. + la modifica e, se cambia `fee_rate_sat_vb`, anche il costo con cui si è + aperto il round in corso. Un tetto `max_round_pool_sats` diverso da 0 e + minore del costo della scommessa (dopo la modifica) dà `422` con + `detail.code = "max_round_pool_below_bet_amount"`. - **Effetti:** scrive `config_updated` con i valori prima/dopo dei soli campi davvero cambiati; nessuna scrittura se nulla cambia. Durata, pausa tra round, costo della scommessa, `fee_address` e `draw_confirmations` si - applicano dal round successivo (per costo e `fee_address`: *da - implementare*, B-95; oggi subito). Nessuna notifica SSE. + applicano dal round successivo, come il tetto `max_round_pool_sats`. Nessuna + notifica SSE. *Codice:* `app/api/routes/admin.py:update_config`, `app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py` - *Stato:* da implementare (B-95), per `max_round_pool_sats` e la copia - all'apertura di costo ed `fee_address` · *Oggi:* sono accettati i primi - nove campi. #### POST /admin/pause e POST /admin/resume diff --git a/docs/architettura.md b/docs/architettura.md index d887513..34163dc 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -616,7 +616,6 @@ 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-95) · *Oggi:* `max_round_pool_sats` non esiste. - **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili: la ripartizione 70/30, le conferme richieste per le transazioni della piattaforma (1) e per le uscite coinbase (120), i limiti di input e di diff --git a/docs/audit-log.md b/docs/audit-log.md index cd35d9f..91a3114 100644 --- a/docs/audit-log.md +++ b/docs/audit-log.md @@ -187,8 +187,6 @@ 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-95), per `max_round_pool_sats` · - *Oggi:* i campi sono i primi nove. - **[AUD-11]** `lottery_paused` — la lotteria è stata messa in pausa, da chiunque. Payload `{"reason"}`, più `"note"` quando l'operatore l'ha diff --git a/docs/ciclo-di-vita-del-round.md b/docs/ciclo-di-vita-del-round.md index 04cd2b6..1b026f2 100644 --- a/docs/ciclo-di-vita-del-round.md +++ b/docs/ciclo-di-vita-del-round.md @@ -105,10 +105,7 @@ richiesta di scommessa (vedi Il round usa sempre i valori fotografati: una modifica da `/admin` vale dal round successivo, mai per quello in corso. *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:round_deadline` · - *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py` (solo durata e pausa) - *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. + *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_scheduler.py` (durata e pausa), `tests/unit/test_round_snapshot.py` - **[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 @@ -119,12 +116,9 @@ richiesta di scommessa (vedi `lottery_configured`) perché `/` mostri avvisi diversi. Le procedure sono in [guida-admin.md](guida-admin.md). *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/api/routes/rounds.py:current_round`, `app/api/routes/admin.py:RoundConfigUpdate` · - *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_rounds_route.py` - *Stato:* da implementare (B-95) · *Oggi:* il pagamento legge `fee_address` - dalla configurazione a ogni tentativo: una modifica durante il round - sposta la commissione del round in corso, e un `fee_address` vuoto (solo - modificando il database a mano) blocca il pagamento con - `no fee_address configured`. + *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_rounds_route.py`, `tests/unit/test_round_snapshot.py::test_the_payout_pays_the_fee_address_the_round_opened_with`, `tests/unit/test_round_snapshot.py::test_clearing_the_fee_address_mid_round_does_not_stop_its_payout` + Un `fee_address` vuoto sulla riga del round (solo modificando il database a + mano) blocca il pagamento con `no fee_address configured`. - **[RND-07]** L'apertura di un round invia una notifica SSE. *Codice:* `app/rounds/service.py:open_new_round_if_needed` - **[RND-34]** **Impegno sul segreto**: all'apertura il server genera un @@ -438,9 +432,6 @@ del montepremi del round. `commission_below_dust_limit`). *Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` · *Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share`, `tests/unit/test_payout_builder.py::test_payout_folds_excess_input_value_into_the_reserve_output`, `tests/unit/test_payout_builder.py::test_payout_leaves_a_dust_sized_excess_to_the_network_fee`, `tests/unit/test_payout_builder.py::test_payout_with_one_participant_at_the_smallest_viable_pool` - *Stato:* da implementare (B-95) · *Oggi:* `fee_address` è letto dalla - configurazione corrente al momento del pagamento, non fotografato - all'apertura. - **[RND-24]** **Prima si scrive, poi si invia**: prima dell'invio il round registra importo del vincitore (`winner_amount_sats`), commissioni (`fee_amount_sats`), riserva per l'RBF (`bump_reserve_sats`, [RND-38]) e diff --git a/docs/guida-admin.md b/docs/guida-admin.md index 9087834..2702cd6 100644 --- a/docs/guida-admin.md +++ b/docs/guida-admin.md @@ -47,9 +47,10 @@ business — quelli si toccano solo da qui. | Campo | Significato | |---|---| -| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: su un'istanza nuova è vuoto, e finché resta vuoto **non si apre nessun round** (il payout non sarebbe costruibile, quindi il round accetterebbe scommesse per poi restare bloccato con i soldi già nel montepremi). Il pannello lo segnala con un avviso in cima ai Parametri, e la pagina utente mostra un banner "lotteria non ancora pronta". Una volta impostato non si può più svuotare dal pannello: un valore vuoto non è un indirizzo `plm1...` valido e viene rifiutato. **Attenzione:** oggi il payout legge questo campo al momento in cui viene costruito, quindi cambiarlo mentre un round è in corso sposta anche la commissione di quel round sul nuovo indirizzo. | -| **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). | +| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee); una modifica vale dal round successivo, mai per quello in corso. Obbligatorio: su un'istanza nuova è vuoto, e finché resta vuoto **non si apre nessun round** (il payout non sarebbe costruibile, quindi il round accetterebbe scommesse per poi restare bloccato con i soldi già nel montepremi). Il pannello lo segnala con un avviso in cima ai Parametri, e la pagina utente mostra un banner "lotteria non ancora pronta". Una volta impostato non si può più svuotare dal pannello: un valore vuoto non è un indirizzo `plm1...` valido e viene rifiutato. **Attenzione:** oggi il payout legge questo campo al momento in cui viene costruito, quindi cambiarlo mentre un round è in corso sposta anche la commissione di quel round sul nuovo indirizzo. | +| **Bet amount (PLM)** | Il costo fisso d'ingresso per round, **dal round successivo**: il round in corso tiene il costo con cui si è aperto, così tutti i suoi giocatori pagano la stessa posta. È anche l'importo minimo prelevabile, e questo vale subito: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). Un valore con cui un round non si potrebbe pagare (commissione di rete, riserva e soglia di polvere non coperte) viene rifiutato. | | **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. | +| **Tetto al montepremi di un round (PLM)** | 0 = nessun tetto. Con un tetto, una scommessa è rifiutata (`round_full`) quando la somma delle scommesse del round lo ha raggiunto; il montepremi può quindi superarlo al massimo di una scommessa. Non può essere minore del costo della scommessa. Vale dal round successivo. | | **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 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 per accreditare un deposito** | Da 1 a 100 (predefinito 6): dopo quante conferme un deposito arrivato all'indirizzo di un utente viene accreditato e diventa spendibile. Un deposito è l'unico ingresso che un utente può spendere due volte con una catena privata, perciò il valore non va abbassato senza motivo. Vale subito per gli accrediti successivi; le uscite di mining (coinbase) ne richiedono comunque 120, e le transazioni costruite dalla piattaforma (resto di una bet, vincite) una sola. | diff --git a/docs/limiti-noti.md b/docs/limiti-noti.md index b68eaa4..8ce9b61 100644 --- a/docs/limiti-noti.md +++ b/docs/limiti-noti.md @@ -350,19 +350,10 @@ implementato il [piano delle correzioni](piano-correzioni.md). ### Configurazione ed economia -- **[LIM-16]** **Solo i tempi del round sono fotografati all'apertura.** Costo - della scommessa, fee rate e `fee_address` sono letti dal vivo. - - *Impatto:* cambiare il costo a metà round fa pagare poste diverse a - giocatori con la stessa probabilità, e cambia subito il prelievo minimo; - cambiare `fee_address` a metà round devia la commissione del round in - corso (la modifica è comunque registrata in audit). - - *Stato:* da chiudere con B-95. - - *Per chiuderlo:* fotografare costo, `fee_address` e `draw_confirmations` - sulla riga del round, come già si fa per durata e pausa ([ADR-10](adr/ADR-10-parametri-round-fotografati-all-apertura.md)). Il fee - rate resta letto dal vivo per scelta, e il prelievo minimo segue il costo - corrente della configurazione ([ADR-20](adr/ADR-20-prelievo-minimo-costo-corrente-scommessa.md)). - - *Codice:* `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/withdrawals/service.py:request_withdrawal` +- ~~[LIM-16]~~ Rimosso: chiuso da B-95. Costo della scommessa, `fee_address` e + tetto al montepremi sono fotografati sulla riga del round all'apertura, come + durata e pausa ([ADR-10](adr/ADR-10-parametri-round-fotografati-all-apertura.md)); il fee rate resta letto dal vivo per scelta e il prelievo + minimo segue il costo corrente della configurazione ([ADR-20](adr/ADR-20-prelievo-minimo-costo-corrente-scommessa.md)). - **[LIM-17]** **Nessun consolidamento automatico del montepremi.** Monete avanzate e resti si accumulano sugli indirizzi del montepremi (fino a B-80 diff --git a/docs/modello-dati.md b/docs/modello-dati.md index a95076a..849605c 100644 --- a/docs/modello-dati.md +++ b/docs/modello-dati.md @@ -98,9 +98,10 @@ erDiagram datetime closed_at int duration_seconds "snapshot" int cooldown_seconds "snapshot" - bigint bet_amount_sats "snapshot (da implementare)" - string fee_address "snapshot (da implementare)" - int draw_confirmations "snapshot (da implementare)" + bigint bet_amount_sats "snapshot" + string fee_address "snapshot" + bigint max_round_pool_sats "snapshot" + int draw_confirmations "snapshot" string pool_address string server_seed_enc "cifrato fino alla rivelazione (da implementare)" string server_seed "in chiaro solo dopo la rivelazione (da implementare)" @@ -140,9 +141,9 @@ erDiagram int draw_animation_seconds int fee_rate_sat_vb int rbf_timeout_seconds - int deposit_confirmations "da implementare" + int deposit_confirmations int draw_confirmations "da implementare" - bigint max_round_pool_sats "da implementare" + bigint max_round_pool_sats bool paused string paused_reason "da implementare" datetime updated_at @@ -426,8 +427,9 @@ restano nella tabella come storico. | `closed_at` | DateTime | sì | `NULL` | Chiusura delle scommesse, poi conferma del pagamento ([RND-28](ciclo-di-vita-del-round.md)) | `app/rounds/scheduler.py:RoundScheduler._tick`; `app/rounds/confirmation.py:_on_payout_confirmed` | | `duration_seconds` | Integer | no | `600` (anche `server_default`) | Durata di **questo** round, copiata da `round_config` all'apertura | `open_new_round_if_needed` | | `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` | +| `bet_amount_sats` | BigInteger | no | backfill da `round_config` nella migrazione | Costo della scommessa di **questo** round, copiato all'apertura ([DAT-61]) | `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]) | `open_new_round_if_needed` | +| `max_round_pool_sats` | BigInteger | no | `0` (`server_default`); backfill `0` | Tetto alla somma delle scommesse di **questo** round, copiato all'apertura ([DAT-61], [DAT-27]); `0` = nessun tetto | `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]) | `open_new_round_if_needed` | @@ -560,22 +562,20 @@ restano nella tabella come storico. tabella con righe; il codice lo riscrive prima del commit e ogni consumatore rifiuta un indirizzo vuoto. -- **[DAT-61]** `bet_amount_sats`, `fee_address` e `draw_confirmations` di - `rounds` sono una **copia** dei valori di `round_config` presa quando il +- **[DAT-61]** `bet_amount_sats`, `fee_address`, `max_round_pool_sats` e + `draw_confirmations` di `rounds` sono una **copia** dei valori di `round_config` presa quando il round si apre, nella stessa transazione dell'inserimento, e non cambiano più. Le scommesse del round costano `rounds.bet_amount_sats`, il suo pagamento paga la commissione a `rounds.fee_address`, la sua estrazione - attende `rounds.draw_confirmations` conferme. La regola di dominio è in + attende `rounds.draw_confirmations` conferme, le scommesse oltre il tetto + `rounds.max_round_pool_sats` sono rifiutate. La regola di dominio è in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-95). La migrazione riempie le righe esistenti con i valori attuali di `round_config` ([DAT-55]). *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:* `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. + `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · + *Test:* `tests/unit/test_round_snapshot.py`, `tests/unit/test_rounds_service.py`, `tests/unit/test_migrations.py` - **[DAT-64]** Il segreto del round sono 32 byte casuali generati con `secrets.token_bytes(32)`. All'inserimento del round si scrivono insieme @@ -748,21 +748,18 @@ Una scommessa: un utente in un round. moltiplicando il numero di partecipanti per il costo (B-11). *Codice:* `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py:build_signed_transaction` · - `app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_bets.py` - *Stato:* da implementare (B-95) · *Oggi:* il costo è - `round_config.bet_amount_sats` letto al momento della scommessa. + `app/api/routes/rounds.py:current_round` · *Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py` - **[DAT-27]** Il numero di righe di un round, contando tutti gli stati, non supera `MAX_PARTICIPANTS_PER_ROUND` (400): la 401ª scommessa è rifiutata con `round_full` prima che si muova denaro (B-52). Con - `round_config.max_round_pool_sats` diverso da 0 ([DAT-62]), una scommessa è - rifiutata con `round_full` anche quando la somma dei `bet_amount_sats` del - round, contando tutti gli stati, ha già raggiunto quel tetto; la regola di + `rounds.max_round_pool_sats` (copiato da `round_config` all'apertura, + [DAT-61], [DAT-62]) diverso da 0, una scommessa è rifiutata con `round_full` + anche quando la somma dei `bet_amount_sats` del round, contando tutti gli + stati, ha già raggiunto quel tetto; la regola di dominio è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95). *Codice:* `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py` · - *Test:* `tests/unit/test_bets.py` - *Stato:* da implementare (B-95), solo per il tetto sul montepremi · *Oggi:* - esiste solo il limite dei 400 partecipanti. + *Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py` ### `round_config` @@ -772,8 +769,8 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). | Colonna | Tipo | Default | Limiti accettati da `PUT /admin/config` | Significato | Quando ha effetto | |---|---|---|---|---|---| | `id` | Integer PK | autoincrement | — | — | — | -| `fee_address` | String(128), non null | nessun default nel modello; la riga nasce con `""` | indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Atteso: dal round **successivo**, copiato all'apertura ([DAT-61]). Oggi: subito, si legge a ogni costruzione del pagamento, quindi vale anche per il round in corso se il suo pagamento non è ancora stato costruito | -| `bet_amount_sats` | BigInteger | `1_000_000_000` (10 PLM) | `> 0`, `≤ 100 000 PLM` | Costo della scommessa; anche il prelievo minimo | Atteso: per le scommesse dal round **successivo** ([DAT-61]), per il prelievo minimo subito. Oggi: subito, per le scommesse e i prelievi successivi; le scommesse già piazzate tengono il proprio importo ([DAT-26]) | +| `fee_address` | String(128), non null | nessun default nel modello; la riga nasce con `""` | indirizzo PLM bech32 valido | Dove va il 30% di ogni round | Dal round **successivo**, copiato all'apertura ([DAT-61]); il round in corso paga la commissione all'indirizzo con cui si è aperto | +| `bet_amount_sats` | BigInteger | `1_000_000_000` (10 PLM) | `> 0`, `≤ 100 000 PLM` | Costo della scommessa; anche il prelievo minimo | Per le scommesse, dal round **successivo** ([DAT-61]); per il prelievo minimo, subito ([ADR-20](adr/ADR-20-prelievo-minimo-costo-corrente-scommessa.md)). Le scommesse già piazzate tengono il proprio importo ([DAT-26]) | | `round_duration_seconds` | Integer | `600` | 30 s – 7 giorni | Durata del round | Dal round **successivo** ([RND-05](ciclo-di-vita-del-round.md)) | | `round_cooldown_seconds` | Integer | `30` | 0 s – 24 h | Pausa dopo la chiusura | Dal round **successivo**: la pausa dopo il round in corso è quella copiata alla sua apertura ([RND-05](ciclo-di-vita-del-round.md)) | | `draw_animation_seconds` | Integer | `20` | 0 – 600 s | Durata minima dell'animazione dell'estrazione nel frontend; non condiziona l'estrazione vera | Subito (letto da `GET /rounds/current`) | @@ -781,7 +778,7 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). | `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]) | Subito, per gli accrediti successivi; gli output già accreditati restano | | `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 | +| `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]) | Dal round **successivo**, copiato all'apertura ([DAT-61]): un round tiene il tetto con cui si è aperto | | `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` | | `updated_at` | DateTime | `utcnow`, aggiornato a ogni modifica (`onupdate`) | — | Ultima modifica della riga | ORM | @@ -804,8 +801,7 @@ 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-95), per l'ultima colonna · - *Oggi:* le colonne modificabili sono le prime nove. + - **[DAT-30]** `paused` e `paused_reason` si modificano solo con `POST /admin/pause` e `POST /admin/resume`, che scrivono `lottery_paused` e @@ -827,8 +823,6 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). 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:* `tests/unit/test_admin.py`, `tests/unit/test_migrations.py` - *Stato:* da implementare (B-95) · *Oggi:* `max_round_pool_sats` non esiste: - non c'è tetto. - **[DAT-69]** `paused_reason` è `NULL` esattamente quando `paused` è falso; con `paused` vero vale uno di questi valori, e nessun altro: diff --git a/docs/scommesse-e-prelievi.md b/docs/scommesse-e-prelievi.md index 6e10186..d8e2668 100644 --- a/docs/scommesse-e-prelievi.md +++ b/docs/scommesse-e-prelievi.md @@ -53,7 +53,7 @@ cifra. Il **costo** è quello del round ([BET-21]): | **[BET-04]** | Il round è in stato `open` e la sua **scadenza** — apertura più la durata fotografata all'apertura (`opened_at + duration_seconds`), non lo stato nel database — non è ancora passata | `round_closing` | | **[BET-05]** | L'utente non ha già una scommessa in questo round (in qualunque stato) | `already_betting` | | **[BET-06]** | Il round ha meno di 400 partecipanti (`MAX_PARTICIPANTS_PER_ROUND`), contando anche quelli non ancora confermati | `round_full` (parametro `max_participants`) | -| **[BET-20]** | Se il round ha un tetto al montepremi (`max_round_pool_sats` fotografato all'apertura, [RND-05](ciclo-di-vita-del-round.md#0--apertura)) diverso da 0, la somma degli importi netti dei partecipanti del round, contando anche quelli non ancora confermati, è minore del tetto. *Stato:* da implementare (B-95) · *Oggi:* il tetto non esiste | `round_full` | +| **[BET-20]** | Se il round ha un tetto al montepremi (`max_round_pool_sats` fotografato all'apertura, [RND-05](ciclo-di-vita-del-round.md#0--apertura)) diverso da 0, la somma degli importi netti dei partecipanti del round, contando anche quelli non ancora confermati, è minore del tetto. | `round_full` | | **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`), come per il prelievo ([WDR-05](#prelievo)). *Stato:* da implementare (B-96) · *Oggi:* sempre `insufficient_balance`, anche quando il saldo mostrato all'utente copre la scommessa | | **[BET-08]** | Il costo si copre con al massimo 50 monete (`MAX_TX_INPUTS`), scelte dalla più grande fino a coprirlo | `too_many_inputs` (parametro `max_inputs`) | | **[BET-09]** | Le monete scelte ([BET-08]) superano il costo di almeno 294 sat (`DUST_LIMIT_SATS`), che restano come resto | `balance_leaves_no_change` (parametro `required_extra_sats`) | @@ -88,10 +88,7 @@ ancora `open` oltre la scadenza. ([RND-05](ciclo-di-vita-del-round.md#0--apertura)), non il valore corrente di `round_config`: una modifica da `/admin` vale dal round successivo. *Codice:* `app/bets/service.py:place_bet` · - *Test:* — - *Stato:* da implementare (B-95) · *Oggi:* il costo è letto da - `round_config` a ogni scommessa, quindi una modifica vale subito anche per - il round in corso. + *Test:* `tests/unit/test_round_snapshot.py::test_every_bet_of_a_round_pays_the_roundd_cost_whatever_is_edited_meanwhile` - **[BET-11]** La scadenza viene verificata due volte: all'arrivo della richiesta ([BET-04]) e di nuovo dopo aver costruito e firmato la transazione. L'iscrizione al round viene poi salvata nella stessa diff --git a/docs/storico-bug.md b/docs/storico-bug.md index c12f334..4343719 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -243,7 +243,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). | B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API | | B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS | | B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | **aperto** | — | SEC, API | -| B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | **aperto** | — | RND, BET | +| B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | *da registrare* | `test_round_snapshot.py`, `test_admin.py`, `test_rounds_route.py`, `test_scheduler.py` | RND, BET | | B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | **aperto** | — | TX, BET, WDR, DEP | | B-97 | Le migrazioni possono eliminare in silenzio gli indici su espressione | Media | *da registrare* | `test_migrations.py` | DAT | | B-98 | La selezione delle monete si ferma alla prima che copre l'importo e rifiuta una scommessa pagabile | Media | **aperto** | — | 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, B-73, B-81, B-79, B-82, B-87. + 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, B-79, B-82, B-87, B-95. - **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 (0 critici, - 3 alti, 6 medi), da implementare nell'ordine di + 3 alti, 5 medi), da implementare nell'ordine di [piano-correzioni.md](piano-correzioni.md). - **Prossimo numero libero:** `B-99`. diff --git a/migrations/versions/a7d3e9f15b48_snapshot_round_parameters.py b/migrations/versions/a7d3e9f15b48_snapshot_round_parameters.py new file mode 100644 index 0000000..4f00d47 --- /dev/null +++ b/migrations/versions/a7d3e9f15b48_snapshot_round_parameters.py @@ -0,0 +1,53 @@ +"""snapshot the round's parameters at open: rounds.bet_amount_sats/fee_address/max_round_pool_sats, round_config.max_round_pool_sats (B-95) + +Revision ID: a7d3e9f15b48 +Revises: f2c64a8d1e93 +Create Date: 2026-10-15 16:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a7d3e9f15b48' +down_revision: Union[str, Sequence[str], None] = 'f2c64a8d1e93' +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-95 (DAT-27, DAT-55, DAT-61, DAT-62): a round now carries its own `bet_amount_sats`, + `fee_address` and `max_round_pool_sats`, copied from `round_config` when it opens. Rounds + that already exist are filled with the *current* values of `round_config` — for one still + in progress that is exactly what it was using until now, since it read them live. The + server defaults stay (changing them away would rebuild the table on SQLite). + + Plain add_column / UPDATE calls — never batch_alter_table, which on SQLite rebuilds the + table and silently drops the expression indexes (B-97, DAT-56). + """ + op.add_column("round_config", sa.Column("max_round_pool_sats", sa.BigInteger(), server_default="0", nullable=False)) + op.add_column("rounds", sa.Column("bet_amount_sats", sa.BigInteger(), server_default="1000000000", nullable=False)) + op.add_column("rounds", sa.Column("fee_address", sa.String(length=128), server_default="", nullable=False)) + op.add_column("rounds", sa.Column("max_round_pool_sats", sa.BigInteger(), server_default="0", nullable=False)) + op.execute( + "UPDATE rounds SET " + "bet_amount_sats = COALESCE((SELECT bet_amount_sats FROM round_config ORDER BY id LIMIT 1), bet_amount_sats), " + "fee_address = COALESCE((SELECT fee_address FROM round_config ORDER BY id LIMIT 1), fee_address)" + ) + + +def downgrade() -> None: + """Downgrade schema.""" + for column in ("max_round_pool_sats", "fee_address", "bet_amount_sats"): + op.drop_column("rounds", column) + op.drop_column("round_config", "max_round_pool_sats") + # drop_column on SQLite is a table rebuild: recreate the expression index (B-97). + op.execute("DROP INDEX IF EXISTS ix_rounds_single_active") + op.execute(f"CREATE UNIQUE INDEX ix_rounds_single_active ON rounds ((1)) WHERE status IN ({_ACTIVE})") diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py index 993dfbb..ac061f2 100644 --- a/tests/unit/test_admin.py +++ b/tests/unit/test_admin.py @@ -512,3 +512,51 @@ async def test_resume_releases_a_payout_held_because_the_winners_bet_left_the_ch assert (await session.scalars(select(Round.payout_blocked_at))).all() == [None, None] resumed = [e for e in (await session.scalars(select(AuditLog))).all() if e.event_type == "lottery_resumed"] assert resumed and '"payout_blocked"' in resumed[0].payload_json + + +# --- B-95 (DAT-62, API-31): the cap on a round's pool --------------------------------------------------------------- + + +async def test_max_round_pool_sats_is_configurable_and_bounded(client): + headers = {"X-Admin-Token": "test-admin-token"} + assert (await client.get("/admin/config", headers=headers)).json()["max_round_pool_sats"] == 0 + put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731 + assert (await put({"max_round_pool_sats": 50_000_000_000})).json()["max_round_pool_sats"] == 50_000_000_000 + assert (await put({"max_round_pool_sats": 0})).json()["max_round_pool_sats"] == 0 # 0 switches it off + assert (await put({"max_round_pool_sats": 4_000_000_000_000_000})).status_code == 200 + assert (await put({"max_round_pool_sats": 4_000_000_000_000_001})).status_code == 422 + assert (await put({"max_round_pool_sats": -1})).status_code == 422 + + +async def test_a_cap_below_the_price_of_one_bet_is_refused_whichever_field_makes_it(client): + headers = {"X-Admin-Token": "test-admin-token"} + put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731 + resp = await put({"max_round_pool_sats": 999_999_999}) # the bet costs 1_000_000_000 + assert resp.status_code == 422 and resp.json()["detail"]["code"] == "max_round_pool_below_bet_amount" + assert (await put({"max_round_pool_sats": 1_000_000_000})).status_code == 200 # exactly one bet fits + resp = await put({"bet_amount_sats": 2_000_000_000}) # now the bet would exceed the stored cap + assert resp.status_code == 422 and resp.json()["detail"]["code"] == "max_round_pool_below_bet_amount" + assert (await put({"bet_amount_sats": 2_000_000_000, "max_round_pool_sats": 6_000_000_000})).status_code == 200 + assert (await client.get("/admin/config", headers=headers)).json()["bet_amount_sats"] == 2_000_000_000 + + +async def test_a_fee_rate_is_checked_against_the_bet_amount_of_the_round_in_progress(client): + """B-95 with RND-40: the round in progress is paid with the cost it opened with, which can be + lower than the one configured now, so the fee rate has to leave that one payable too.""" + from datetime import datetime, timezone + + from app.db import base as db_base + from app.db.models import Round + + headers = {"X-Admin-Token": "test-admin-token"} + put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731 + async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported + await conn.run_sync(db_base.Base.metadata.create_all) + assert (await put({"bet_amount_sats": 100_000_000})).status_code == 200 + async with db_base.AsyncSessionLocal() as session: + session.add(Round(status="open", opened_at=datetime.now(timezone.utc), bet_amount_sats=50_000)) # opened cheap + await session.commit() + # 14 sat/vB is fine for the configured 1 PLM (and for 100 PLM) but not for the 50_000 sat round + resp = await put({"fee_rate_sat_vb": 14}) + assert resp.status_code == 422 and resp.json()["detail"]["code"] == "config_not_payable" + assert (await put({"fee_rate_sat_vb": 10})).status_code == 200 diff --git a/tests/unit/test_round_snapshot.py b/tests/unit/test_round_snapshot.py new file mode 100644 index 0000000..d2f763b --- /dev/null +++ b/tests/unit/test_round_snapshot.py @@ -0,0 +1,249 @@ +"""B-95 (RND-05, RND-06, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-31): a round +runs by the parameters it opened with. + +Only the duration and the cooldown were copied onto the round when it opened; the cost of a bet +and the fee address were read live, so an edit from /admin in the middle of a round gave the +players of that round different stakes for the same chance, and redirected the commission of +the round in progress. The cap on the pool is new. +""" + +import sqlite3 +from pathlib import Path + +import pytest +from embit.transaction import Transaction +from sqlalchemy import select + +from app.bets.service import BetError, place_bet +from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User +from app.rounds.scheduler import RoundScheduler +from app.rounds.service import open_new_round_if_needed +from app.wallet.plm_network import PLM_MAINNET +from tests.unit.test_bets import FakeElectrumClient, _make_funded_user, session_factory # noqa: F401 (fixture) +from tests.unit.test_migrations import _alembic, _index_sql +from tests.unit.test_scheduler import FakePayoutClient, FakePayoutListener, _FEE_ADDRESS as _SEEDED_FEE_ADDRESS + +_OTHER_FEE_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd" + + +async def _edit_config(session_factory, **values) -> None: + async with session_factory() as session: + config = (await session.scalars(select(RoundConfig))).one() + for name, value in values.items(): + setattr(config, name, value) + await session.commit() + + +async def _open_round(session_factory) -> Round: + async with session_factory() as session: + round_ = await open_new_round_if_needed(session) + await session.commit() + return round_ + + +# --- the copy (RND-05, DAT-61) --------------------------------------------------------------------------------------- + + +async def test_a_round_copies_cost_fee_address_and_cap_when_it_opens(session_factory): + await _edit_config(session_factory, bet_amount_sats=700_000_000, max_round_pool_sats=5_000_000_000) + round_ = await _open_round(session_factory) + assert (round_.bet_amount_sats, round_.fee_address, round_.max_round_pool_sats) == ( + 700_000_000, + _SEEDED_FEE_ADDRESS, + 5_000_000_000, + ) + assert round_.draw_confirmations == 3 and round_.duration_seconds == 600 # what was already copied + + +async def test_an_edit_from_admin_never_reaches_the_round_in_progress_but_the_next_one(session_factory): + first = await _open_round(session_factory) + await _edit_config( + session_factory, bet_amount_sats=2_000_000_000, fee_address=_OTHER_FEE_ADDRESS, max_round_pool_sats=9_000_000_000 + ) + async with session_factory() as session: + again = await session.get(Round, first.id) + assert (again.bet_amount_sats, again.fee_address, again.max_round_pool_sats) == ( + 1_000_000_000, + _SEEDED_FEE_ADDRESS, + 0, + ) + again.status = "closed" + again.closed_at = None # no cooldown to wait for + await session.commit() + second = await _open_round(session_factory) + assert (second.bet_amount_sats, second.fee_address, second.max_round_pool_sats) == ( + 2_000_000_000, + _OTHER_FEE_ADDRESS, + 9_000_000_000, + ) + + +# --- the cost (BET-21, DAT-26) ----------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize("edited", [3_000_000_000, 500_000_000], ids=["raised", "lowered"]) +async def test_every_bet_of_a_round_pays_the_roundd_cost_whatever_is_edited_meanwhile(session_factory, edited): + first = await _make_funded_user(session_factory, 0, 5_000_000_000) + second = await _make_funded_user(session_factory, 1, 5_000_000_000) + client = FakeElectrumClient() + + async with session_factory() as session: + await place_bet(session, client, await session.get(User, first)) + await _edit_config(session_factory, bet_amount_sats=edited) + async with session_factory() as session: + await place_bet(session, client, await session.get(User, second)) + + amounts = [] + for raw in client.broadcasted: + tx = Transaction.parse(bytes.fromhex(raw)) + amounts.append(tx.vout[0].value) + # both pay the same stake (cost 10 PLM minus the same network fee), not the edited one + assert amounts[0] == amounts[1] + assert 1_000_000_000 - 10_000 < amounts[0] <= 1_000_000_000 + async with session_factory() as session: + stakes = (await session.scalars(select(RoundParticipant.bet_amount_sats))).all() + assert len(set(stakes)) == 1 + + +# --- the commission goes where the round said (RND-06) --------------------------------------------------------------- + + +async def test_the_payout_pays_the_fee_address_the_round_opened_with(payout_session_factory): + from app.wallet.hd import derive_pool_address + from tests.unit.test_scheduler import _seed_paying_out_round + + edited_to = derive_pool_address(4242) # a valid address that is neither the winner's nor the seeded one + + await _seed_paying_out_round(payout_session_factory) + async with payout_session_factory() as session: + (await session.scalars(select(RoundConfig))).one().fee_address = edited_to # edited mid-round + await session.commit() + client = FakePayoutClient([]) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._trigger_payout(1) + + tx = Transaction.parse(bytes.fromhex(client.broadcasted[0])) + assert tx.vout[1].script_pubkey.address(network=PLM_MAINNET) == _SEEDED_FEE_ADDRESS + assert edited_to not in {o.script_pubkey.address(network=PLM_MAINNET) for o in tx.vout} + + +async def test_clearing_the_fee_address_mid_round_does_not_stop_its_payout(payout_session_factory): + from tests.unit.test_scheduler import _seed_paying_out_round + + await _seed_paying_out_round(payout_session_factory) + async with payout_session_factory() as session: + (await session.scalars(select(RoundConfig))).one().fee_address = "" # the operator slip of B-66 + await session.commit() + client = FakePayoutClient([]) + await RoundScheduler(payout_session_factory, FakePayoutListener(client))._trigger_payout(1) + assert client.broadcasted and not [ + e for e in (await _events(payout_session_factory)) if e == "payout_failed" + ] + + +async def _events(session_factory) -> list[str]: + async with session_factory() as session: + return list((await session.scalars(select(AuditLog.event_type))).all()) + + +# the fixture of the payout tests (the in-memory database with a master key) +from tests.unit.test_scheduler import payout_session_factory # noqa: E402,F401 + + +# --- the cap on the pool (BET-20, DAT-27) ------------------------------------------------------------------------------------ + + +async def _bet(session_factory, user_id: int, client=None): + async with session_factory() as session: + return await place_bet(session, client or FakeElectrumClient(), await session.get(User, user_id)) + + +async def test_a_bet_is_refused_once_the_pool_has_reached_the_cap_and_only_then(session_factory): + # one bet nets ~ 999.9k sat: a cap of 1.5 bets lets exactly two bets in (the second starts under it) + await _edit_config(session_factory, max_round_pool_sats=1_500_000_000) + users = [await _make_funded_user(session_factory, i, 2_000_000_000) for i in range(3)] + + await _bet(session_factory, users[0]) # pool 0 < cap + await _bet(session_factory, users[1]) # pool ~1.0e9 < 1.5e9: accepted, takes the pool past the cap + with pytest.raises(BetError) as refused: + await _bet(session_factory, users[2]) # pool ~2.0e9 >= cap + assert refused.value.code == "round_full" and refused.value.params["max_round_pool_sats"] == 1_500_000_000 + async with session_factory() as session: # nothing moved for the refused player + assert (await session.scalars(select(PendingTransaction.user_id))).all().count(users[2]) == 0 + assert len((await session.scalars(select(RoundParticipant))).all()) == 2 + from app.db.models import UtxoEvent + + refused_coins = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == users[2]))).all() + assert refused_coins and all(coin.spent_txid is None for coin in refused_coins) + + +async def test_a_pool_exactly_at_the_cap_has_reached_it(session_factory): + """"Reached" is `>=`: a pool that equals the cap is full.""" + first = await _make_funded_user(session_factory, 0, 2_000_000_000) + second = await _make_funded_user(session_factory, 1, 2_000_000_000) + await _bet(session_factory, first) + async with session_factory() as session: + net = (await session.scalars(select(RoundParticipant.bet_amount_sats))).one() + (await session.scalars(select(Round))).one().max_round_pool_sats = net # the round's own cap, set to the pool + await session.commit() + with pytest.raises(BetError) as refused: + await _bet(session_factory, second) + assert refused.value.code == "round_full" + + +async def test_the_cap_counts_bets_still_in_flight(session_factory): + await _edit_config(session_factory, max_round_pool_sats=900_000_000) + first = await _make_funded_user(session_factory, 0, 2_000_000_000) + second = await _make_funded_user(session_factory, 1, 2_000_000_000) + await _bet(session_factory, first) # `broadcast`, not confirmed + async with session_factory() as session: + assert (await session.scalars(select(RoundParticipant.status))).all() == ["broadcast"] + with pytest.raises(BetError) as refused: + await _bet(session_factory, second) + assert refused.value.code == "round_full" + + +async def test_without_a_cap_nothing_limits_the_pool_but_the_participants(session_factory): + users = [await _make_funded_user(session_factory, i, 2_000_000_000) for i in range(4)] + for user_id in users: + await _bet(session_factory, user_id) + async with session_factory() as session: + assert len((await session.scalars(select(RoundParticipant))).all()) == 4 + + +async def test_raising_or_lowering_the_cap_mid_round_does_not_change_that_round(session_factory): + await _edit_config(session_factory, max_round_pool_sats=1_500_000_000) + users = [await _make_funded_user(session_factory, i, 2_000_000_000) for i in range(4)] + await _bet(session_factory, users[0]) + await _edit_config(session_factory, max_round_pool_sats=100_000_000_000) # raised: this round keeps 1.5e9 + await _bet(session_factory, users[1]) + with pytest.raises(BetError): + await _bet(session_factory, users[2]) + await _edit_config(session_factory, max_round_pool_sats=1) # lowered below everything: no effect either + async with session_factory() as session: + assert (await session.scalars(select(Round.max_round_pool_sats))).all() == [1_500_000_000] + + +# --- the existing rounds at the migration (DAT-55) ------------------------------------------------------------------------- + + +def test_the_migration_fills_existing_rounds_with_the_current_configuration(tmp_path): + db_path = tmp_path / "old.db" + _alembic(db_path, "upgrade", "f2c64a8d1e93") + with sqlite3.connect(db_path) as conn: + conn.execute( + "INSERT INTO round_config (fee_address, bet_amount_sats, round_duration_seconds, round_cooldown_seconds, " + "fee_rate_sat_vb, rbf_timeout_seconds, draw_animation_seconds, paused, deposit_confirmations, " + "draw_confirmations, updated_at) " + f"VALUES ('{_OTHER_FEE_ADDRESS}', 750000000, 600, 30, 1, 900, 20, 0, 6, 3, '2026-01-01 00:00:00')" + ) + conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closed', '2026-01-01 00:00:00')") + conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('paying_out', '2026-01-02 00:00:00')") + _alembic(db_path, "upgrade", "head") + with sqlite3.connect(db_path) as conn: + rows = conn.execute("SELECT bet_amount_sats, fee_address, max_round_pool_sats FROM rounds").fetchall() + cap = conn.execute("SELECT max_round_pool_sats FROM round_config").fetchone()[0] + assert rows == [(750_000_000, _OTHER_FEE_ADDRESS, 0)] * 2 and cap == 0 + assert _index_sql(Path(db_path), "ix_rounds_single_active") is not None + assert _index_sql(Path(db_path), "ix_users_username_lower") is not None diff --git a/tests/unit/test_rounds_route.py b/tests/unit/test_rounds_route.py index c30069b..60f45c4 100644 --- a/tests/unit/test_rounds_route.py +++ b/tests/unit/test_rounds_route.py @@ -316,3 +316,27 @@ async def test_lottery_configured_is_false_when_the_amounts_could_not_pay_a_roun (await session.scalars(select(RoundConfig))).one().bet_amount_sats = 1_000_000_000 await session.commit() assert (await ac.get("/rounds/current")).json()["lottery_configured"] is True + + +async def test_current_round_shows_the_round_cost_while_active_and_the_configured_one_otherwise(client): # B-95 (API-21) + from datetime import datetime, timezone + + from sqlalchemy import select + + from app.db.models import Round, RoundConfig + + ac, session_factory = client + async with session_factory() as session: + session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000)) + session.add(Round(id=70, status="open", opened_at=datetime.now(timezone.utc), bet_amount_sats=1_000_000_000)) + await session.commit() + async with session_factory() as session: + (await session.scalars(select(RoundConfig))).one().bet_amount_sats = 4_000_000_000 # edited mid-round + await session.commit() + + assert (await ac.get("/rounds/current")).json()["bet_amount_sats"] == 1_000_000_000 # the round's own + + async with session_factory() as session: + (await session.get(Round, 70)).status = "closed" + await session.commit() + assert (await ac.get("/rounds/current")).json()["bet_amount_sats"] == 4_000_000_000 # what the next one will cost diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index b91b225..49ad767 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -139,6 +139,7 @@ async def _seed_paying_out_round(session_factory, round_id: int = 1, bets: list[ id=round_id, status="paying_out", pool_amount_sats=sum(bets), + fee_address=_FEE_ADDRESS, # B-95: the round's own, copied at open winner_user_id=winner.id, pool_address=derive_pool_address(round_id), # B-80 ) @@ -367,8 +368,9 @@ async def test_trigger_payout_logs_a_failure_when_not_connected(payout_session_f async def test_trigger_payout_logs_a_failure_when_fee_address_missing(payout_session_factory): winner_id = await _seed_paying_out_round(payout_session_factory) async with payout_session_factory() as session: - config = (await session.scalars(select(RoundConfig))).one() - config.fee_address = "" + # B-95 (RND-06): the payout reads the round's own address, so only an empty one on the + # round (a database edited by hand — the API never lets it be emptied) blocks it. + (await session.get(Round, 1)).fee_address = "" await session.commit() entries = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]