Safe restore from a backup: START_PAUSED and the derivation-gap check (B-90, REG-15, OPS-27)
START_PAUSED=true pauses the lottery as the operator before the scheduler exists. On the first Electrum session the 20 derivation indexes past the highest assigned one are looked up on every server; if any has history, registration stays closed (503 registration_unavailable) and restore_gap_detected is written, so a restored database cannot hand a lost user's address to a new one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
55d852fe08
commit
40ac490826
21 files changed
+281
-70
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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': '这是你自己的充值地址 — 请提现到外部钱包。',
|
||||
|
||||
+4
-5
@@ -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 |
|
||||
|
||||
@@ -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<br/>(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
|
||||
|
||||
+3
-8
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-12
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-5
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user