diff --git a/.env.example b/.env.example index 8d29378..da0280e 100644 --- a/.env.example +++ b/.env.example @@ -50,3 +50,8 @@ ADMIN_TOKEN= # endpoints included — to anyone who requests them. Off by default; set to # true only for local development, never in production. ENABLE_API_DOCS=false + +# Start with the lottery paused (as the operator's pause, audit-logged), e.g. after +# restoring a backup: nothing opens a round until you resume from /admin. See +# docs/operativita.md (OPS-27). +START_PAUSED=false diff --git a/BUGS.md b/BUGS.md index 5ab7c01..0b31a4a 100644 --- a/BUGS.md +++ b/BUGS.md @@ -103,17 +103,6 @@ decisions, see `docs/limiti-noti.md`): ## High -### B-90 — Restoring a backup is unsafe - -A restored DB reopens rounds that were already drawn and paid, and reassigns the -derivation indexes of users registered after the backup to new users — who then -receive the old users' funds. - -Fix: `START_PAUSED=true` starts with the lottery paused; at startup the next 20 -indexes past the highest assigned are checked for on-chain history and, if any has -some, registration is refused until the operator reconciles (`restore_gap_detected`); -per-round addresses (B-80) and B-82's pre-payout check make a replayed round -unpayable instead of payable twice. ### B-91 — Host and supply-chain hardening diff --git a/CLAUDE.md b/CLAUDE.md index 81ca827..a3723c9 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 — 711 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 — 719 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 711 tests +python -m pytest # all 719 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 ``` diff --git a/README.md b/README.md index 93af724..34f285b 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 ``` -711 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +719 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 fc15748..6628c1e 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -14,7 +14,7 @@ from app.config import settings 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.pause import pause_by_operator 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 @@ -196,14 +196,7 @@ async def pause_lottery( B-78 (DAT-69, API-32): the reason is "operator", except that an automatic reason already in force stays — it is the one that needs a look. The audit row always says `operator` for this call.""" - config = await get_round_config(session) - if not (config.paused and config.paused_reason in AUTOMATIC_REASONS): - config.paused_reason = OPERATOR_REASON - config.paused = True - payload = {"reason": OPERATOR_REASON} - if body is not None and body.note: - payload["note"] = body.note - await write_audit_log(session, "lottery_paused", payload) + config = await pause_by_operator(session, body.note if body is not None else None) await session.commit() return _config_response(config) diff --git a/app/auth/routes.py b/app/auth/routes.py index 1a20ffe..2a70bbd 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -70,6 +70,14 @@ class TokenResponse(BaseModel): async def register( body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session) ) -> TokenResponse: + # B-90 (REG-15): after a restore a new user could be handed the derivation index — the + # address, with the funds — of someone who registered after the backup. + if not getattr(request.app.state.electrum_listener, "registration_open", True): + raise http_error( + status.HTTP_503_SERVICE_UNAVAILABLE, + "registration_unavailable", + "registration is temporarily unavailable", + ) limiters = _rate_limiters(request) ip_key = f"ip:{_client_ip(request)}" # B-58: checked before the Argon2 hash below, so an IP that's out of quota diff --git a/app/config.py b/app/config.py index d454a87..4914f47 100644 --- a/app/config.py +++ b/app/config.py @@ -41,6 +41,9 @@ class Settings(BaseSettings): # trust the quorum exists to remove. allow_no_quorum: bool = False + # B-90 (OPS-27): start with the lottery paused, as after restoring a backup. + start_paused: bool = False + xprv_encryption_key: str = "" master_key_path: str = "./master.xprv.enc" jwt_secret: str = "" diff --git a/app/electrum/listener.py b/app/electrum/listener.py index 23e8f89..3770a7b 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -5,7 +5,7 @@ from collections import Counter from collections.abc import Callable from embit.transaction import Transaction -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import async_sessionmaker from app.audit.log import write_audit_log @@ -34,6 +34,7 @@ from app.rounds.draw import ( ) from app.rounds.events import broadcaster from app.rounds.pause import pause_automatically +from app.wallet.hd import derive_user_address logger = logging.getLogger(__name__) @@ -70,6 +71,10 @@ _monotonic = time.monotonic # ordinary on any chain, two or more is worth a look before a new round opens. _REORG_PAUSE_DEPTH = 2 +# B-90 (REG-15): how many derivation indexes past the highest assigned one are looked up for history +# before registration opens. +_RESTORE_GAP_WINDOW = 20 + # `blockchain.block.headers` serves at most this many headers per request (ELC-12). _MAX_HEADERS_PER_REQUEST = 2016 @@ -95,6 +100,11 @@ class ElectrumListener: servers down) still backs off instead of spinning. """ + # B-90 (REG-15): False until the check of the derivation indexes past the highest assigned + # one has completed and found no address with history. A class attribute so a test that + # is not about a restore can open registration once for every listener. + registration_open = False + def __init__( self, client_factory: Callable[[ElectrumEndpoint], ElectrumClient], @@ -108,6 +118,7 @@ class ElectrumListener: # B-84 (ELC-19): ALLOW_NO_QUORUM, development only — see _corroborate_majority. self._allow_no_quorum = allow_no_quorum self._endpoint_index = 0 + self._restore_checked = False self._scripthash_to_user: dict[str, int] = {} # Retains address_for_new_user's fire-and-forget subscribe task so it # can't be garbage-collected mid-flight, and so its exception (if any) is @@ -267,10 +278,16 @@ class ElectrumListener: # the session actually does end. subscribe_task = asyncio.create_task(self._subscribe_all_users()) subscribe_task.add_done_callback(self._log_subscribe_all_users_failure) + restore_task = None + if not self._restore_checked: + restore_task = asyncio.create_task(self._check_restore_gap()) + restore_task.add_done_callback(self._log_restore_check_failure) try: done, still_running = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) finally: subscribe_task.cancel() + if restore_task is not None: + restore_task.cancel() for task in tasks: task.cancel() for task in done: @@ -282,6 +299,56 @@ class ElectrumListener: await client.close() return True + def _log_restore_check_failure(self, task: asyncio.Task) -> None: + if not task.cancelled() and task.exception() is not None: + logger.warning("restore-gap check not completed, registration stays closed: %r", task.exception()) + + async def _check_restore_gap(self) -> None: + """B-90 (REG-15): a database restored from a backup does not know the users who + registered after it, and would hand their derivation indexes — their addresses, + with whatever they received — to new users. So the `_RESTORE_GAP_WINDOW` indexes + past the highest assigned one are looked up with `get_history` on the active + server and on every other one; a single server reporting a transaction makes the + address used. Registration opens only when none is; otherwise `restore_gap_detected` + is written and it stays closed until a later start finds none (the operator + occupies those indexes, OPS-28). If the active server cannot answer the check is + simply tried again on the next session.""" + async with self._session_factory() as session: + highest = (await session.execute(select(func.max(User.derivation_index)))).scalar_one_or_none() + first = 0 if highest is None else highest + 1 + addresses = [(index, derive_user_address(index)) for index in range(first, first + _RESTORE_GAP_WINDOW)] + + async def ask(client: ElectrumClient) -> list[bool]: + histories = await asyncio.gather( + *(client.request("blockchain.scripthash.get_history", [address_to_scripthash(a)]) for _, a in addresses) + ) + return [bool(history) for history in histories] + + active = await self._ask_active_server(ask) + if active is None: + raise RuntimeError("the active server did not answer the history lookups") + others = await self._ask_other_servers(ask) + answers = [active] + [answer for answer in others if answer is not None] + used = [ + {"derivation_index": index, "address": address} + for position, (index, address) in enumerate(addresses) + if any(answer[position] for answer in answers) + ] + self._restore_checked = True + if not used: + self.registration_open = True + return + logger.error( + "restore gap: %s derivation index(es) past the highest assigned one already have history " + "on chain — registration stays closed (OPS-28)", + len(used), + ) + async with self._session_factory() as session: + await write_audit_log( + session, "restore_gap_detected", {"highest_assigned_index": highest, "used": used} + ) + await session.commit() + def _log_subscribe_all_users_failure(self, task: asyncio.Task) -> None: if task.cancelled(): return diff --git a/app/main.py b/app/main.py index 1ad7331..3d68ee3 100644 --- a/app/main.py +++ b/app/main.py @@ -28,6 +28,7 @@ from app.deposits.reconcile import DepositReconciler from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints from app.electrum.listener import ElectrumListener from app.rounds.scheduler import RoundScheduler +from app.rounds.pause import pause_by_operator from app.tx.broadcast import RbfBumper from app.tx.confirmation import ConfirmationPoller from app.tx.locks import UserLocks @@ -55,6 +56,11 @@ async def lifespan(app: FastAPI): ) validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum verify_master_key() # B-89: refuses to serve with a key that did not produce the stored addresses + if settings.start_paused: # B-90 (OPS-27): before the scheduler exists, so no round can open first + async with AsyncSessionLocal() as session: + await pause_by_operator(session, "START_PAUSED") + await session.commit() + logger.warning("START_PAUSED=true: the lottery starts paused (resume it from /admin)") logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints)) listener = ElectrumListener( diff --git a/app/rounds/pause.py b/app/rounds/pause.py index 5a0a3d4..c506792 100644 --- a/app/rounds/pause.py +++ b/app/rounds/pause.py @@ -28,3 +28,18 @@ async def pause_automatically(session: AsyncSession, reason: str) -> None: config.paused = True config.paused_reason = reason await write_audit_log(session, "lottery_paused", {"reason": reason}) + + +async def pause_by_operator(session: AsyncSession, note: str | None = None): + """The operator's pause (`POST /admin/pause`, B-90's `START_PAUSED`): never overwrites + an automatic reason already in force — that is the one that needs a look — and the + audit row always says `operator`. Returns the config row; the caller commits.""" + config = await get_round_config(session) + if not (config.paused and config.paused_reason in AUTOMATIC_REASONS): + config.paused_reason = OPERATOR_REASON + config.paused = True + payload = {"reason": OPERATOR_REASON} + if note: + payload["note"] = note + await write_audit_log(session, "lottery_paused", payload) + return config diff --git a/app/static/i18n.js b/app/static/i18n.js index d3d0ecf..b4c036e 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -161,6 +161,7 @@ const TRANSLATIONS = { 'error.invalid_amount': 'Enter an amount greater than zero.', 'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.', 'error.service_busy': 'The service is busy right now and nothing was sent. Please try again in a moment.', + 'error.registration_unavailable': 'Registration is temporarily unavailable. Please try again later.', 'error.amount_below_dust_limit': 'The amount is too small to be sent.', 'error.too_many_inputs': 'Your balance is split across too many small deposits to be spent in a single transaction (max {max_inputs}). Please contact support to consolidate it.', 'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.', @@ -329,6 +330,7 @@ const TRANSLATIONS = { 'error.invalid_amount': 'Inserisci un importo maggiore di zero.', 'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.', 'error.service_busy': 'Il servizio è occupato in questo momento e non è stato inviato nulla. Riprova tra poco.', + 'error.registration_unavailable': 'La registrazione è temporaneamente non disponibile. Riprova più tardi.', 'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.", 'error.too_many_inputs': 'Il tuo saldo è suddiviso in troppi piccoli depositi per essere speso in una sola transazione (max {max_inputs}). Contatta l\'assistenza per consolidarlo.', 'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.', @@ -497,6 +499,7 @@ const TRANSLATIONS = { 'error.invalid_amount': 'Introduce un importe mayor que cero.', 'error.broadcast_failed': 'La red rechazó la transacción. Inténtalo de nuevo en un momento.', 'error.service_busy': 'El servicio está ocupado en este momento y no se ha enviado nada. Inténtalo de nuevo en un momento.', + 'error.registration_unavailable': 'El registro no está disponible temporalmente. Inténtalo de nuevo más tarde.', 'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.', 'error.too_many_inputs': 'Tu saldo está repartido en demasiados depósitos pequeños para gastarse en una sola transacción (máx. {max_inputs}). Contacta con soporte para consolidarlo.', 'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.', @@ -665,6 +668,7 @@ const TRANSLATIONS = { 'error.invalid_amount': 'Saisissez un montant supérieur à zéro.', 'error.broadcast_failed': 'Le réseau a refusé la transaction. Veuillez réessayer dans un instant.', 'error.service_busy': 'Le service est occupé pour le moment et rien n\'a été envoyé. Veuillez réessayer dans un instant.', + 'error.registration_unavailable': 'L\'inscription est temporairement indisponible. Veuillez réessayer plus tard.', 'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.", 'error.too_many_inputs': 'Votre solde est réparti sur trop de petits dépôts pour être dépensé en une seule transaction (max {max_inputs}). Contactez le support pour le consolider.', 'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.", @@ -833,6 +837,7 @@ const TRANSLATIONS = { 'error.invalid_amount': 'Gib einen Betrag größer als null ein.', 'error.broadcast_failed': 'Das Netzwerk hat die Transaktion abgelehnt. Bitte versuche es in Kürze erneut.', 'error.service_busy': 'Der Dienst ist gerade ausgelastet und es wurde nichts gesendet. Bitte versuche es gleich noch einmal.', + 'error.registration_unavailable': 'Die Registrierung ist vorübergehend nicht verfügbar. Bitte versuche es später noch einmal.', 'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.', 'error.too_many_inputs': 'Ihr Guthaben ist auf zu viele kleine Einzahlungen verteilt, um in einer einzigen Transaktion ausgegeben zu werden (max. {max_inputs}). Bitte wenden Sie sich an den Support, um es zusammenzufassen.', 'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.', @@ -1001,6 +1006,7 @@ const TRANSLATIONS = { 'error.invalid_amount': 'Введите сумму больше нуля.', 'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.', 'error.service_busy': 'Сервис сейчас занят, ничего не было отправлено. Попробуйте ещё раз через минуту.', + 'error.registration_unavailable': 'Регистрация временно недоступна. Попробуйте позже.', 'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.', 'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.', 'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.', @@ -1169,6 +1175,7 @@ const TRANSLATIONS = { 'error.invalid_amount': '请输入大于零的金额。', 'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。', 'error.service_busy': '服务当前繁忙,没有发送任何内容,请稍后重试。', + 'error.registration_unavailable': '注册暂时不可用,请稍后再试。', 'error.amount_below_dust_limit': '金额过小,无法发送。', 'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。', 'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。', diff --git a/docs/api.md b/docs/api.md index 6b10de9..62a693e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -305,12 +305,12 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. usato, **senza distinzione tra maiuscole e minuscole**, anche se l'altro account si è registrato nello stesso istante); `409 derivation_index_conflict` (dopo 5 tentativi non è stato possibile - assegnare un indice di derivazione libero); `503 registration_suspended` + assegnare un indice di derivazione libero); `503 registration_unavailable` (all'avvio sono stati trovati movimenti sulla catena per indici di derivazione successivi all'ultimo assegnato, cioè il database potrebbe essere un backup più vecchio della realtà; la registrazione resta sospesa finché l'operatore non riconcilia, - [operativita.md](operativita.md) B-90 — *da implementare*). + [operativita.md](operativita.md) B-90). - **Limite di frequenza:** al massimo 5 account creati per IP in una finestra mobile di un'ora; la quota è verificata **prima** dell'hash Argon2 e consumata solo da una registrazione riuscita (un nome già preso o un @@ -324,8 +324,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. [registrazione-e-depositi.md](registrazione-e-depositi.md). *Codice:* `app/auth/routes.py:register`, `app/auth/routes.py:RegisterRequest` · *Test:* `tests/unit/test_auth.py`, `tests/unit/test_users.py` - *Stato:* da implementare (B-92, B-90) · *Oggi:* non scrive audit, e non - esiste la sospensione `registration_suspended`. + *Stato:* da implementare (B-92) · *Oggi:* non scrive audit. #### POST /auth/login @@ -1053,7 +1052,7 @@ dei parametri operativi in | `internal_error` | 500 | `app/main.py:log_unhandled_exception` | — | eccezione non gestita | sì | | `withdrawal_to_platform_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni | sì | | `service_busy` | 503 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/auth/routes.py:register` | — | il database non ha concesso il lock di scrittura entro il timeout; nulla è stato trasmesso, si può riprovare | sì | -| `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) — *da implementare* | no — da aggiungere | +| `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) | sì | | `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | | `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | | `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | diff --git a/docs/architettura.md b/docs/architettura.md index bef3584..a747aeb 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -344,7 +344,7 @@ sequenceDiagram L->>L: parse_endpoints(...) L->>L: quorum minimo (validate_quorum_servers) L->>L: verifica della master key (B-89) - L->>L: START_PAUSED (B-90, da implementare) + L->>L: START_PAUSED (B-90) L->>L: app.state.electrum_listener, app.state.user_locks L->>L: create_task ×6 Note over L: il server serve richieste
(controllo dei buchi di indici alla prima connessione, B-90) @@ -448,8 +448,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]). ([operativita.md](operativita.md#ripristino), B-90). *Codice:* `app/main.py:lifespan` · `app/config.py:validate_runtime_secrets` · `app/config.py:validate_quorum_servers` · *Test:* `tests/unit/test_electrum_listener.py` - *Stato:* da implementare (B-90), per il controllo 3 · *Oggi:* il lifespan non - ferma la registrazione né mette in pausa dopo un ripristino. - **[ARC-21]** Lo stato condiviso con le route vive su `app.state`: `electrum_listener` (l'`ElectrumListener`) e `user_locks` (un `UserLocks`), creati nel lifespan; `auth_rate_limiters` viene invece creato @@ -600,9 +598,8 @@ La configurazione è divisa in due posti con regole diverse. | `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) | *Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py` - *Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e - `XPRV_ENCRYPTION_KEY_FILE` non esistono, la chiave Fernet è la variabile - `XPRV_ENCRYPTION_KEY`. + *Stato:* da implementare (B-91), per `XPRV_ENCRYPTION_KEY_FILE` · *Oggi:* la + chiave Fernet è la variabile `XPRV_ENCRYPTION_KEY`. - **[ARC-31]** **Parametri operativi e di business** (costo della scommessa, durata e pausa del round, animazione, commissione di rete, timeout RBF, indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei diff --git a/docs/audit-log.md b/docs/audit-log.md index 91a3114..0dc3eed 100644 --- a/docs/audit-log.md +++ b/docs/audit-log.md @@ -44,7 +44,7 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in `app/tx/broadcast.py:RbfBumper._tick`, `app/main.py:lifespan`, `app/rounds/scheduler.py:RoundScheduler._tick`, `app/electrum/listener.py:ElectrumListener._record_reorg` - *Stato:* da implementare (B-90, B-96), per due degli eventi + *Stato:* da implementare (B-96), per uno degli eventi nuovi · *Oggi:* gli eventi osservativi sono i primi quattro, `chain_reorg_detected` e `rbf_bump_failed`. @@ -213,11 +213,7 @@ flowchart LR `app/electrum/listener.py:ElectrumListener._record_reorg`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/tx/reconcile.py:reconcile_once` · - *Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py` - *Stato:* da implementare (B-90) · *Oggi:* scritto da - `POST /admin/pause` (`operator`), dal riconciliatore (`resurrected_tx`), dal - listener (`reorg`) e dallo scheduler (`payout_blocked`); l'avvio con - `START_PAUSED` non lo scrive ancora. + *Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`, `tests/unit/test_restore_gap.py` - **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload `{"reason"}`: il `paused_reason` che la ripresa ha tolto (`null` se la @@ -294,8 +290,7 @@ flowchart LR ogni avvio in cui il controllo trova indirizzi usati; transazione propria ([AUD-03]). La sospensione delle registrazioni che ne segue non dipende da questa riga. Procedura in [operativita.md](operativita.md) (OPS-28). - *Codice:* `app/main.py:lifespan` · *Test:* — - *Stato:* da implementare (B-90) · *Oggi:* nessun controllo e nessun evento. + *Codice:* `app/electrum/listener.py:ElectrumListener._check_restore_gap` · *Test:* `tests/unit/test_restore_gap.py` ### Denaro degli utenti diff --git a/docs/modello-dati.md b/docs/modello-dati.md index 1cd5100..9f48649 100644 --- a/docs/modello-dati.md +++ b/docs/modello-dati.md @@ -844,10 +844,6 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)). `app/electrum/listener.py:ElectrumListener._record_reorg` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/reconcile.py:reconcile_once` · *Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py` - *Stato:* da implementare (B-90) · *Oggi:* si scrivono - `operator` (`POST /admin/pause`), `resurrected_tx` (il riconciliatore), - `reorg` (il listener) e `payout_blocked` (lo scheduler); `START_PAUSED` non ha - ancora chi lo scrive. - **[DAT-31]** Con `fee_address` vuoto (o di soli spazi) non si apre nessun round (B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la diff --git a/docs/operativita.md b/docs/operativita.md index a8ea2ff..e804fcc 100644 --- a/docs/operativita.md +++ b/docs/operativita.md @@ -162,7 +162,7 @@ denaro di oggi. docker compose run --rm --no-deps app python -c "import sqlite3; c=sqlite3.connect('/app/db_data/plm_lottery.db'); c.execute('UPDATE round_config SET paused=1'); c.commit(); print('pausa impostata')" ``` La modifica non lascia una riga di audit: annotarla altrove. - Dopo B-90 si usa invece `START_PAUSED=true` in `.env` ([OPS-27]), che + Si usa invece `START_PAUSED=true` in `.env` ([OPS-27]), che scrive anche il motivo della pausa e la riga di audit. 5. **Elencare, sempre ad app ferma, i round non chiusi e le transazioni in volo** del backup: @@ -235,8 +235,7 @@ denaro di oggi. Gli utenti registrati dopo il backup spariscono con il ripristino, e i loro indici vengono riassegnati ai prossimi iscritti: il nuovo utente riceve lo **stesso indirizzo** di un utente perso, e con esso i fondi che quello vi - aveva depositato. Oggi il codice non ha alcuna protezione: l'unica è la - procedura [OPS-28]. Dopo B-90 l'app controlla da sola gli indici successivi + aveva depositato. L'app lo previene: controlla da sola gli indici successivi e sospende le registrazioni ([REG-15](registrazione-e-depositi.md#registrazione)); la procedura resta il modo di rimuovere la sospensione. Gli utenti persi non si possono @@ -253,13 +252,11 @@ denaro di oggi. ("Riprendi lotteria"). Con il valore predefinito `false` l'avvio non tocca la pausa. *Codice:* `app/config.py:Settings`, `app/main.py:lifespan`, - `app/rounds/service.py:open_new_round_if_needed` · - *Test:* — - *Stato:* da implementare (B-90) · *Oggi:* la variabile non esiste; la pausa - si imposta a mano nel database (passo 4 di [OPS-07]). + `app/rounds/pause.py:pause_by_operator` · + *Test:* `tests/unit/test_restore_gap.py` - **[OPS-28]** **Occupare gli indici di utenti persi.** Procedura, ad app - ferma, valida oggi e dopo B-90: + ferma: 1. Trovare il massimo indice assegnato e ricavare gli indirizzi dei 20 indici successivi: ```bash @@ -283,7 +280,7 @@ denaro di oggi. conoscere il proprio indirizzo, gli si consegna il segnaposto corrispondente con un reset della password ([OPS-19]). - Dopo B-90 il controllo automatico di + Il controllo automatico di [REG-15](registrazione-e-depositi.md#registrazione), al riavvio successivo, non trova più indirizzi usati oltre i segnaposto e le registrazioni riprendono. @@ -789,9 +786,6 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). `app/tx/confirmation.py:poll_once`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* `tests/unit/test_electrum_listener.py` - *Stato:* da implementare (B-90) · *Oggi:* il sistema mette in pausa la - lotteria per `resurrected_tx` (B-78), `reorg` (B-85) e `payout_blocked` (B-82); - l'avvio con `START_PAUSED=true` non esiste ancora. ### Intervento manuale sui fondi diff --git a/docs/registrazione-e-depositi.md b/docs/registrazione-e-depositi.md index d4ea68b..8e19b6c 100644 --- a/docs/registrazione-e-depositi.md +++ b/docs/registrazione-e-depositi.md @@ -97,10 +97,7 @@ L'utente sceglie username e password (`POST /auth/register`). Il server: ricostruire gli utenti mancanti (procedura in [operativita.md](operativita.md#ripristino), B-90). La registrazione è rifiutata allo stesso modo finché il controllo non è stato completato. - *Codice:* `app/main.py:lifespan`, `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` · *Test:* — - *Stato:* da implementare (B-90) · *Oggi:* nessun controllo; dopo un - ripristino il prossimo utente riceve il primo indice non presente nel - database, anche se il suo indirizzo ha già ricevuto fondi. + *Codice:* `app/electrum/listener.py:ElectrumListener._check_restore_gap`, `app/auth/routes.py:register`, `app/wallet/hd.py:derive_user_address` · *Test:* `tests/unit/test_restore_gap.py` - **[REG-16]** Ogni registrazione riuscita scrive l'evento `user_registered` con l'indice di derivazione e l'indirizzo assegnati, nella stessa transazione che crea l'utente. diff --git a/docs/sicurezza.md b/docs/sicurezza.md index 2e29650..e7ab2a1 100644 --- a/docs/sicurezza.md +++ b/docs/sicurezza.md @@ -131,8 +131,7 @@ l'host. riavvio. L'elenco completo è in [architettura.md](architettura.md#configurazione). *Codice:* `app/config.py:Settings` - *Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e - `XPRV_ENCRYPTION_KEY_FILE` non esistono. + *Stato:* da implementare (B-91), per `XPRV_ENCRYPTION_KEY_FILE`. `.env.example` documenta le chiavi necessarie e il comando per generarle; non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque @@ -778,9 +777,8 @@ sicurezza. successivi al più alto assegnato e, se uno ha cronologia in catena, la registrazione è rifiutata (`registration_unavailable`) e si scrive `restore_gap_detected` - ([REG-15](registrazione-e-depositi.md#registrazione), B-90). *Oggi:* - nessun controllo; l'unica difesa è la procedura - [OPS-28](operativita.md#ripristino). + ([REG-15](registrazione-e-depositi.md#registrazione), B-90); la procedura + [OPS-28](operativita.md#ripristino) toglie la sospensione. - **Round rigiocati.** Con `START_PAUSED=true` il server parte con la lotteria in pausa ([OPS-27](operativita.md#ripristino), B-90), e ogni round ha il proprio indirizzo del montepremi, le cui monete sono verificate con diff --git a/docs/storico-bug.md b/docs/storico-bug.md index 1e303be..97cc2a5 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -238,7 +238,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). | B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | *da registrare* | `test_rbf_dust.py`, `test_broadcast.py` | TX | | B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC | | B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | *da registrare* | `test_master_key_check.py` | SEC, OPS | -| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | **aperto** | — | OPS, REG | +| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | *da registrare* | `test_restore_gap.py` | OPS, REG | | B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | **aperto** | — | SEC, OPS | | 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 | @@ -254,7 +254,7 @@ 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, B-89, B-95, B-98, B-99, B-100. + 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-89, B-90, B-95, B-98, B-99, B-100. - **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, 5 medi), da implementare nell'ordine di diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 58ac86b..1b2fa69 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -25,6 +25,15 @@ def _throwaway_master_key(tmp_path, monkeypatch): hd._account_key = None +@pytest.fixture(autouse=True) +def _registration_open(monkeypatch): + """B-90: a real listener keeps registration closed until its restore-gap check ran; + only the tests about that check care, and they set it themselves.""" + from app.electrum.listener import ElectrumListener + + monkeypatch.setattr(ElectrumListener, "registration_open", True) + + # B-74: the real powLimit needs ~2^32 hashes per header, so tests mine headers at a trivial # target (`0x207fffff`) and this swaps in a limit that allows it. Done at import, not in a # fixture, because some test modules mine headers while they are being collected. The diff --git a/tests/unit/test_restore_gap.py b/tests/unit/test_restore_gap.py new file mode 100644 index 0000000..762f66a --- /dev/null +++ b/tests/unit/test_restore_gap.py @@ -0,0 +1,133 @@ +"""B-90 (REG-15, OPS-27): a restored backup must not hand out derivation indexes that already +have history, and START_PAUSED starts the lottery paused.""" + +import json + +import pytest +from sqlalchemy import select + +from app.db.models import AuditLog, RoundConfig, User +from app.electrum.listener import ElectrumListener +from app.electrum.scripthash import address_to_scripthash +from app.wallet.hd import derive_user_address +from tests.unit.test_electrum_listener import _ENDPOINTS, session_factory # noqa: F401 (fixture) + + +class _Server: + """Answers get_history: the given scripthashes have a transaction, the rest none.""" + + def __init__(self, used=(), fail=False): + self.used = set(used) + self.fail = fail + + async def connect(self): + if self.fail: + raise ConnectionRefusedError("down") + + async def request(self, method, params=None): + assert method == "blockchain.scripthash.get_history" + return [{"tx_hash": "aa" * 32, "height": 5}] if params[0] in self.used else [] + + async def close(self): + pass + + +def _listener(session_factory, active, others): + servers = {"second.example": others[0], "third.example": others[1]} + listener = ElectrumListener(lambda endpoint: servers[endpoint.host], session_factory, _ENDPOINTS) + listener.client = active + listener.registration_open = False + return listener + + +async def _users(session_factory, count): + async with session_factory() as session: + for i in range(count): + session.add(User(username=f"u{i}", password_hash="x", derivation_index=i, address=derive_user_address(i))) + await session.commit() + + +def _hash(index): + return address_to_scripthash(derive_user_address(index)) + + +async def _audit(session_factory): + async with session_factory() as session: + return list((await session.execute(select(AuditLog).where(AuditLog.event_type == "restore_gap_detected"))).scalars()) + + +async def test_registration_opens_when_the_next_20_indexes_are_unused(session_factory): + await _users(session_factory, 3) + listener = _listener(session_factory, _Server(), [_Server(), _Server()]) + await listener._check_restore_gap() + assert listener.registration_open and await _audit(session_factory) == [] + + +async def test_one_server_reporting_history_is_enough_to_keep_it_closed(session_factory): + """REG-15: the checked window is highest + 1 … highest + 20; any server's word counts.""" + await _users(session_factory, 3) # highest assigned index 2 -> checks 3..22 + listener = _listener(session_factory, _Server(), [_Server(used={_hash(22)}), _Server()]) + await listener._check_restore_gap() + assert not listener.registration_open + (row,) = await _audit(session_factory) + payload = json.loads(row.payload_json) + assert payload["highest_assigned_index"] == 2 + assert payload["used"] == [{"derivation_index": 22, "address": derive_user_address(22)}] + + +async def test_an_index_just_past_the_window_is_not_looked_at(session_factory): + await _users(session_factory, 3) + listener = _listener(session_factory, _Server(used={_hash(23)}), [_Server(), _Server()]) + await listener._check_restore_gap() + assert listener.registration_open + + +async def test_an_empty_database_checks_indexes_0_to_19(session_factory): + listener = _listener(session_factory, _Server(used={_hash(0)}), [_Server(), _Server()]) + await listener._check_restore_gap() + (row,) = await _audit(session_factory) + assert json.loads(row.payload_json)["highest_assigned_index"] is None + + +async def test_without_the_active_servers_answer_nothing_is_decided(session_factory): + """The check is simply tried again on the next session; registration stays closed.""" + listener = _listener(session_factory, None, [_Server(), _Server()]) + with pytest.raises(RuntimeError): + await listener._check_restore_gap() + assert not listener.registration_open and not listener._restore_checked + + +async def test_an_unreachable_quorum_server_does_not_block_a_clean_result(session_factory): + listener = _listener(session_factory, _Server(), [_Server(fail=True), _Server()]) + await listener._check_restore_gap() + assert listener.registration_open + + +async def test_register_is_refused_while_the_check_has_not_passed(session_factory, monkeypatch): + from httpx import ASGITransport, AsyncClient + + from app.main import app + + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + monkeypatch.setattr(ElectrumListener, "registration_open", False) + app.state.electrum_listener = listener + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as client: + response = await client.post("/auth/register", json={"username": "alice", "password": "password123"}) + assert response.status_code == 503 + assert response.json()["detail"]["code"] == "registration_unavailable" + + +async def test_start_paused_pauses_as_the_operator_without_overwriting_an_automatic_reason(session_factory): + from app.rounds.pause import pause_automatically, pause_by_operator + + async with session_factory() as session: + await pause_by_operator(session, "START_PAUSED") + await session.commit() + config = (await session.execute(select(RoundConfig))).scalar_one() + assert config.paused and config.paused_reason == "operator" + await pause_automatically(session, "reorg") + await pause_by_operator(session, "START_PAUSED") + await session.commit() + assert config.paused_reason == "reorg" # an automatic reason is the one that needs a look + events = (await session.execute(select(AuditLog).where(AuditLog.event_type == "lottery_paused"))).scalars().all() + assert [json.loads(e.payload_json)["reason"] for e in events] == ["operator", "reorg", "operator"]