diff --git a/app/electrum/client.py b/app/electrum/client.py index faf17b4..58ec96e 100644 --- a/app/electrum/client.py +++ b/app/electrum/client.py @@ -7,6 +7,8 @@ import re import ssl from typing import NamedTuple +from app.wallet.plm_network import GENESIS_HASH + logger = logging.getLogger(__name__) @@ -192,6 +194,20 @@ class ElectrumClient: raise self._read_task = asyncio.create_task(self._read_loop()) await self.request("server.version", ["plm-lottery", "1.4"]) + try: + await self._check_genesis() + except ElectrumError: + self._read_task.cancel() + self._writer.close() + raise + + async def _check_genesis(self) -> None: + """The server must be on PLM's chain, not just any chain with valid headers: PLM shares + Bitcoin's genesis and powLimit, so only `server.features` tells them apart (B-99, ELC-30).""" + features = await self.request("server.features") + genesis = features.get("genesis_hash") if isinstance(features, dict) else None + if genesis != GENESIS_HASH: + raise ElectrumError(f"{self.host}:{self.port} is not on the PLM chain (genesis {genesis!r})") def _check_pin(self) -> None: """The server's DER certificate must hash to the pinned SHA-256 (B-84, ELC-03).""" diff --git a/app/wallet/plm_network.py b/app/wallet/plm_network.py index 499feec..737ff13 100644 --- a/app/wallet/plm_network.py +++ b/app/wallet/plm_network.py @@ -26,3 +26,7 @@ PLM_MAINNET = { BIP44_COIN_TYPE = 746 ACCOUNT_PATH = f"m/84h/{BIP44_COIN_TYPE}h/0h" + +# PLM shares Bitcoin's genesis block (palladiumcore chainparams.cpp) and its powLimit, so a +# Bitcoin Electrum server would pass every header check; the handshake asks for this instead. +GENESIS_HASH = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" diff --git a/docs/connessione-electrum.md b/docs/connessione-electrum.md index 63bda18..dff4867 100644 --- a/docs/connessione-electrum.md +++ b/docs/connessione-electrum.md @@ -62,6 +62,16 @@ Le difese sono cinque. di [ELC-14]. *Codice:* `app/config.py:validate_quorum_servers`, `app/main.py:lifespan` · *Test:* `tests/unit/test_electrum_listener.py` +- **[ELC-30]** Subito dopo `server.version` la connessione chiede + `server.features`: il `genesis_hash` deve essere quello di PLM + (`000000000019d668…ce26f`). PLM condivide genesis e `powLimit` con Bitcoin + (`palladiumcore/src/chainparams.cpp`), quindi un server Electrum di Bitcoin + supererebbe ogni controllo sugli header di [ELC-11]; solo il genesis lo + distingue. Un genesis diverso o assente, o un errore alla richiesta, fa + fallire la connessione come un server irraggiungibile ([ELC-02]); vale anche + per le connessioni del quorum ([ELC-14]). + *Codice:* `app/electrum/client.py:ElectrumClient._check_genesis`, `app/wallet/plm_network.py:GENESIS_HASH` · + *Test:* `tests/unit/test_electrum_client.py` ## 2. Richieste con limite di tempo @@ -338,11 +348,14 @@ B-83). - **[ELC-29]** Il listener espone lo stato della connessione: se è connesso, il server attivo, l'altezza della punta e da quanti secondi è stato accettato l'ultimo header. Lo leggono `GET /health/ready` (non pronto se non - connesso o se l'ultimo header ha più di 360 secondi, + connesso o se l'ultimo header ha più di 1800 secondi (con blocchi da 120 s + una soglia di 360 s scatterebbe a vuoto ogni ventina di blocchi, + e^-3 ≈ 5%; la rotazione di [ELC-21] resta a 360 s perché agisce solo se un + altro server ha davvero una punta più alta), [api.md](api.md) B-92) e l'indicatore Electrum di `/admin`. L'altezza corrente resta visibile anche nella barra di stato della catena di `/` (da `chain_tip_height` di `GET /rounds/current`). *Codice:* `app/electrum/listener.py:ElectrumListener.current_endpoint`, `app/main.py:health` · *Test:* — - *Stato:* da implementare (B-86, B-92) · *Oggi:* `/health` risponde sempre + *Stato:* da implementare (B-92) · *Oggi:* `/health` risponde sempre `ok`; l'età dell'ultimo header non è registrata da nessuna parte e `/admin` mostra solo l'altezza. diff --git a/tests/unit/test_electrum_client.py b/tests/unit/test_electrum_client.py index 5078be4..44dc22a 100644 --- a/tests/unit/test_electrum_client.py +++ b/tests/unit/test_electrum_client.py @@ -325,12 +325,30 @@ class _PinWriter(FakeWriter): self.closed = True +def _answer_features(reader, writer, result=None): + """Make `writer` answer `server.features` the moment it is asked: a reply fed up front + would be read before its request is registered, and dropped.""" + from app.wallet.plm_network import GENESIS_HASH + + original = writer.write + + def write(data): + original(data) + sent = json.loads(data.decode()) + if sent["method"] == "server.features": + reply = {"genesis_hash": GENESIS_HASH} if result is None else result + reader.feed_data((json.dumps({"id": sent["id"], "result": reply}) + "\n").encode()) + + writer.write = write + + async def _connect_with_pin(monkeypatch, pin, der): import hashlib # noqa: F401 writer = _PinWriter(der) reader = asyncio.StreamReader() reader.feed_data(b'{"id": 1, "result": ["fake", "1.4"]}\n') + _answer_features(reader, writer) async def fake_open_connection(host, port, **kwargs): return reader, writer @@ -413,3 +431,22 @@ async def test_only_a_server_error_object_is_an_explicit_refusal(): silent._read_task.cancel() finally: client_module._REQUEST_TIMEOUT_SECONDS = original + + +async def test_a_server_on_another_chain_is_refused_at_the_handshake(monkeypatch): + """ELC-30: Bitcoin's genesis (or no genesis at all) fails like an unreachable server.""" + import pytest + + bitcoin = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26e" + for result in ({"genesis_hash": bitcoin}, {}): + writer = FakeWriter() + reader = asyncio.StreamReader() + reader.feed_data(b'{"id": 1, "result": ["fake", "1.4"]}\n') + _answer_features(reader, writer, result) + + async def fake_open_connection(host, port, reader=reader, writer=writer, **kwargs): + return reader, writer + + monkeypatch.setattr(asyncio, "open_connection", fake_open_connection) + with pytest.raises(ElectrumError, match="not on the PLM chain"): + await ElectrumClient("localhost", 1).connect()