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
@@ -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