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:
davideandClaude Sonnet 5.5 committed 2026-10-14 14:04:35 +02:00
1 parent fd6a91e93d
commit 13cb66cc06
18 files changed
+451 -125

No files matched your search

+20 -5
View File
@@ -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())"
-10
View File
@@ -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).
+4 -4
View File
@@ -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.
+1 -1
View File
@@ -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
+49
View File
@@ -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
View File
@@ -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:
+27 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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`.
+127
View File
@@ -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
+90 -4
View File
@@ -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