diff --git a/BUGS.md b/BUGS.md index 33d44ca..393d6e2 100644 --- a/BUGS.md +++ b/BUGS.md @@ -40,24 +40,6 @@ remains the last prerequisite for running unattended. ## Medium — correctness and robustness -### B-61 — config edits apply retroactively to the round already in progress - -`app/rounds/scheduler.py:71`, `app/rounds/service.py:51-61`, -`app/api/routes/admin.py:106-128`. - -`round_duration_seconds` is read live on every tick and on every bet check, and the -deadline is computed as `opened_at + duration`. Lowering it from 600 to 60 while a -round is 300 s in closes that round instantly; raising it moves the `closes_at` -clients are already counting down to. `round_cooldown_seconds` has the same -property for the gap after a close. - -B-11 fixed exactly this class of problem for `bet_amount_sats` (an in-progress -round's advertised jackpot must not move when an operator edits the bet amount); -the timing fields were left live. - -Fix: snapshot the duration (and cooldown) onto the `Round` row when it opens and -read them from there, leaving the config row as the value for the *next* round. - ### B-62 — "withdraw the full amount" reliably produces an unbumpable transaction `app/static/app.js:798-807`, `app/wallet/psbt_builder.py:138-141`, diff --git a/CLAUDE.md b/CLAUDE.md index c2c0f0c..09a17f9 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 — 305 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 — 309 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. @@ -33,7 +33,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 305 tests +python -m pytest # all 309 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 ``` @@ -84,7 +84,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW |---|---|---| | Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable | | Prize split | **70% winner / 30% fees**, rounding remainder to fees | **hardcoded** in `rounds/scheduler.py` — a code change, not an admin edit | -| Round duration / cooldown | 600s / 30s | `RoundConfig` | +| 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` | | Fee rate / RBF timeout | 1 sat/vB / 900s | `RoundConfig` | | Min withdrawal | = current `bet_amount_sats` (no separate field) | `withdrawals/service.py` | @@ -155,7 +155,7 @@ Diagrams: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifec **PLAY** — fixed cost, **at most one active bet per user**, and **at most `MAX_PARTICIPANTS_PER_ROUND` (400) players per round** — past that the bet is refused with `round_full` and the player waits for the next round (B-52: the payout must spend one pool UTXO per bet, so a round is only ever allowed to grow to what a single payout tx can drain). PSBT user-address → pool-address, always with a **change output back to the same user address** (a user's balance must never exactly equal the bet). Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast. **DRAW** — configurable timer (default 600s): -- *Bet cutoff is the round's own deadline* (`opened_at + round_duration_seconds`), **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. +- *Bet cutoff is the round's own deadline* (`round_deadline` = `opened_at + Round.duration_seconds`, the value snapshotted at open time — B-61), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a **compare-and-set on the round row** (`UPDATE rounds ... WHERE status = 'open'`, B-53): the scheduler flips `open` → `closing` in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only `confirmed` participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is `_close_and_draw` re-counting in-flight bets in the same session it snapshots the participants from. - *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms). - *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount. - *Payout* is signed with the pool key; its **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern. diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index 56ecbb6..f7b98b8 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -15,7 +15,7 @@ from app.db.models import RoundParticipant, User from app.db.session import get_session from app.rounds.config import get_round_config from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster -from app.rounds.service import get_active_round, winner_share +from app.rounds.service import get_active_round, round_deadline, winner_share router = APIRouter(prefix="/rounds", tags=["rounds"]) @@ -142,7 +142,9 @@ async def current_round( ) ) or 0 opened_at = round_.opened_at.replace(tzinfo=timezone.utc) - closes_at = opened_at + timedelta(seconds=config.round_duration_seconds) + # B-61: from the round's own duration — the countdown clients are watching must + # not jump because an operator edited the config mid-round. + closes_at = round_deadline(round_) # Lets the frontend show the personalized win/lose reveal only to players in # this round — everyone else (not logged in, or logged in but didn't bet) diff --git a/app/bets/service.py b/app/bets/service.py index 58a75fd..4ebd480 100644 --- a/app/bets/service.py +++ b/app/bets/service.py @@ -38,7 +38,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) - raise BetError("no_round_open", "no round open right now, please try again shortly") config = await get_round_config(session) - if not round_accepts_bets(round_, config.round_duration_seconds): + if not round_accepts_bets(round_): raise BetError("round_closing", "the current round is closing, please try again shortly") already_playing = await session.scalar( @@ -137,7 +137,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) - # the row, or the flip commits first and this matches zero rows and refuses the # bet before anything is broadcast. try: - if not round_accepts_bets(round_, config.round_duration_seconds): + if not round_accepts_bets(round_): raise _RoundClosedDuringBuild guard = await session.execute( update(Round) diff --git a/app/db/models.py b/app/db/models.py index 70f9ed6..833b6f9 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -82,6 +82,16 @@ class Round(Base): status: Mapped[str] = mapped_column(String(16), default="open") opened_at: Mapped[datetime] = mapped_column(default=utcnow) closed_at: Mapped[datetime | None] = mapped_column(default=None) + # B-61: the round's own timing, snapshotted from RoundConfig when it opens. + # Read live from the config, a mid-round edit applied retroactively: lowering + # round_duration_seconds from 600 to 60 while a round was 300s in closed it + # instantly, and raising it moved the closes_at every client was already + # counting down to. Same class of bug B-11 fixed for the advertised jackpot. + # The config row is now what the *next* round opens with; these are what this + # round runs by. cooldown_seconds is read off the round that just closed, so + # the gap it announced is the gap that's honoured. + duration_seconds: Mapped[int] = mapped_column(default=600, server_default="600") + cooldown_seconds: Mapped[int] = mapped_column(default=30, server_default="30") # Set once, when status flips to "drawing" (rounds/scheduler.py:_close_and_draw). # Lets both the audit log (B-36's draw_stalled entries) and GET /rounds/current # (draw_waiting_since) measure how long a round has been waiting on a block, diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index e946cc5..1ce0469 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -14,7 +14,7 @@ from app.electrum.scripthash import address_to_scripthash from app.rounds.config import get_round_config from app.rounds.draw import draw_winner, header_hex_to_block_hash from app.rounds.events import broadcaster -from app.rounds.service import open_new_round_if_needed, winner_share +from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share from app.wallet.hd import derive_pool_key from app.wallet.plm_network import PLM_MAINNET from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction @@ -67,8 +67,11 @@ class RoundScheduler: await session.commit() if round_ is None: return # still in the cooldown window after the last round closed - round_id, status, opened_at = round_.id, round_.status, round_.opened_at - round_duration_seconds = (await get_round_config(session)).round_duration_seconds + round_id, status = round_.id, round_.status + # B-61: this round's own snapshotted deadline, not one recomputed from + # whatever the config says now — an operator lowering the duration + # mid-round used to close the round on the spot. + deadline = round_deadline(round_) if status == "paying_out": # B-26: _trigger_payout used to run exactly once, from _close_and_draw — @@ -83,8 +86,7 @@ class RoundScheduler: return # "drawing" — progress happens inside the in-flight _close_and_draw call if status == "open": - opened_at = opened_at.replace(tzinfo=timezone.utc) - if datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds): + if datetime.now(timezone.utc) < deadline: return async with self._session_factory() as session: diff --git a/app/rounds/service.py b/app/rounds/service.py index 524e39e..45ed8e1 100644 --- a/app/rounds/service.py +++ b/app/rounds/service.py @@ -48,7 +48,15 @@ async def get_active_round(session: AsyncSession) -> Round | None: return active[0] if active else None -def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool: +def round_deadline(round_: Round) -> datetime: + """When this round stops accepting bets. B-61: from the round's own snapshotted + duration, not from the live config — an operator editing round_duration_seconds + mid-round must not move a deadline clients are already counting down to, nor + close an in-progress round on the spot.""" + return round_.opened_at.replace(tzinfo=timezone.utc) + timedelta(seconds=round_.duration_seconds) + + +def round_accepts_bets(round_: Round) -> bool: """The authoritative "yellow light" check: once a round's timer has expired, no new bet may be accepted, even though its DB status is still "open" (the scheduler only flips it to "closing" on its next tick, up to @@ -57,8 +65,7 @@ def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool: before actually closing.""" if round_.status != "open": return False - opened_at = round_.opened_at.replace(tzinfo=timezone.utc) - return datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds) + return datetime.now(timezone.utc) < round_deadline(round_) async def open_new_round_if_needed(session: AsyncSession) -> Round | None: @@ -83,11 +90,19 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None: last_closed = await session.scalar(select(Round).where(Round.status == "closed").order_by(Round.id.desc())) if last_closed is not None and last_closed.closed_at is not None: closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc) - if datetime.now(timezone.utc) < closed_at + timedelta(seconds=config.round_cooldown_seconds): + # B-61: the cooldown the closing round announced is the one honoured, so + # editing the config never retroactively shortens or extends a gap already + # under way. The new value applies from the next round on. + if datetime.now(timezone.utc) < closed_at + timedelta(seconds=last_closed.cooldown_seconds): return None for attempt in range(_OPEN_ROUND_ATTEMPTS): - round_ = Round(status="open") + # B-61: the timing this round will run by, fixed at open time. + round_ = Round( + status="open", + duration_seconds=config.round_duration_seconds, + cooldown_seconds=config.round_cooldown_seconds, + ) session.add(round_) try: await session.flush() diff --git a/docs/guida-admin.md b/docs/guida-admin.md index fceca32..34d8875 100644 --- a/docs/guida-admin.md +++ b/docs/guida-admin.md @@ -40,6 +40,14 @@ business — quelli si toccano solo da qui. | **Bet amount (PLM)** | Il costo fisso d'ingresso per round. È anche l'importo minimo prelevabile: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). | | **Durata round (secondi)** | Quanto resta aperto un round prima di chiudersi ed estrarre il vincitore. Il taglio per le nuove giocate scatta esattamente allo scadere di questo tempo (verificato ad ogni bet, non dipende dal ciclo dello scheduler) — è un "semaforo giallo": nessuna nuova entrata, ma le bet già trasmesse prima dello scadere hanno comunque tempo di confermarsi prima che il round chiuda ed estragga. | | **Pausa tra un round e il successivo (secondi)** | Cooldown dopo la chiusura di un round, prima che il successivo si apra — dà tempo ai giocatori di vedere l'esito. | + +Durata e cooldown si applicano **dal round successivo**, non a quello già in +corso: ogni round si porta dietro i valori con cui è stato aperto, così +abbassare la durata mentre un round è a metà non lo chiude di colpo, e alzarla +non sposta il countdown che i giocatori stanno già guardando. Gli altri +parametri (bet amount, fee rate, RBF timeout) restano invece a effetto +immediato. + | **Durata animazione estrazione (secondi)** | Tempo minimo per cui la dashboard di ogni utente mostra l'animazione "Estrazione in corso" dopo la chiusura del round, prima di rivelare il vincitore. È solo un minimo: il processo reale aspetta fino a 3 blocchi confermati in sequenza (ultima bet in sospeso, estrazione, payout — ~2 minuti l'uno), quindi l'animazione può durare più a lungo di questo valore, mai meno. | | **Fee rate di rete (sat/vB)** | Fee per byte usata per costruire bet, payout e prelievi. | | **Timeout prima del fee-bump RBF (secondi)** | Dopo quanto tempo senza conferma una transazione viene ritrasmessa con fee più alta. | diff --git a/migrations/versions/283844a44b4a_snapshot_round_timing_onto_the_round_.py b/migrations/versions/283844a44b4a_snapshot_round_timing_onto_the_round_.py new file mode 100644 index 0000000..8b3c4c4 --- /dev/null +++ b/migrations/versions/283844a44b4a_snapshot_round_timing_onto_the_round_.py @@ -0,0 +1,42 @@ +"""snapshot round timing onto the round row (B-61) + +Revision ID: 283844a44b4a +Revises: c1d4a97b5e10 +Create Date: 2026-08-03 23:05:04.996492 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '283844a44b4a' +down_revision: Union[str, Sequence[str], None] = 'c1d4a97b5e10' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column('rounds', sa.Column('duration_seconds', sa.Integer(), server_default='600', nullable=False)) + op.add_column('rounds', sa.Column('cooldown_seconds', sa.Integer(), server_default='30', nullable=False)) + + # Backfill from the live config rather than leaving the column defaults: an + # instance running with, say, a 300s round would otherwise see every existing + # row — including the round currently in progress — jump to 600s the moment + # this migration lands, which is exactly the retroactive change B-61 is about. + op.execute( + "UPDATE rounds SET " + "duration_seconds = coalesce((SELECT round_duration_seconds FROM round_config LIMIT 1), 600), " + "cooldown_seconds = coalesce((SELECT round_cooldown_seconds FROM round_config LIMIT 1), 30)" + ) + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('rounds', 'cooldown_seconds') + op.drop_column('rounds', 'duration_seconds') + # ### end Alembic commands ### diff --git a/tests/unit/test_rounds_service.py b/tests/unit/test_rounds_service.py index 45608b4..0aa86b9 100644 --- a/tests/unit/test_rounds_service.py +++ b/tests/unit/test_rounds_service.py @@ -171,3 +171,50 @@ async def test_closed_rounds_can_coexist_with_an_active_one(session_factory): async with session_factory() as session: assert len((await session.scalars(select(Round))).all()) == 3 + + +# --- B-61: a round runs by the timing it opened with, not by the live config ------ + + +async def test_a_new_round_snapshots_the_current_config_timing(session_factory): + async with session_factory() as session: + session.add(RoundConfig(fee_address="", round_duration_seconds=120, round_cooldown_seconds=45)) + await session.commit() + + async with session_factory() as session: + round_ = await open_new_round_if_needed(session) + await session.commit() + + assert round_.duration_seconds == 120 + assert round_.cooldown_seconds == 45 + + +async def test_round_accepts_bets_uses_the_rounds_own_duration(session_factory): + from app.rounds.service import round_accepts_bets + + opened_at = datetime.now(timezone.utc) - timedelta(seconds=100) + still_open = Round(status="open", opened_at=opened_at, duration_seconds=600) + expired = Round(status="open", opened_at=opened_at, duration_seconds=60) + + assert round_accepts_bets(still_open) is True + assert round_accepts_bets(expired) is False + + +async def test_cooldown_comes_from_the_round_that_closed(session_factory): + """The gap a closing round announced is the gap that's honoured: shortening + round_cooldown_seconds afterwards must not open the next round early, nor + lengthening it hold the lottery shut.""" + async with session_factory() as session: + session.add(RoundConfig(fee_address="", round_cooldown_seconds=0)) # just lowered to 0 + session.add( + Round( + status="closed", + opened_at=datetime.now(timezone.utc) - timedelta(seconds=200), + closed_at=datetime.now(timezone.utc) - timedelta(seconds=10), + cooldown_seconds=300, # what that round ran with + ) + ) + await session.commit() + + async with session_factory() as session: + assert await open_new_round_if_needed(session) is None # still cooling down diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index d92dc00..b1a4d13 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -46,7 +46,8 @@ async def test_tick_closes_round_with_no_participants_once_due(session_factory, past = datetime.now(timezone.utc) - timedelta(seconds=10) async with session_factory() as session: session.add(RoundConfig(fee_address="", round_duration_seconds=1)) - session.add(Round(status="open", opened_at=past)) + # B-61: the deadline comes from the round's own snapshot, not from the config. + session.add(Round(status="open", opened_at=past, duration_seconds=1)) await session.commit() scheduler = RoundScheduler(session_factory, FakeListener()) @@ -528,3 +529,26 @@ async def test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check(sess events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] assert "round_closed" not in events assert "winner_drawn" not in events + + +async def test_tick_ignores_a_config_duration_edited_mid_round(session_factory): # B-61 + """Lowering round_duration_seconds from 600 to 30 while a round is 300s in used + to close that round on the spot, because the deadline was recomputed live from + the config on every tick. The edit applies to the *next* round.""" + async with session_factory() as session: + session.add(RoundConfig(fee_address="", round_duration_seconds=30)) # just lowered + session.add( + Round( + status="open", + opened_at=datetime.now(timezone.utc) - timedelta(seconds=300), + duration_seconds=600, # what this round opened with + ) + ) + await session.commit() + + scheduler = RoundScheduler(session_factory, FakeListener()) + await scheduler._tick() + + async with session_factory() as session: + round_ = (await session.scalars(select(Round))).one() + assert round_.status == "open" # still 300s to go, by its own clock