Require a real Electrum quorum and allow certificate pinning (B-84, ELC-01, ELC-03, ELC-14, ELC-19, ARC-20, OPS-12)
An empty ELECTRUM_FALLBACK_SERVERS (the .env.example default) silently made every corroboration pass, and with fallbacks the majority was taken over whoever answered, so one reachable fallback decided alone. TLS certificates were never checked. - _corroborate_majority passes only with at least 2 answers (MIN_QUORUM_RESPONSES) and a strict majority of the answers; no other server configured, or fewer than 2 answering, fails. ALLOW_NO_QUORUM=true keeps the old reduced rule. - config.validate_quorum_servers, called from the lifespan after parse_endpoints, refuses to start with fewer than 2 fallback servers unless ALLOW_NO_QUORUM=true (which logs a warning at every start). - Optional pinning: `host:port[:mode]:pin=<sha256>` per fallback and ELECTRUM_CERT_PIN for the primary; ElectrumClient compares the SHA-256 of the DER certificate after the TLS handshake and a mismatch fails like an unreachable server. A pin on a plaintext entry is a startup error. - .env.example now ships the four bootstrap servers and documents both new variables; docs/setup.md, spec, storico and BUGS.md updated. - Tests changed because the spec changed: the two "no other servers configured -> True" corroboration tests now assert that outcome only under allow_no_quorum. Note: a dev .env with fewer than 2 fallback servers will now refuse to start. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fd6a91e93d
commit
13cb66cc06
18 files changed
+451
-125
No files matched your search
+20
-5
@@ -3,17 +3,32 @@ ELECTRUM_PORT=50002
|
||||
ELECTRUM_USE_SSL=true
|
||||
|
||||
# Additional Electrum servers to fall back to, comma-separated. Each entry is
|
||||
# `host:port` (TLS, the normal case) or `host:port:notls`. The listener rotates
|
||||
# over the primary above plus these, so one unreachable server costs a single
|
||||
# reconnect attempt instead of an outage — every deposit credit, broadcast and
|
||||
# `host:port` (TLS, the normal case), `host:port:notls`, or `host:port:pin=<sha256>`
|
||||
# (TLS, and the server's DER certificate must hash to that SHA-256, in hex). The
|
||||
# listener rotates over the primary above plus these, so one unreachable server costs
|
||||
# a single reconnect attempt instead of an outage — every deposit credit, broadcast and
|
||||
# confirmation goes through this one connection, which makes a single server the
|
||||
# platform's biggest single point of failure. A typo here fails at startup rather
|
||||
# than during the outage when the fallback is what you need.
|
||||
#
|
||||
# AT LEAST 2 are required (B-84): every decision that moves money is corroborated by
|
||||
# the *other* servers and needs two independent answers, so with fewer than two
|
||||
# fallbacks the server refuses to start (override: ALLOW_NO_QUORUM, development only).
|
||||
# Configure three or more in production, so one being down does not stop the lottery.
|
||||
#
|
||||
# These are the mainnet bootstrap servers from the PalladiumWallet repo
|
||||
# (src/Core/Chain/ChainProfiles.cs, ChainProfiles.Mainnet.BootstrapServers) — the
|
||||
# same ones the reference wallet falls back to:
|
||||
# Example: ELECTRUM_FALLBACK_SERVERS=173.212.224.67:50002,144.91.120.225:50002,66.94.115.80:50002,89.117.149.130:50002
|
||||
ELECTRUM_FALLBACK_SERVERS=
|
||||
ELECTRUM_FALLBACK_SERVERS=173.212.224.67:50002,144.91.120.225:50002,66.94.115.80:50002,89.117.149.130:50002
|
||||
|
||||
# Optional: SHA-256 (hex) of the primary server's DER certificate. A connection that
|
||||
# presents another certificate fails like an unreachable server. Empty = not pinned.
|
||||
ELECTRUM_CERT_PIN=
|
||||
|
||||
# Development only: start with fewer than 2 fallback servers (corroboration then falls
|
||||
# back to the weak rule: a single answer decides, or nothing is checked at all).
|
||||
# NEVER true in production.
|
||||
ALLOW_NO_QUORUM=false
|
||||
|
||||
# Fernet key protecting the master xprv at rest. Generate with:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
|
||||
@@ -237,16 +237,6 @@ Fix: a confirmation is accepted once the quorum returns a valid merkle proof
|
||||
(`blockchain.transaction.get_merkle`) against a header we hold; reinstatement needs
|
||||
the same quorum as a credit (B-59); broadcasts fan out (B-76).
|
||||
|
||||
### B-84 — The quorum can be one server, or none, silently
|
||||
|
||||
An empty `ELECTRUM_FALLBACK_SERVERS` (the `.env.example` default) disables every
|
||||
corroboration; with fallbacks, the majority is over whoever answers, so one reachable
|
||||
fallback decides. TLS certificates are not verified.
|
||||
|
||||
Fix: at least 2 fallbacks and at least 2 responders for every corroboration, or the
|
||||
server refuses to start (override `ALLOW_NO_QUORUM=true`, development only); optional
|
||||
certificate pinning per endpoint (`host:port:pin=<sha256>`).
|
||||
|
||||
### B-85 — Reorgs are neither followed nor surfaced
|
||||
|
||||
Nothing detects a reorg; a 1-block reorg is treated as a hostile server (B-74).
|
||||
|
||||
@@ -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 — 414 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 — 425 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 (1-conf), 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 414 tests
|
||||
python -m pytest # all 425 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
|
||||
```
|
||||
@@ -154,11 +154,11 @@ Chain access goes through `listener.client`, passed as `lambda: listener.client`
|
||||
|
||||
One connection serves everything — deposit credits, broadcasts, confirmations, the tip the draw waits on — so it's both the biggest single point of failure and, with a hostile server on the other end, the biggest integrity risk. Five defences:
|
||||
|
||||
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
|
||||
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls][:pin=<sha256>]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed; `pin=` fixes the server's DER certificate hash, `ELECTRUM_CERT_PIN` does the same for the primary, B-84). The server **refuses to start with fewer than 2 fallbacks** (`config.validate_quorum_servers`, B-84; `ALLOW_NO_QUORUM=true` overrides it, development only). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
|
||||
- **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets` *while holding the per-user lock*, and could stall the confirmation poller permanently.
|
||||
- **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
|
||||
- **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. Two more headers are refused *without* ending the session, since neither implies a hostile server (B-64): one at a height we already hold a header for (a reorg at the tip, or one server disagreeing — the hash committed to for a height is never swapped under us, and `corroborate_header` is what catches us holding an orphan), and one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63.
|
||||
- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — with one exception today: `deposits/service.py:reinstate_reappeared_utxos` restores a reappeared UTXO on the active server's word alone. No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing. A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep.
|
||||
- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — with one exception today: `deposits/service.py:reinstate_reappeared_utxos` restores a reappeared UTXO on the active server's word alone. It needs **at least 2 answers** and a strict majority of those that answered (B-84): fewer → returns **False**, since one voice decides nothing independently and an unreachable network proves nothing. Only `ALLOW_NO_QUORUM=true` keeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep.
|
||||
|
||||
On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep.
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ python -m pytest # all tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
```
|
||||
|
||||
414 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
425 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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Minimum JWT signing key length. HS256 keys shorter than the hash output weaken
|
||||
@@ -5,6 +7,14 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
# than being shipped by accident.
|
||||
MIN_JWT_SECRET_LENGTH = 32
|
||||
|
||||
# B-84 (ELC-19, ELC-14): fallback servers needed besides the primary. With fewer than
|
||||
# three servers, a corroboration (which never asks the active one) cannot have two
|
||||
# independent answers — one server would decide, or none would.
|
||||
MIN_QUORUM_FALLBACK_SERVERS = 2
|
||||
MIN_QUORUM_RESPONSES = 2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
@@ -21,6 +31,15 @@ class Settings(BaseSettings):
|
||||
# connection, which makes a single hardcoded server the platform's biggest
|
||||
# single point of failure. Parsed by electrum.client.parse_endpoints.
|
||||
electrum_fallback_servers: str = ""
|
||||
# B-84 (ELC-03): SHA-256 (hex) of the primary server's DER certificate. Empty = not
|
||||
# pinned. Fallback servers carry their own `:pin=<sha256>` in the list above.
|
||||
electrum_cert_pin: str = ""
|
||||
# B-84 (ELC-19): development only. Lets the server start with fewer than
|
||||
# MIN_QUORUM_FALLBACK_SERVERS fallback servers, and corroboration then follows the
|
||||
# reduced rule (no other server: passes; otherwise a strict majority of however
|
||||
# many answer, even one). Never for production: it is exactly the single-server
|
||||
# trust the quorum exists to remove.
|
||||
allow_no_quorum: bool = False
|
||||
|
||||
xprv_encryption_key: str = ""
|
||||
master_key_path: str = "./master.xprv.enc"
|
||||
@@ -79,3 +98,33 @@ def validate_runtime_secrets(config: Settings | None = None) -> None:
|
||||
)
|
||||
if problems:
|
||||
raise ConfigError("invalid configuration in .env: " + "; ".join(problems))
|
||||
|
||||
|
||||
def validate_quorum_servers(endpoints: list, config: Settings | None = None) -> None:
|
||||
"""B-84 (ELC-19): refuse to serve with fewer than MIN_QUORUM_FALLBACK_SERVERS
|
||||
fallback servers (`endpoints` is the deduplicated rotation: primary first, then the
|
||||
fallbacks), unless ALLOW_NO_QUORUM=true. The old behaviour — an empty
|
||||
ELECTRUM_FALLBACK_SERVERS, the .env.example default — silently made every
|
||||
corroboration pass, i.e. one server decided the draw, the credits and the spends.
|
||||
|
||||
Called from the lifespan, after parse_endpoints, rather than from
|
||||
validate_runtime_secrets: it needs the parsed list, and a Settings validator would
|
||||
couple every import (tests included) to a populated .env.
|
||||
"""
|
||||
config = config or settings
|
||||
fallbacks = len(endpoints) - 1
|
||||
if fallbacks >= MIN_QUORUM_FALLBACK_SERVERS:
|
||||
return
|
||||
if config.allow_no_quorum:
|
||||
logger.warning(
|
||||
"ALLOW_NO_QUORUM=true: only %s fallback Electrum server(s) configured (%s needed) — "
|
||||
"corroboration is reduced to the weaker rule. Development only, never in production.",
|
||||
fallbacks,
|
||||
MIN_QUORUM_FALLBACK_SERVERS,
|
||||
)
|
||||
return
|
||||
raise ConfigError(
|
||||
f"ELECTRUM_FALLBACK_SERVERS lists {fallbacks} usable fallback server(s) besides the primary, "
|
||||
f"but at least {MIN_QUORUM_FALLBACK_SERVERS} are required so every corroboration has two independent "
|
||||
"answers (set ALLOW_NO_QUORUM=true to override, development only)"
|
||||
)
|
||||
+78
-17
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
@@ -13,50 +14,91 @@ logger = logging.getLogger(__name__)
|
||||
class ElectrumEndpoint(NamedTuple):
|
||||
"""One server to connect to. The listener rotates over a list of these so a
|
||||
single unreachable or misbehaving server doesn't take the platform down —
|
||||
every consumer of PLM chain data goes through this one connection."""
|
||||
every consumer of PLM chain data goes through this one connection.
|
||||
|
||||
`pin` (B-84, ELC-03) is the SHA-256 of the server's DER certificate, in lower-case
|
||||
hex; when set, a connection presenting any other certificate fails like an
|
||||
unreachable server. Only meaningful with TLS."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
use_ssl: bool = True
|
||||
pin: str | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}"
|
||||
return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}{' (pinned)' if self.pin else ''}"
|
||||
|
||||
|
||||
_PIN_PATTERN = re.compile(r"[0-9a-f]{64}")
|
||||
|
||||
|
||||
def normalize_pin(raw: str, entry: str) -> str:
|
||||
"""A certificate pin as lower-case hex, or ValueError: 64 hexadecimal digits."""
|
||||
pin = raw.strip().lower()
|
||||
if not _PIN_PATTERN.fullmatch(pin):
|
||||
raise ValueError(f"invalid certificate pin in Electrum server {entry!r}: expected 64 hex digits (sha256)")
|
||||
return pin
|
||||
|
||||
|
||||
def parse_endpoints(
|
||||
primary_host: str, primary_port: int, primary_use_ssl: bool, fallback_spec: str
|
||||
primary_host: str,
|
||||
primary_port: int,
|
||||
primary_use_ssl: bool,
|
||||
fallback_spec: str,
|
||||
primary_pin: str = "",
|
||||
) -> list[ElectrumEndpoint]:
|
||||
"""Build the connection rotation: the primary server first, then whatever
|
||||
ELECTRUM_FALLBACK_SERVERS lists.
|
||||
|
||||
`fallback_spec` is comma-separated, each entry `host:port` (TLS, the normal
|
||||
case) or `host:port:notls`. Malformed entries raise ValueError rather than
|
||||
being skipped: a typo in a fallback server is something to fix at startup,
|
||||
not to discover during an outage, when the fallback is what's needed.
|
||||
case), `host:port:notls`, `host:port:pin=<sha256>` (TLS with a pinned certificate,
|
||||
B-84) or `host:port:notls`/`ssl`/`tls` followed by `:pin=<sha256>`. A pin on a
|
||||
plaintext entry is refused: there is no certificate to pin. `primary_pin` is the
|
||||
same pin for the primary (ELECTRUM_CERT_PIN). Malformed entries raise ValueError
|
||||
rather than being skipped: a typo in a fallback server is something to fix at
|
||||
startup, not to discover during an outage, when the fallback is what's needed.
|
||||
Duplicates are dropped, keeping first position.
|
||||
"""
|
||||
endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl)]
|
||||
syntax = "expected host:port[:notls][:pin=<sha256>]"
|
||||
pin = normalize_pin(primary_pin, "ELECTRUM_CERT_PIN") if primary_pin.strip() else None
|
||||
if pin and not primary_use_ssl:
|
||||
raise ValueError("ELECTRUM_CERT_PIN needs TLS: ELECTRUM_USE_SSL is off")
|
||||
endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl, pin)]
|
||||
for raw in fallback_spec.split(","):
|
||||
entry = raw.strip()
|
||||
if not entry:
|
||||
continue
|
||||
parts = entry.split(":")
|
||||
if len(parts) not in (2, 3):
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]")
|
||||
if not 2 <= len(parts) <= 4:
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: {syntax}")
|
||||
host, port = parts[0].strip(), parts[1].strip()
|
||||
if not host or not port.isdigit():
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]")
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: {syntax}")
|
||||
use_ssl = True
|
||||
if len(parts) == 3:
|
||||
flag = parts[2].strip().lower()
|
||||
if flag not in ("ssl", "tls", "notls", "plain"):
|
||||
pin = None
|
||||
mode_seen = False
|
||||
for extra in parts[2:]:
|
||||
extra = extra.strip()
|
||||
if extra.lower().startswith("pin="):
|
||||
if pin is not None:
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: more than one pin")
|
||||
pin = normalize_pin(extra[4:], entry)
|
||||
continue
|
||||
flag = extra.lower()
|
||||
if mode_seen or flag not in ("ssl", "tls", "notls", "plain"):
|
||||
raise ValueError(f"invalid TLS flag {flag!r} in Electrum server {entry!r}")
|
||||
mode_seen = True
|
||||
use_ssl = flag in ("ssl", "tls")
|
||||
endpoints.append(ElectrumEndpoint(host, int(port), use_ssl))
|
||||
if pin and not use_ssl:
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: a pin needs TLS")
|
||||
endpoints.append(ElectrumEndpoint(host, int(port), use_ssl, pin))
|
||||
|
||||
deduped: list[ElectrumEndpoint] = []
|
||||
seen: set[tuple] = set()
|
||||
for endpoint in endpoints:
|
||||
if endpoint not in deduped:
|
||||
key = (endpoint.host, endpoint.port, endpoint.use_ssl)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
deduped.append(endpoint)
|
||||
return deduped
|
||||
|
||||
@@ -100,10 +142,11 @@ class ElectrumClient:
|
||||
notification queues nobody will ever fill again.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int, use_ssl: bool = True):
|
||||
def __init__(self, host: str, port: int, use_ssl: bool = True, pin: str | None = None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.use_ssl = use_ssl
|
||||
self.pin = pin.lower() if pin else None
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._id_counter = itertools.count(1)
|
||||
@@ -115,7 +158,10 @@ class ElectrumClient:
|
||||
async def connect(self) -> None:
|
||||
# Electrum servers commonly present self-signed certs; the protocol's trust
|
||||
# model is server consensus, not TLS PKI, so we only use SSL for transport
|
||||
# encryption and don't verify the certificate chain/hostname.
|
||||
# encryption and don't verify the certificate chain/hostname. What can be
|
||||
# asked instead is that the certificate be *the* one the operator pinned
|
||||
# (B-84, ELC-03): without a pin, whoever sits on the path to a server takes
|
||||
# its place, and only the quorum stands between that and the draw.
|
||||
ssl_context = None
|
||||
if self.use_ssl:
|
||||
ssl_context = ssl.create_default_context()
|
||||
@@ -124,9 +170,24 @@ class ElectrumClient:
|
||||
self._reader, self._writer = await asyncio.open_connection(
|
||||
self.host, self.port, ssl=ssl_context, limit=_MAX_LINE_BYTES
|
||||
)
|
||||
if self.pin:
|
||||
try:
|
||||
self._check_pin()
|
||||
except ElectrumError:
|
||||
self._writer.close()
|
||||
raise
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
await self.request("server.version", ["plm-lottery", "1.4"])
|
||||
|
||||
def _check_pin(self) -> None:
|
||||
"""The server's DER certificate must hash to the pinned SHA-256 (B-84, ELC-03)."""
|
||||
ssl_object = self._writer.get_extra_info("ssl_object") if self._writer is not None else None
|
||||
der = ssl_object.getpeercert(binary_form=True) if ssl_object is not None else None
|
||||
if not der:
|
||||
raise ElectrumError(f"{self.host}:{self.port} presented no certificate to check against the pin")
|
||||
if hashlib.sha256(der).hexdigest() != self.pin:
|
||||
raise ElectrumError(f"{self.host}:{self.port} certificate does not match the configured pin")
|
||||
|
||||
async def close(self) -> None:
|
||||
self._closed.set()
|
||||
if self._read_task is not None:
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.deposits.service import (
|
||||
mark_utxos_spent_externally,
|
||||
reinstate_reappeared_utxos,
|
||||
)
|
||||
from app.config import MIN_QUORUM_RESPONSES
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.draw import (
|
||||
@@ -63,10 +64,13 @@ class ElectrumListener:
|
||||
client_factory: Callable[[ElectrumEndpoint], ElectrumClient],
|
||||
session_factory: async_sessionmaker,
|
||||
endpoints: list[ElectrumEndpoint] | None = None,
|
||||
allow_no_quorum: bool = False,
|
||||
):
|
||||
self._client_factory = client_factory
|
||||
self._session_factory = session_factory
|
||||
self._endpoints = list(endpoints or [])
|
||||
# B-84 (ELC-19): ALLOW_NO_QUORUM, development only — see _corroborate_majority.
|
||||
self._allow_no_quorum = allow_no_quorum
|
||||
self._endpoint_index = 0
|
||||
self._scripthash_to_user: dict[str, int] = {}
|
||||
# Retains address_for_new_user's fire-and-forget subscribe task so it
|
||||
@@ -363,21 +367,36 @@ class ElectrumListener:
|
||||
would control) and require a strict majority of the ones that actually
|
||||
answer to agree, via `agrees`, with what our own connection reported.
|
||||
|
||||
Returns True with no other servers configured — nothing to corroborate
|
||||
against, a risk accepted when ELECTRUM_FALLBACK_SERVERS was left empty
|
||||
(see CLAUDE.md). Returns False (never silently "passes") if none of the
|
||||
others could be reached, since an unreachable network proves nothing
|
||||
either way.
|
||||
The quorum is strict (B-84, ELC-14): it passes only when **at least
|
||||
MIN_QUORUM_RESPONSES** of the other servers answered *and* a strict majority of
|
||||
those that answered agree. Fewer answers — one reachable server, or none, or no
|
||||
other server configured at all — fail, since a lone voice decides nothing
|
||||
independently and an unreachable network proves nothing. Startup already refuses
|
||||
to run with fewer than two fallbacks (config.validate_quorum_servers), so with a
|
||||
sound configuration "too few answered" means servers are down, and the
|
||||
corroboration simply fails (a delay, never a loss — the callers retry).
|
||||
|
||||
Only with ALLOW_NO_QUORUM=true (development) does the reduced rule apply: no
|
||||
other server configured passes, and otherwise a strict majority of whoever
|
||||
answered decides, even a single answer.
|
||||
"""
|
||||
others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
|
||||
if not others:
|
||||
if not others and self._allow_no_quorum:
|
||||
return True
|
||||
if not others:
|
||||
logger.warning("could not corroborate %s: no other Electrum server is configured", description)
|
||||
return False
|
||||
|
||||
results = await asyncio.gather(*(ask(endpoint) for endpoint in others))
|
||||
responded = [result for result in results if result is not None]
|
||||
if not responded:
|
||||
minimum = 1 if self._allow_no_quorum else MIN_QUORUM_RESPONSES
|
||||
if len(responded) < minimum:
|
||||
logger.warning(
|
||||
"could not corroborate %s with any of %s other configured server(s)", description, len(others)
|
||||
"could not corroborate %s: %s of %s other configured server(s) answered, %s needed",
|
||||
description,
|
||||
len(responded),
|
||||
len(others),
|
||||
minimum,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
+7
-3
@@ -22,7 +22,7 @@ from app.api.routes.users import router as users_router
|
||||
from app.api.routes.withdrawals import router as withdrawals_router
|
||||
from app.auth.routes import router as auth_router
|
||||
from app.api.errors import ApiError
|
||||
from app.config import settings, validate_runtime_secrets
|
||||
from app.config import settings, validate_quorum_servers, validate_runtime_secrets
|
||||
from app.db.base import AsyncSessionLocal
|
||||
from app.deposits.reconcile import DepositReconciler
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints
|
||||
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient:
|
||||
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl)
|
||||
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl, endpoint.pin)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -50,10 +50,14 @@ async def lifespan(app: FastAPI):
|
||||
settings.electrum_port,
|
||||
settings.electrum_use_ssl,
|
||||
settings.electrum_fallback_servers,
|
||||
settings.electrum_cert_pin,
|
||||
)
|
||||
validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum
|
||||
logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints))
|
||||
|
||||
listener = ElectrumListener(_make_electrum_client, AsyncSessionLocal, endpoints)
|
||||
listener = ElectrumListener(
|
||||
_make_electrum_client, AsyncSessionLocal, endpoints, allow_no_quorum=settings.allow_no_quorum
|
||||
)
|
||||
app.state.electrum_listener = listener
|
||||
app.state.user_locks = UserLocks()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-07 — Ogni decisione che sposta denaro in base a una risposta di rete è corroborata da un quorum degli altri server configurati
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-83, B-84)
|
||||
- **Stato:** Accettata — implementazione da completare (B-83)
|
||||
- **Area:** Conferme e fiducia nella rete
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -33,6 +33,5 @@ di 2 server, la corroborazione fallisce.
|
||||
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` · `app/electrum/listener.py:ElectrumListener.corroborate_header` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` · `app/main.py:lifespan` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-83, B-84) · *Oggi:* senza server di riserva la
|
||||
corroborazione passa sempre, basta una risposta, e conferme, ripristini e
|
||||
abbandoni si decidono sul solo server attivo.
|
||||
*Stato:* da implementare (B-83) · *Oggi:* conferme, ripristini e abbandoni
|
||||
si decidono sul solo server attivo.
|
||||
+9
-12
@@ -342,7 +342,7 @@ sequenceDiagram
|
||||
U->>L: startup
|
||||
L->>L: validate_runtime_secrets()
|
||||
L->>L: parse_endpoints(...)
|
||||
L->>L: quorum minimo (B-84, da implementare)
|
||||
L->>L: quorum minimo (validate_quorum_servers)
|
||||
L->>L: verifica della master key (B-89, da implementare)
|
||||
L->>L: START_PAUSED (B-90, da implementare)
|
||||
L->>L: app.state.electrum_listener, app.state.user_locks
|
||||
@@ -424,8 +424,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
|
||||
scritto nel log.
|
||||
*Codice:* `app/electrum/client.py:parse_endpoints` ·
|
||||
*Test:* `tests/unit/test_electrum_client.py`
|
||||
*Stato:* da implementare (B-84) · *Oggi:* la sintassi è `host:porta[:notls]`,
|
||||
senza pin del certificato.
|
||||
|
||||
- **[ARC-60]** **Controlli di avvio.** Dopo [ARC-19] e [ARC-20] e prima di
|
||||
creare i task, il lifespan esegue nell'ordine, e ogni controllo fallito
|
||||
@@ -448,11 +446,11 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
|
||||
dei task: gira alla prima connessione del listener, e finché non si è
|
||||
concluso senza trovare cronologia la registrazione è rifiutata
|
||||
([operativita.md](operativita.md#ripristino), B-90).
|
||||
*Codice:* `app/main.py:lifespan` · `app/config.py:validate_runtime_secrets` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-84, B-89, B-90) · *Oggi:* dopo i segreti e gli
|
||||
endpoint il lifespan crea subito i task; la chiave si decifra alla prima
|
||||
registrazione o al primo pagamento.
|
||||
*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-89, B-90), per i controlli 2 e 3 · *Oggi:* dopo
|
||||
i segreti, gli endpoint e il quorum il lifespan crea subito i task; la
|
||||
chiave si decifra alla prima registrazione o al primo pagamento.
|
||||
- **[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
|
||||
@@ -610,10 +608,9 @@ 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-84, B-90, B-91) · *Oggi:* `ALLOW_NO_QUORUM`,
|
||||
`START_PAUSED` e `XPRV_ENCRYPTION_KEY_FILE` non esistono, la chiave Fernet è
|
||||
la variabile `XPRV_ENCRYPTION_KEY` e la sintassi delle riserve non ha
|
||||
`pin=`.
|
||||
*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`.
|
||||
- **[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
|
||||
|
||||
@@ -27,15 +27,14 @@ Le difese sono cinque.
|
||||
`ELECTRUM_USE_SSL`) seguito da quelli di riserva. Ogni voce di riserva è
|
||||
`host:porta` (TLS), `host:porta:<modo>` con `<modo>` tra `ssl`/`tls` (TLS) e
|
||||
`notls`/`plain` (in chiaro), oppure `host:porta:pin=<sha256>` (TLS con
|
||||
certificato fissato, [ELC-03]); `<sha256>` sono 64 cifre esadecimali. Le
|
||||
certificato fissato, [ELC-03]); `<sha256>` sono 64 cifre esadecimali (maiuscole accettate, conservate in
|
||||
minuscolo); `pin=` può seguire anche `<modo>` con TLS, mai `notls`/`plain`
|
||||
(non c'è un certificato da fissare). Le
|
||||
voci vuote sono ignorate e i duplicati eliminati mantenendo la prima
|
||||
posizione. Una voce malformata blocca l'avvio subito, non durante il guasto
|
||||
in cui servirebbe.
|
||||
*Codice:* `app/electrum/client.py:parse_endpoints` ·
|
||||
*Test:* `tests/unit/test_electrum_client.py`
|
||||
*Stato:* da implementare (B-84), per la forma `pin=` · *Oggi:* il terzo
|
||||
campo accetta solo `ssl`/`tls`/`notls`/`plain`; `pin=…` è una voce
|
||||
malformata.
|
||||
- **[ELC-02]** Dopo ogni sessione fallita o caduta si prova **subito** il
|
||||
server successivo nell'elenco, a rotazione; l'attesa crescente (da 1s
|
||||
raddoppiando fino a 30s) scatta solo dopo che ogni server ha fallito una
|
||||
@@ -52,9 +51,8 @@ Le difese sono cinque.
|
||||
irraggiungibile e si passa al successivo ([ELC-02]). Senza pin la fiducia
|
||||
non viene dal TLS ma dal quorum ([§ 5](#5-il-quorum)). Ogni connessione si
|
||||
apre con la stretta di mano `server.version`.
|
||||
*Codice:* `app/electrum/client.py:ElectrumClient.connect` · *Test:* —
|
||||
*Stato:* da implementare (B-84) · *Oggi:* nessun certificato viene mai
|
||||
verificato; non esiste un modo di fissarlo.
|
||||
*Codice:* `app/electrum/client.py:ElectrumClient.connect`, `app/electrum/client.py:ElectrumClient._check_pin`, `app/main.py:_make_electrum_client` ·
|
||||
*Test:* `tests/unit/test_electrum_client.py`
|
||||
- **[ELC-19]** Il server **rifiuta di avviarsi** se, dopo l'eliminazione dei
|
||||
duplicati, l'elenco contiene meno di **2 server di riserva** oltre al
|
||||
principale: con meno di tre server il quorum di [ELC-14] non può mai avere
|
||||
@@ -62,10 +60,8 @@ Le difese sono cinque.
|
||||
`ALLOW_NO_QUORUM=true`, **solo per lo sviluppo**: il server parte, scrive un
|
||||
avviso nei log a ogni avvio, e le corroborazioni seguono la regola ridotta
|
||||
di [ELC-14].
|
||||
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` · *Test:* —
|
||||
*Stato:* da implementare (B-84) · *Oggi:* un `ELECTRUM_FALLBACK_SERVERS`
|
||||
vuoto (il valore di `.env.example`) è accettato in silenzio e disattiva
|
||||
ogni corroborazione.
|
||||
*Codice:* `app/config.py:validate_quorum_servers`, `app/main.py:lifespan` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
|
||||
## 2. Richieste con limite di tempo
|
||||
|
||||
@@ -299,11 +295,6 @@ server attivo; sono le righe [ELC-26], [ELC-28], [ELC-25] e [ELC-27].
|
||||
maggioranza stretta delle risposte, anche di una sola.
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-84) · *Oggi:* vale per tutti la regola ridotta.
|
||||
Senza server di riserva ogni verifica passa; con server di riserva la
|
||||
maggioranza è calcolata su chi ha risposto, quindi **una risposta su una
|
||||
decide**: se è raggiungibile un solo altro server, la sua parola basta.
|
||||
Nessuna risposta fa fallire la verifica.
|
||||
|
||||
| ID | Prima di… | Si verifica che… |
|
||||
|---|---|---|
|
||||
|
||||
+13
-16
@@ -258,23 +258,20 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
|
||||
*Codice:* `app/deposits/service.py:find_utxos_missing_from` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/electrum/listener.py:ElectrumListener._apply_header`
|
||||
|
||||
- **[LIM-11]** **Senza server di riserva configurati la corroborazione passa
|
||||
sempre.** Con `ELECTRUM_FALLBACK_SERVERS` vuoto (il default di
|
||||
`.env.example`) il solo server primario decide estrazione, spese esterne e
|
||||
accrediti. Anche con server di riserva, la maggioranza è calcolata **su chi
|
||||
risponde**: un solo server di riserva raggiungibile decide da solo. I
|
||||
certificati TLS non sono verificati.
|
||||
- *Impatto:* chi controlla il server primario (o la maggioranza dei server
|
||||
- **[LIM-11]** **I server del quorum li sceglie l'operatore.** Il server
|
||||
rifiuta di partire con meno di 2 server di riserva e ogni corroborazione
|
||||
richiede almeno 2 risposte ([ADR-07](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md), B-84), ma più server controllati dalla
|
||||
stessa parte contano come voci indipendenti. I certificati TLS non sono
|
||||
verificati contro un'autorità; si possono fissare per endpoint
|
||||
(`host:porta:pin=<sha256>`, `ELECTRUM_CERT_PIN`).
|
||||
- *Impatto:* chi controlla la maggioranza dei server configurati (o
|
||||
raggiungibili) può scegliere il vincitore o alterare i saldi mostrati.
|
||||
- *Mitigazione attuale:* nessun server risponde → la corroborazione
|
||||
**fallisce** (una rete irraggiungibile non prova nulla).
|
||||
- *Stato:* da chiudere con B-84.
|
||||
- *Per chiuderlo:* almeno 2 server di riserva configurati (altrimenti il
|
||||
server rifiuta di partire, salvo `ALLOW_NO_QUORUM=true` in sviluppo) e
|
||||
almeno 2 risposte per ogni corroborazione ([ADR-07](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md)); pin facoltativo del
|
||||
certificato per endpoint (`host:porta:pin=<sha256>`). Resta accettato
|
||||
dopo il piano che i server del quorum siano scelti dall'operatore: più
|
||||
server controllati dalla stessa parte contano come voci indipendenti.
|
||||
- *Mitigazione attuale:* quorum minimo obbligatorio; meno di 2 risposte →
|
||||
la corroborazione **fallisce** (una rete irraggiungibile non prova
|
||||
nulla); pin facoltativo del certificato. Il vecchio caso «senza riserve
|
||||
tutto passa» e «una sola risposta decide» è chiuso da B-84.
|
||||
- *Per chiuderlo:* non si chiude: resta una scelta dell'operatore usare
|
||||
server indipendenti fra loro.
|
||||
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority`
|
||||
|
||||
|
||||
+3
-10
@@ -69,10 +69,9 @@ dovranno allora rispettare quel proprietario.
|
||||
`app/config.py:validate_runtime_secrets`,
|
||||
`app/electrum/client.py:parse_endpoints`, `app/wallet/hd.py:_load_account_key` ·
|
||||
*Test:* `tests/unit/test_config.py`, `tests/unit/test_electrum_client.py`
|
||||
*Stato:* da implementare (B-89, B-84) · *Oggi:* della master key si
|
||||
controlla solo che il file esista e che `XPRV_ENCRYPTION_KEY` non sia vuota;
|
||||
una chiave sbagliata emerge alla prima derivazione, e il server parte anche
|
||||
senza server di riserva.
|
||||
*Stato:* da implementare (B-89) · *Oggi:* della master key si controlla
|
||||
solo che il file esista e che `XPRV_ENCRYPTION_KEY` non sia vuota; una
|
||||
chiave sbagliata emerge alla prima derivazione.
|
||||
|
||||
- **[OPS-03]** Il database SQLite lavora in modalità **WAL**: accanto a
|
||||
`plm_lottery.db` esistono `plm_lottery.db-wal` e `plm_lottery.db-shm`, e le
|
||||
@@ -469,12 +468,6 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority`,
|
||||
`app/main.py:lifespan` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-84) · *Oggi:* con `ELECTRUM_FALLBACK_SERVERS`
|
||||
vuoto (il valore di `.env.example`) il server parte e la corroborazione
|
||||
passa sempre: un solo server decide tutto. Con server di riserva la
|
||||
maggioranza è su chi risponde, quindi basta una sola riserva raggiungibile;
|
||||
se nessuna risponde, la corroborazione fallisce
|
||||
(`draw_header_corroboration_failed` a ogni blocco).
|
||||
|
||||
### Round bloccato in `drawing`
|
||||
|
||||
|
||||
+8
-4
@@ -47,14 +47,18 @@ endpoint admin inclusi, quindi restano disattivati a meno di non impostarlo
|
||||
esplicitamente a `true` — utile in locale, da evitare in produzione.
|
||||
|
||||
`ELECTRUM_FALLBACK_SERVERS` elenca i server di riserva, separati da virgola, nel
|
||||
formato `host:porta` (TLS, il caso normale) oppure `host:porta:notls`. Esempio:
|
||||
formato `host:porta` (TLS, il caso normale), `host:porta:notls` oppure
|
||||
`host:porta:pin=<sha256>` (TLS con il certificato del server fissato). Esempio:
|
||||
|
||||
```
|
||||
ELECTRUM_FALLBACK_SERVERS=nodo2.example.net:50002,nodo3.example.net:50001:notls
|
||||
ELECTRUM_FALLBACK_SERVERS=nodo2.example.net:50002,nodo3.example.net:50002,nodo4.example.net:50002:pin=<sha256>
|
||||
```
|
||||
|
||||
Vengono provati a rotazione dopo il primario. Attenzione: un valore scritto male
|
||||
**blocca l'avvio** dell'app — è voluto, meglio accorgersene subito che durante il
|
||||
Vengono provati a rotazione dopo il primario e servono anche da quorum: ne
|
||||
servono **almeno 2**, altrimenti l'app non parte (`ALLOW_NO_QUORUM=true` lo
|
||||
permette, ma solo in sviluppo). `ELECTRUM_CERT_PIN` fissa allo stesso modo il
|
||||
certificato del primario. Attenzione: un valore scritto male **blocca
|
||||
l'avvio** dell'app — è voluto, meglio accorgersene subito che durante il
|
||||
disservizio in cui il fallback serve davvero.
|
||||
|
||||
`JWT_SECRET` e `XPRV_ENCRYPTION_KEY` vengono verificati all'avvio: se sono vuoti
|
||||
|
||||
+2
-8
@@ -135,8 +135,8 @@ l'host.
|
||||
riavvio. L'elenco completo è in
|
||||
[architettura.md](architettura.md#configurazione).
|
||||
*Codice:* `app/config.py:Settings`
|
||||
*Stato:* da implementare (B-84, B-90, B-91) · *Oggi:* `ALLOW_NO_QUORUM`,
|
||||
`START_PAUSED` e `XPRV_ENCRYPTION_KEY_FILE` non esistono.
|
||||
*Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e
|
||||
`XPRV_ENCRYPTION_KEY_FILE` non esistono.
|
||||
|
||||
`.env.example` documenta le chiavi necessarie e il comando per generarle;
|
||||
non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque
|
||||
@@ -629,12 +629,6 @@ loro significato per la sicurezza.
|
||||
più server controllati dalla stessa parte contano come voci indipendenti
|
||||
([LIM-11](limiti-noti.md)).
|
||||
|
||||
*Oggi* (B-84): un `ELECTRUM_FALLBACK_SERVERS` vuoto (il valore di
|
||||
`.env.example`) è accettato in silenzio e fa passare ogni corroborazione; con
|
||||
server di riserva la maggioranza è calcolata su chi risponde, quindi una sola
|
||||
risposta decide; nessun certificato è verificato e non esiste un modo di
|
||||
fissarlo.
|
||||
|
||||
## Host e catena di fornitura
|
||||
|
||||
Chi controlla l'host controlla tutti i fondi ([Modello di
|
||||
|
||||
+3
-3
@@ -232,7 +232,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
| B-81 | `drawing` resta bloccato dopo qualsiasi eccezione, non solo dopo un riavvio | Critica | **aperto** | — | RND |
|
||||
| B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | **aperto** | — | DEP, RND |
|
||||
| B-83 | Conferme e reintegri si basano solo sul server attivo | Alta | **aperto** | — | ELC, TX, DEP |
|
||||
| B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | **aperto** | — | ELC, SEC |
|
||||
| B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | *da registrare* | `test_electrum_client.py`, `test_electrum_listener.py` | ELC, SEC |
|
||||
| B-85 | Le riorganizzazioni della catena non vengono né seguite né segnalate | Alta | **aperto** | — | ELC, RND |
|
||||
| B-86 | Un server connesso ma fermo non viene mai sostituito | Alta | **aperto** | — | ELC, OPS |
|
||||
| B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | **aperto** | — | TX |
|
||||
@@ -252,9 +252,9 @@ 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.
|
||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84.
|
||||
- **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 (8 critici,
|
||||
8 alti, 6 medi), da implementare nell'ordine di
|
||||
7 alti, 6 medi), da implementare nell'ordine di
|
||||
[piano-correzioni.md](piano-correzioni.md).
|
||||
- **Prossimo numero libero:** `B-99`.
|
||||
@@ -253,3 +253,130 @@ async def test_a_reply_that_is_not_json_fails_its_request_not_the_connection():
|
||||
assert await asyncio.wait_for(task2, timeout=1) == 7
|
||||
|
||||
client._read_task.cancel()
|
||||
|
||||
|
||||
# --- B-84 (ELC-01, ELC-03): optional certificate pinning --------------------------
|
||||
|
||||
_PIN = "ab" * 32
|
||||
|
||||
|
||||
def test_parse_endpoints_reads_pins_on_fallbacks_and_the_primary():
|
||||
"""ELC-01 / ELC-03 / B-84: `host:port:pin=<sha256>` and `host:port:<mode>:pin=<sha256>`,
|
||||
plus ELECTRUM_CERT_PIN for the primary; the pin is normalised to lower case."""
|
||||
from app.electrum.client import ElectrumEndpoint, parse_endpoints
|
||||
|
||||
endpoints = parse_endpoints(
|
||||
"primary.example",
|
||||
50002,
|
||||
True,
|
||||
f"a.example:50002:pin={_PIN.upper()}, b.example:50002:tls:pin={_PIN}, c.example:50001:notls",
|
||||
primary_pin=_PIN,
|
||||
)
|
||||
assert endpoints == [
|
||||
ElectrumEndpoint("primary.example", 50002, True, _PIN),
|
||||
ElectrumEndpoint("a.example", 50002, True, _PIN),
|
||||
ElectrumEndpoint("b.example", 50002, True, _PIN),
|
||||
ElectrumEndpoint("c.example", 50001, False, None),
|
||||
]
|
||||
|
||||
|
||||
def test_parse_endpoints_rejects_malformed_pins():
|
||||
"""ELC-01 / B-84: a bad pin blocks the start, like any malformed entry."""
|
||||
import pytest
|
||||
|
||||
from app.electrum.client import parse_endpoints
|
||||
|
||||
for bad in [
|
||||
"h:1:pin=",
|
||||
"h:1:pin=abc",
|
||||
f"h:1:pin={'zz' * 32}",
|
||||
f"h:1:notls:pin={_PIN}", # nothing to pin without TLS
|
||||
f"h:1:pin={_PIN}:pin={_PIN}",
|
||||
f"h:1:tls:ssl:pin={_PIN}",
|
||||
f"h:1:tls:ssl:notls:pin={_PIN}", # too many fields
|
||||
]:
|
||||
with pytest.raises(ValueError):
|
||||
parse_endpoints("primary.example", 50002, True, bad)
|
||||
with pytest.raises(ValueError):
|
||||
parse_endpoints("primary.example", 50002, True, "", primary_pin="nothex")
|
||||
with pytest.raises(ValueError):
|
||||
parse_endpoints("primary.example", 50002, False, "", primary_pin=_PIN)
|
||||
|
||||
|
||||
class _FakeSslObject:
|
||||
def __init__(self, der):
|
||||
self._der = der
|
||||
|
||||
def getpeercert(self, binary_form=False):
|
||||
assert binary_form
|
||||
return self._der
|
||||
|
||||
|
||||
class _PinWriter(FakeWriter):
|
||||
def __init__(self, der):
|
||||
super().__init__()
|
||||
self._ssl = _FakeSslObject(der) if der is not None else None
|
||||
self.closed = False
|
||||
|
||||
def get_extra_info(self, name):
|
||||
return self._ssl if name == "ssl_object" else None
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
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')
|
||||
|
||||
async def fake_open_connection(host, port, **kwargs):
|
||||
return reader, writer
|
||||
|
||||
monkeypatch.setattr(asyncio, "open_connection", fake_open_connection)
|
||||
client = ElectrumClient("localhost", 1, True, pin)
|
||||
try:
|
||||
await client.connect()
|
||||
finally:
|
||||
if client._read_task is not None:
|
||||
client._read_task.cancel()
|
||||
return writer
|
||||
|
||||
|
||||
async def test_a_connection_with_the_pinned_certificate_is_accepted(monkeypatch):
|
||||
"""ELC-03 / B-84: the DER certificate hashes to the pin."""
|
||||
import hashlib
|
||||
|
||||
der = b"the server certificate"
|
||||
writer = await _connect_with_pin(monkeypatch, hashlib.sha256(der).hexdigest().upper(), der)
|
||||
assert not writer.closed
|
||||
|
||||
|
||||
async def test_a_connection_with_another_certificate_fails_like_an_unreachable_server(monkeypatch):
|
||||
"""ELC-03 / B-84: mismatch (and no certificate at all) raise ElectrumError and close."""
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
pin = hashlib.sha256(b"the pinned certificate").hexdigest()
|
||||
for der in (b"someone else in the middle", None):
|
||||
writer = _PinWriter(der)
|
||||
|
||||
async def fake_open_connection(host, port, writer=writer, **kwargs):
|
||||
return asyncio.StreamReader(), writer
|
||||
|
||||
monkeypatch.setattr(asyncio, "open_connection", fake_open_connection)
|
||||
with pytest.raises(ElectrumError):
|
||||
await ElectrumClient("localhost", 1, True, pin).connect()
|
||||
assert writer.closed
|
||||
|
||||
|
||||
async def test_main_builds_clients_with_the_endpoint_pin():
|
||||
"""ELC-03 / B-84: app.main hands each endpoint's pin to its client."""
|
||||
from app.electrum.client import ElectrumEndpoint
|
||||
from app.main import _make_electrum_client
|
||||
|
||||
assert _make_electrum_client(ElectrumEndpoint("h", 1, True, _PIN)).pin == _PIN
|
||||
assert _make_electrum_client(ElectrumEndpoint("h", 1, True)).pin is None
|
||||
@@ -338,9 +338,11 @@ async def _endpoint_client_factory(responses: dict[str, object]):
|
||||
return factory
|
||||
|
||||
|
||||
async def test_corroborate_header_true_with_no_other_servers_configured(session_factory):
|
||||
async def test_corroborate_header_true_with_no_other_servers_only_under_allow_no_quorum(session_factory):
|
||||
"""ELC-14 / ELC-19 / B-84: this used to be the rule for everyone. Now only the
|
||||
development override passes with nothing to corroborate against."""
|
||||
single = [ElectrumEndpoint("only.example", 50002, True)]
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, single, allow_no_quorum=True)
|
||||
assert await listener.corroborate_header(100, "deadbeef") is True
|
||||
|
||||
|
||||
@@ -424,9 +426,10 @@ async def _listunspent_client_factory(responses: dict[str, object]):
|
||||
return factory
|
||||
|
||||
|
||||
async def test_corroborate_utxo_spent_true_with_no_other_servers_configured(session_factory):
|
||||
async def test_corroborate_utxo_spent_true_with_no_other_servers_only_under_allow_no_quorum(session_factory):
|
||||
"""ELC-14 / ELC-19 / B-84, see the header twin of this test."""
|
||||
single = [ElectrumEndpoint("only.example", 50002, True)]
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, single, allow_no_quorum=True)
|
||||
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True
|
||||
|
||||
|
||||
@@ -842,3 +845,86 @@ async def test_refresh_user_credits_once_the_others_corroborate(session_factory)
|
||||
async with session_factory() as session:
|
||||
user = await session.get(User, user_id)
|
||||
assert user.cached_balance_sats == 25_000_000
|
||||
|
||||
|
||||
# --- B-84 (ELC-14, ELC-19): the quorum is never one server, and never nobody ------
|
||||
|
||||
|
||||
async def test_corroboration_fails_with_no_other_server_configured(session_factory):
|
||||
"""ELC-14 / B-84: nothing to corroborate against is a failure, not a pass."""
|
||||
single = [ElectrumEndpoint("only.example", 50002, True)]
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
|
||||
assert await listener.corroborate_header(100, "deadbeef") is False
|
||||
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False
|
||||
|
||||
|
||||
async def test_a_single_responding_server_does_not_decide(session_factory):
|
||||
"""ELC-14 / B-84: two others are configured but only one answers — even though it
|
||||
agrees, one answer is not a quorum. Before B-84 this passed."""
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": "irrelevant", "second.example": header_hex, "third.example": None}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
assert await listener.corroborate_header(100, expected_hash) is False
|
||||
|
||||
# The development override restores the reduced rule.
|
||||
relaxed = ElectrumListener(factory, session_factory, _ENDPOINTS, allow_no_quorum=True)
|
||||
assert await relaxed.corroborate_header(100, expected_hash) is True
|
||||
|
||||
|
||||
async def test_two_answers_must_both_agree_for_a_strict_majority(session_factory):
|
||||
"""ELC-14 / B-84: with exactly two answers, 1 agree + 1 disagree is not a majority."""
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
other_hex = _mine_header("11" * 32)
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": "irrelevant", "second.example": header_hex, "third.example": other_hex}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
assert await listener.corroborate_header(100, expected_hash) is False
|
||||
|
||||
|
||||
async def test_a_credit_is_not_corroborated_by_one_server_alone(session_factory):
|
||||
"""ELC-17 / B-84: the same minimum applies to every kind of corroboration."""
|
||||
entry = {"tx_hash": "77" * 32, "tx_pos": 0, "value": 5_000_000, "height": 10}
|
||||
factory = await _listunspent_client_factory(
|
||||
{"first.example": [entry], "second.example": [entry], "third.example": None}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
assert await listener.corroborate_utxo_credit("scripthash", "77" * 32, 0, 5_000_000) is False
|
||||
|
||||
|
||||
def test_startup_refuses_fewer_than_two_fallback_servers():
|
||||
"""ELC-19 / B-84: refuse to serve unless ALLOW_NO_QUORUM=true."""
|
||||
import pytest
|
||||
|
||||
from app.config import ConfigError, Settings, validate_quorum_servers
|
||||
|
||||
primary = ElectrumEndpoint("primary.example", 50002, True)
|
||||
one = [primary, ElectrumEndpoint("a.example", 50002, True)]
|
||||
two = one + [ElectrumEndpoint("b.example", 50002, True)]
|
||||
|
||||
for too_few in ([primary], one):
|
||||
with pytest.raises(ConfigError, match="ELECTRUM_FALLBACK_SERVERS"):
|
||||
validate_quorum_servers(too_few, Settings(allow_no_quorum=False))
|
||||
validate_quorum_servers(two, Settings(allow_no_quorum=False)) # must not raise
|
||||
validate_quorum_servers([primary], Settings(allow_no_quorum=True)) # the override
|
||||
|
||||
|
||||
async def test_the_lifespan_refuses_to_serve_without_a_quorum(monkeypatch):
|
||||
"""ELC-19 / ARC-60 / B-84: app.main.lifespan stops before starting any task."""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.config import ConfigError, settings
|
||||
from app.main import lifespan
|
||||
|
||||
monkeypatch.setattr(settings, "jwt_secret", "x" * 40)
|
||||
monkeypatch.setattr(settings, "xprv_encryption_key", "k")
|
||||
monkeypatch.setattr(settings, "electrum_fallback_servers", "only-one.example:50002")
|
||||
monkeypatch.setattr(settings, "allow_no_quorum", False)
|
||||
with pytest.raises(ConfigError):
|
||||
async with lifespan(FastAPI()):
|
||||
pass
|
||||
Reference in new issue
Block a user