Files
davideandClaude Sonnet 5.5 40ac490826 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>
2026-10-18 00:43:45 +02:00

134 lines
5.7 KiB
Python

"""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"]