diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 1382388..16580f7 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -44,10 +44,18 @@ jobs: # electrum/crypto.py requires one of these (normally pulled in # transitively via the dnspython[DNSSEC] extra); install explicitly. pip install aiohttp cryptography + # PyQt6 so tests/test_qt_tab.py actually runs instead of skipping. + # It renders through the offscreen QPA platform, so no display is + # needed, but Qt still links against these X libs on ubuntu-latest. + sudo apt-get update + sudo apt-get install -y libegl1 libgl1 libxkbcommon-x11-0 \ + libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 + pip install PyQt6 - name: Run unit tests env: ELECTRUM_SRC: ${{ github.workspace }}/electrum + QT_QPA_PLATFORM: offscreen run: | cd electrum_swapgui python -m unittest discover -s tests -v diff --git a/README.md b/README.md index c26686a..b7c11fe 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,14 @@ From the tab you can: - **Enable / disable** the swap server at runtime. - **Edit every server setting**: HTTP port, swap fee, nostr relays, and the nostr announcement proof-of-work target. -- **Watch live output**: the advertised pairs (min / max-forward / max-reverse / - mining fee / fee %), your lightning send/receive liquidity, and the history & - running P/L of swaps this node has served. +- **Watch live output**, split into two sub-tabs: + - **Status** — your nostr pubkey (npub, with the identicon takers see), the + advertised pairs (min / max-forward / max-reverse / mining fee / fee %), + your lightning send/receive liquidity, and the history & running P/L of + swaps this node has served. + - **Diagnostics** — why the offer is or is not going out, the three values a + taker's filter must match, and a one-click **discoverability check**. See + [Why can nobody see my swap server?](#why-can-nobody-see-my-swap-server) The server runs two independent transports (either or both): @@ -35,6 +40,55 @@ tasks are never spawned by Electrum itself in the GUI; the plugin starts/stops them on the network asyncio loop and keeps the aiohttp `AppRunner` so the HTTP listener can be shut down cleanly (`swapserver_gui.py:ManagedHttpSwapServer`). +## Why can nobody see my swap server? + +A swap server can be running, connected, and grinding a perfectly valid proof of +work and still be **completely invisible**, because every rejection on the taker +side is silent — `NostrTransport._get_pairs_loop` in +`electrum/submarine_swaps.py` drops a candidate offer with at most a `debug` log +line. The **Diagnostics** sub-tab exists to make that legible. + +A taker only considers an offer when *all* of these line up: + +| What | Server side | Taker side | +| --- | --- | --- | +| event kind | `USER_STATUS_NIP38` = `30315` | same | +| `d` tag | `electrum-swapserver-` | same — **differs between Electrum versions** | +| `r` tag | `net:` | same — note `signet` ≠ `mutinynet` | +| `created_at` | now | within ±1 h of the taker's clock | +| proof of work | ground to *your* `SWAPSERVER_POW_TARGET` | at least the *taker's* `SWAPSERVER_POW_TARGET` (**default 30**) | + +Ranked causes, most common first: + +1. **Nothing was ever published — no liquidity.** `publish_offer` returns early + unless `max_forward` or `max_reverse` reaches `MIN_SWAP_AMOUNT_SAT` + (20,000 sat). `max_forward` is capped by *both* your lightning inbound + capacity and your on-chain spendable balance; `max_reverse` by lightning + outbound. `server_update_pairs` also rounds both *down* to two leading + digits, so 19,999 sat becomes 19,000. The Status tab no longer claims to be + "announcing" in this state. +2. **Proof-of-work asymmetry.** `SWAPSERVER_POW_TARGET` means "bits to grind" + on the server but "bits I demand" on the taker. Lower it to save CPU and + every taker still on the default of 30 drops you. The Diagnostics tab warns + whenever the announcement carries fewer than 30 bits. +3. **Network mismatch.** `signet` and `mutinynet` are separate `NET_NAME`s + upstream; the `r` tag never matches across them. +4. **Electrum version skew** between the two wallets, via the `d` tag. +5. **The taker is not using nostr at all** — a non-empty `SWAPSERVER_URL` makes + it build an `HttpTransport`, and a wallet that is itself a server never runs + the discovery loop. +6. **Relay-side expiry.** Announcements carry a NIP-40 `expiration` of ~10 + minutes and are republished every 10 minutes, so a server that has been down + for longer has no stored offer left. Some public relays also silently refuse + kind `30315`. + +**Check discoverability** asks each configured relay the taker's own question, +using a throwaway key, and names the first rule that rejects you — per relay, +so "found on 2 of 9" is visible. It runs two queries: one by author (does our +announcement exist here at all?) and one with the taker's verbatim filter (does +it survive it?). That split is what distinguishes a wrong tag from a missing +event from being crowded out of a busy relay's 10-result page. + ## Layout ``` @@ -43,8 +97,10 @@ plugins/swapserver_gui/ __init__.py # registers plugins.swapserver_gui.autostart _version.py # plugin version, stamped from the git tag at build time swapserver_gui.py # GUI-agnostic server lifecycle + history helpers + pow.py # cancel-safe, resumable announcement proof-of-work + nostr_check.py # nostr identity + the discoverability rule engine/check qt.py # the "Swap Server" tab + Plugin(load_wallet/close_wallet) -tests/ # unit + HTTP end-to-end tests (no GUI needed) +tests/ # unit + HTTP/nostr/Qt end-to-end tests contrib/make_zip.sh # build the external-plugin zip contrib/regtest_demo/ # one-command local regtest + nostr demo ``` @@ -107,6 +163,19 @@ ELECTRUM_SRC=/path/to/electrum python3 -m unittest discover -s tests -v `SimpleConfig`, including disabling the HTTP port (see the note below). - `tests/test_version.py` / `tests/test_zip_plugin_load.py` — version formatting and the end-to-end build-time stamping chain. +- `tests/test_nostr_check.py` — the discoverability rule engine, pinned to the + order of checks `_get_pairs_loop` performs, so the field we blame is the one + that actually rejects us upstream. +- `tests/test_diagnostics.py` — the announcement state machine, the liquidity + gate (`publish_offer`'s early return), and the npub shown in the Status tab. +- `tests/test_discovery_e2e.py` — the check driven against a **real** NIP-01 + relay hosted in-process (`tests/fake_relay.py`, aiohttp WebSocket): offers are + published with `electrum_aionostr` exactly as the server does, signatures are + verified on receipt, and each failure mode (low PoW, wrong net, wrong version, + crowded out, unreachable) is asserted to be named correctly. +- `tests/test_qt_tab.py` — builds the real `SwapServerTab` on Qt's offscreen + platform and drives `refresh()`. **Skips when PyQt6 is not installed**; CI + installs it so these run there. ### A note on the HTTP port diff --git a/contrib/make_zip.sh b/contrib/make_zip.sh index ace4e8e..aa74e5b 100755 --- a/contrib/make_zip.sh +++ b/contrib/make_zip.sh @@ -10,6 +10,7 @@ # swapserver_gui/__init__.py # swapserver_gui/swapserver_gui.py # swapserver_gui/pow.py +# swapserver_gui/nostr_check.py # swapserver_gui/qt.py # # The zip is a plain archive: no signing key or secret is required to build it. diff --git a/plugins/swapserver_gui/nostr_check.py b/plugins/swapserver_gui/nostr_check.py new file mode 100644 index 0000000..c8f9ba2 --- /dev/null +++ b/plugins/swapserver_gui/nostr_check.py @@ -0,0 +1,537 @@ +#!/usr/bin/env python +# +# swapserver_gui - a Qt GUI plugin for Electrum's submarine swap server. +# This file is released into the public domain (The Unlicense); see LICENSE. +# +# "Can a taker actually see me?" -- nostr identity helpers and a self-check that +# queries our own relays with a taker's exact filter. +# +# WHY THIS MODULE EXISTS +# ---------------------- +# A swap server can be running, connected and grinding a valid proof of work and +# still be completely invisible, because every rejection on the taker side is +# silent. ``SwapManager``'s client loop (electrum/submarine_swaps.py, +# ``NostrTransport._get_pairs_loop``) drops a candidate offer with at most a +# ``debug`` log line when any of these do not line up: +# +# * kind -> ``USER_STATUS_NIP38`` (30315) +# * tag ``d`` -> ``electrum-swapserver-`` +# (so two wallets running different Electrum versions never see each other) +# * tag ``r`` -> ``net:`` +# (note that 'signet' and 'mutinynet' are *different* NET_NAMEs) +# * ``created_at`` -> within +/- 1h of the taker's clock +# * proof of work -> at least the *taker's* ``SWAPSERVER_POW_TARGET`` +# +# That last one is the nastiest: ``SWAPSERVER_POW_TARGET`` means "how many bits +# to grind" on the server and "how many bits I demand" on the taker, so a server +# whose operator lowered it to save CPU is dropped by every taker still on the +# default of 30 -- with nothing shown in either GUI. +# +# On top of that, the server may never have published at all: +# ``NostrTransport.publish_offer`` returns early when there is no liquidity to +# advertise, and the announcement carries a NIP-40 ``expiration`` tag, so relays +# that honour it drop the event ~10 minutes after the server goes away. +# +# So we ask the relays directly. For each configured relay we run two queries: +# +# A. broad, by author: does *our* announcement exist on this relay at all? +# B. the taker's verbatim filter: do we show up in what a taker actually asks +# for? (Only run when A produced an event that passes every rule -- it +# distinguishes "valid but crowded out of the ``limit``" from the rest.) +# +# Splitting it in two is what lets us name the failing field instead of just +# reporting "not found". + +import asyncio +import json +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence + +# ``pow.pow_bits``: (x-only pubkey, nonce) -> leading zero bits. +PowBitsFn = Callable[[bytes, Optional[int]], int] + +if TYPE_CHECKING: + from electrum.simple_config import SimpleConfig + from electrum.network import Network + from electrum.wallet import Abstract_Wallet + + +# electrum/simple_config.py: SWAPSERVER_POW_TARGET = ConfigVar(..., default=30). +# A taker who never touched the setting demands this many bits, so announcing +# with fewer makes us invisible to stock wallets even when everything else is +# correct. Kept as a literal (rather than read from a fresh SimpleConfig) so it +# describes *other people's* default, not whatever this machine is configured to. +DEFAULT_TAKER_POW_TARGET = 30 + +# Upstream's client loop only looks one hour back, and independently discards +# events whose created_at is more than an hour from its own clock. +OFFER_LOOKBACK_SEC = 60 * 60 + +# Mirrors the ``limit`` in _get_pairs_loop's filter. +TAKER_QUERY_LIMIT = 10 + + +class CheckStatus(Enum): + """Why a taker would (or would not) see our offer on a given relay.""" + + DISCOVERABLE = "discoverable" + UNREACHABLE = "unreachable" + NO_EVENT = "no_event" + WRONG_VERSION = "wrong_version" + WRONG_NET = "wrong_net" + STALE = "stale" + LOW_POW = "low_pow" + UNPARSEABLE = "unparseable" + CROWDED_OUT = "crowded_out" + ERROR = "error" + + @property + def is_ok(self) -> bool: + return self is CheckStatus.DISCOVERABLE + + +@dataclass(frozen=True) +class EventView: + """The parts of a nostr event this module reasons about. + + A plain value object rather than an ``electrum_aionostr.event.Event`` so the + rule engine below can be exercised without a relay (or aionostr) present. + """ + + pubkey: str + created_at: int + tags: Sequence[Sequence[str]] + content: str + + @classmethod + def from_event(cls, event: Any) -> 'EventView': + return cls( + pubkey=str(event.pubkey), + created_at=int(event.created_at), + tags=[list(t) for t in (event.tags or [])], + content=str(event.content or ""), + ) + + def tag_dict(self) -> Optional[Dict[str, str]]: + """``{name: first_value}``, or None if upstream would fail to build it. + + Upstream does ``tags = {k: v for k, v in event.tags}`` inside a ``try`` + that skips the whole event on failure, so a *single* tag with anything + other than exactly two elements makes the offer invisible. We reproduce + that rather than being more lenient: the point is to predict upstream. + """ + result: Dict[str, str] = {} + for tag in self.tags: + if len(tag) != 2: + return None + result[str(tag[0])] = str(tag[1]) + return result + + +@dataclass(frozen=True) +class Verdict: + status: CheckStatus + detail: str + pow_bits: Optional[int] = None + + @property + def is_ok(self) -> bool: + return self.status.is_ok + + +def d_tag_for(event_version: int) -> str: + return f"electrum-swapserver-{event_version}" + + +def r_tag_for(net_name: str) -> str: + return f"net:{net_name}" + + +def evaluate_offer_event( + view: EventView, + *, + net_name: str, + event_version: int, + taker_pow_target: int, + pow_bits_fn: PowBitsFn, + now_ts: Optional[int] = None, +) -> Verdict: + """Decide whether a taker's client loop would accept ``view`` as an offer. + + The checks -- and their order -- mirror + ``NostrTransport._get_pairs_loop`` (electrum/submarine_swaps.py) so that the + field we blame is the one that actually rejects us first upstream. + + ``pow_bits_fn(pubkey_bytes, nonce)`` is injected (rather than imported) to + keep this module free of the sibling-import dance the zip plugin needs; it + is always ``pow.pow_bits``. + """ + if now_ts is None: + now_ts = int(time.time()) + + # 1. content + tags must both parse (upstream wraps them in one try block) + tags = view.tag_dict() + try: + content = json.loads(view.content) + if not isinstance(content, dict): + raise ValueError("content is not a JSON object") + except (ValueError, TypeError) as e: + return Verdict(CheckStatus.UNPARSEABLE, f"announcement content is malformed: {e}") + if tags is None: + return Verdict( + CheckStatus.UNPARSEABLE, + "a tag on the announcement does not have exactly two elements, " + "which makes takers discard the whole event") + + # 2. event version (the 'd' tag) + want_d = d_tag_for(event_version) + got_d = tags.get('d') + if got_d != want_d: + return Verdict( + CheckStatus.WRONG_VERSION, + f"announcement is tagged {got_d!r} but takers on this Electrum " + f"version look for {want_d!r}") + + # 3. network (the 'r' tag) + want_r = r_tag_for(net_name) + got_r = tags.get('r') + if got_r != want_r: + return Verdict( + CheckStatus.WRONG_NET, + f"announcement is tagged {got_r!r} but takers on this chain look " + f"for {want_r!r}") + + # 4. freshness (takers reject anything more than an hour off their clock) + age = now_ts - view.created_at + if age > OFFER_LOOKBACK_SEC: + return Verdict( + CheckStatus.STALE, + f"announcement is {format_age(age)} old; takers ignore anything " + f"older than 1 hour") + if age < -OFFER_LOOKBACK_SEC: + return Verdict( + CheckStatus.STALE, + f"announcement is timestamped {format_age(-age)} in the future; " + f"check this machine's clock") + + # 5. proof of work + try: + pow_nonce = int(content.get('pow_nonce', "0"), 16) + except (ValueError, TypeError): + return Verdict(CheckStatus.UNPARSEABLE, "pow_nonce is not a hex string") + try: + pow_bits = int(pow_bits_fn(bytes.fromhex(view.pubkey), pow_nonce)) + except (ValueError, TypeError): + return Verdict(CheckStatus.UNPARSEABLE, "pubkey is not valid hex") + if pow_bits < taker_pow_target: + return Verdict( + CheckStatus.LOW_POW, + f"announcement carries {pow_bits} bits of proof of work but takers " + f"demand {taker_pow_target}", + pow_bits=pow_bits) + + # 6. the payload a taker needs in order to build SwapFees + if not isinstance(content.get('relays'), str): + return Verdict(CheckStatus.UNPARSEABLE, "announcement has no relay list", + pow_bits=pow_bits) + for required in ('percentage_fee', 'mining_fee', 'min_amount', + 'max_forward_amount', 'max_reverse_amount'): + if content.get(required) is None: + return Verdict(CheckStatus.UNPARSEABLE, + f"announcement is missing {required!r}", + pow_bits=pow_bits) + + return Verdict(CheckStatus.DISCOVERABLE, "takers can see this offer", + pow_bits=pow_bits) + + +def format_age(seconds: int) -> str: + seconds = int(seconds) + if seconds < 0: + return "just now" + if seconds < 90: + return f"{seconds}s" + if seconds < 90 * 60: + return f"{seconds // 60} min" + if seconds < 48 * 3600: + return f"{seconds // 3600}h" + return f"{seconds // 86400}d" + + +# ------------------------------------------------------------------- queries +def author_query(pubkey_hex: str, *, kind: int, now_ts: Optional[int] = None) -> Dict[str, Any]: + """Query A: everything *we* published recently, whatever its tags say.""" + if now_ts is None: + now_ts = int(time.time()) + return { + "kinds": [kind], + "authors": [pubkey_hex], + "since": now_ts - OFFER_LOOKBACK_SEC, + "limit": TAKER_QUERY_LIMIT, + } + + +def taker_query(*, kind: int, net_name: str, event_version: int, + now_ts: Optional[int] = None) -> Dict[str, Any]: + """Query B: byte-for-byte what a taker asks a relay for. + + Kept identical to the filter built in ``NostrTransport._get_pairs_loop``; + if upstream changes it, this must follow. + """ + if now_ts is None: + now_ts = int(time.time()) + return { + "kinds": [kind], + "limit": TAKER_QUERY_LIMIT, + "#d": [d_tag_for(event_version)], + "#r": [r_tag_for(net_name)], + "since": now_ts - OFFER_LOOKBACK_SEC, + } + + +# ------------------------------------------------------------------- results +@dataclass(frozen=True) +class RelayResult: + relay: str + status: CheckStatus + detail: str + pow_bits: Optional[int] = None + event_age_sec: Optional[int] = None + + @property + def is_ok(self) -> bool: + return self.status.is_ok + + +@dataclass +class DiscoveryReport: + """What every configured relay had to say about our own announcement.""" + + pubkey_hex: str + npub: str + net_name: str + event_version: int + kind: int + taker_pow_target: int + results: List[RelayResult] = field(default_factory=list) + + @property + def ok_count(self) -> int: + return sum(1 for r in self.results if r.is_ok) + + @property + def best_pow_bits(self) -> Optional[int]: + seen = [r.pow_bits for r in self.results if r.pow_bits is not None] + return max(seen) if seen else None + + def headline(self) -> str: + total = len(self.results) + if total == 0: + return "No relays configured." + if self.ok_count == 0: + return f"Not discoverable on any of the {total} relay(s)." + return f"Discoverable on {self.ok_count} of {total} relay(s)." + + def warnings(self) -> List[str]: + """Problems that are real even when the headline looks healthy.""" + out: List[str] = [] + bits = self.best_pow_bits + if bits is not None and bits < DEFAULT_TAKER_POW_TARGET: + out.append( + f"The announcement carries {bits} bits of proof of work. Takers " + f"still on Electrum's default target of {DEFAULT_TAKER_POW_TARGET} " + f"will silently ignore this server, even where the check above " + f"says 'discoverable'. Raise the Nostr PoW target to " + f"{DEFAULT_TAKER_POW_TARGET} and restart the server.") + if self.taker_pow_target < DEFAULT_TAKER_POW_TARGET: + out.append( + f"This wallet demands only {self.taker_pow_target} bits from " + f"other servers, so the check above is more forgiving than a " + f"stock taker would be.") + if any(r.status is CheckStatus.CROWDED_OUT for r in self.results): + out.append( + f"Some relays returned a full page of {TAKER_QUERY_LIMIT} other " + f"offers before ours. Takers query with that limit, so a busy " + f"relay can hide this server.") + return out + + +# --------------------------------------------------------------- the network +def _throwaway_nsec() -> Optional[str]: + """A random nsec so the check queries relays as a stranger would.""" + try: + from electrum.lnutil import generate_random_keypair + from electrum_aionostr.util import to_nip19 + return to_nip19('nsec', generate_random_keypair().privkey.hex()) + except Exception: + return None + + +def _build_manager(relay_url: str, *, network: Optional['Network'], + connect_timeout: int) -> Any: + """One aionostr Manager per relay, so results are attributable. + + ``Manager.connect`` prunes relays it could not reach from its own list, so + a single multi-relay Manager cannot tell us *which* relay was unreachable. + """ + import electrum_aionostr as aionostr + import ssl + from electrum.util import ca_path, make_aiohttp_proxy_connector + + ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path) + proxy = None + if network is not None and network.proxy and network.proxy.enabled: + proxy = make_aiohttp_proxy_connector(network.proxy, ssl_context) + return aionostr.Manager( + [relay_url], + private_key=_throwaway_nsec(), + ssl_context=ssl_context, + proxy=proxy, + connect_timeout=connect_timeout, + ) + + +async def _collect(manager: Any, query: Dict[str, Any], *, timeout: float) -> List[EventView]: + """Drain a subscription into a list, bounded by ``timeout``. + + ``Manager.get_events`` waits for an EOSE from every relay or its own 60s + fallback, which is far too long for a button in a GUI. + """ + out: List[EventView] = [] + + async def _run() -> None: + async for event in manager.get_events(query, only_stored=True): + out.append(EventView.from_event(event)) + + try: + await asyncio.wait_for(_run(), timeout=timeout) + except asyncio.TimeoutError: + pass # whatever arrived before the deadline is still informative + return out + + +async def check_relay( + relay_url: str, + *, + pubkey_hex: str, + net_name: str, + event_version: int, + kind: int, + taker_pow_target: int, + pow_bits_fn: PowBitsFn, + network: Optional['Network'] = None, + connect_timeout: int = 5, + query_timeout: float = 10.0, +) -> RelayResult: + """Ask one relay whether our announcement is there and would be accepted.""" + manager = None + try: + manager = _build_manager(relay_url, network=network, connect_timeout=connect_timeout) + await asyncio.wait_for(manager.connect(), timeout=connect_timeout + 1) + if not manager.relays: + return RelayResult(relay_url, CheckStatus.UNREACHABLE, + "could not connect to this relay") + + now_ts = int(time.time()) + # A: is our announcement stored here at all? + ours = await _collect( + manager, author_query(pubkey_hex, kind=kind, now_ts=now_ts), + timeout=query_timeout) + ours = [e for e in ours if e.pubkey == pubkey_hex] + if not ours: + return RelayResult( + relay_url, CheckStatus.NO_EVENT, + "this relay is holding no announcement from us: either none was " + "published, the relay rejected it, or it expired (announcements " + "carry a 10 minute expiration tag)") + newest = max(ours, key=lambda e: e.created_at) + age = now_ts - newest.created_at + + verdict = evaluate_offer_event( + newest, net_name=net_name, event_version=event_version, + taker_pow_target=taker_pow_target, pow_bits_fn=pow_bits_fn, + now_ts=now_ts) + if not verdict.is_ok: + return RelayResult(relay_url, verdict.status, verdict.detail, + pow_bits=verdict.pow_bits, event_age_sec=age) + + # B: our event is valid -- but does it survive the taker's own filter? + seen = await _collect( + manager, + taker_query(kind=kind, net_name=net_name, + event_version=event_version, now_ts=now_ts), + timeout=query_timeout) + if not any(e.pubkey == pubkey_hex for e in seen): + if len(seen) >= TAKER_QUERY_LIMIT: + return RelayResult( + relay_url, CheckStatus.CROWDED_OUT, + f"our announcement is valid, but this relay returned " + f"{len(seen)} other offers first and takers only ask for " + f"{TAKER_QUERY_LIMIT}", + pow_bits=verdict.pow_bits, event_age_sec=age) + return RelayResult( + relay_url, CheckStatus.NO_EVENT, + "our announcement is stored here but the relay did not return " + "it for a taker's filter; it may not index tag queries", + pow_bits=verdict.pow_bits, event_age_sec=age) + + return RelayResult(relay_url, CheckStatus.DISCOVERABLE, + f"announced {format_age(age)} ago", + pow_bits=verdict.pow_bits, event_age_sec=age) + except asyncio.CancelledError: + raise + except asyncio.TimeoutError: + return RelayResult(relay_url, CheckStatus.UNREACHABLE, + "timed out connecting to this relay") + except Exception as e: + return RelayResult(relay_url, CheckStatus.ERROR, f"{type(e).__name__}: {e}") + finally: + if manager is not None: + try: + await manager.close() + except Exception: + pass + + +async def run_discovery_check( + *, + relays: Sequence[str], + pubkey_hex: str, + npub: str, + net_name: str, + event_version: int, + kind: int, + taker_pow_target: int, + pow_bits_fn: PowBitsFn, + network: Optional['Network'] = None, + connect_timeout: int = 5, + query_timeout: float = 10.0, +) -> DiscoveryReport: + """Check every relay concurrently and collect the verdicts.""" + report = DiscoveryReport( + pubkey_hex=pubkey_hex, npub=npub, net_name=net_name, + event_version=event_version, kind=kind, + taker_pow_target=taker_pow_target) + urls = [r.strip() for r in relays if r.strip()] + if not urls: + return report + tasks = [ + check_relay( + url, pubkey_hex=pubkey_hex, net_name=net_name, + event_version=event_version, kind=kind, + taker_pow_target=taker_pow_target, pow_bits_fn=pow_bits_fn, + network=network, connect_timeout=connect_timeout, + query_timeout=query_timeout) + for url in urls + ] + gathered = await asyncio.gather(*tasks, return_exceptions=True) + for url, outcome in zip(urls, gathered): + if isinstance(outcome, RelayResult): + report.results.append(outcome) + elif isinstance(outcome, BaseException): + report.results.append(RelayResult( + url, CheckStatus.ERROR, + f"{type(outcome).__name__}: {outcome}")) + return report diff --git a/plugins/swapserver_gui/qt.py b/plugins/swapserver_gui/qt.py index f928af0..4337134 100644 --- a/plugins/swapserver_gui/qt.py +++ b/plugins/swapserver_gui/qt.py @@ -7,26 +7,28 @@ # controls to the transport lifecycle implemented in ``swapserver_gui.py``. import importlib -from typing import TYPE_CHECKING, Optional, Dict, Any +from typing import TYPE_CHECKING, Any, Dict, List, Optional -from PyQt6.QtCore import Qt, QTimer +import time +from PyQt6.QtCore import Qt, QTimer, pyqtSignal from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QFormLayout, QGroupBox, QLabel, QPushButton, QSpinBox, QPlainTextEdit, QTreeWidget, QTreeWidgetItem, - QSizePolicy, + QSizePolicy, QTabWidget, ) from electrum.i18n import _ from electrum.plugin import hook -from electrum.gui.qt.util import read_QIcon +from electrum.gui.qt.util import read_QIcon, pubkey_to_q_icon, WWLabel, ColorScheme from .swapserver_gui import ( - SwapServerGuiPlugin, SwapServerError, get_swap_history, get_swap_summary, - save_settings, + SwapServerGuiPlugin, SwapServerError, AnnounceState, get_swap_history, + get_swap_summary, save_settings, ) # NB: not ``from . import pow as swap_pow`` -- that form breaks when Electrum # loads this plugin from a zip. See the long comment in swapserver_gui.py. swap_pow = importlib.import_module('.pow', __package__) +nostr_check = importlib.import_module('.nostr_check', __package__) _version = importlib.import_module('._version', __package__) # Above this many bits a proof-of-work grind stops being a "wait a few minutes" @@ -50,12 +52,20 @@ def _fmt_sat(config, sat: Optional[int]) -> str: class SwapServerTab(QWidget): """The 'Swap Server' tab: enable/disable, settings, and live output.""" + # The discoverability check completes on the asyncio thread; this hops the + # report (or the exception) back onto the GUI thread before touching widgets. + checkFinished = pyqtSignal(object) + def __init__(self, plugin: 'Plugin', window: 'ElectrumWindow') -> None: QWidget.__init__(self) self.plugin = plugin self.window = window self.config = plugin.config self.wallet = window.wallet + self._npub: Optional[str] = None + self._check_running: bool = False + self._alive: List[bool] = [True] # cleared by clean_up(); see on_check_discoverability + self.checkFinished.connect(self.on_check_finished) root = QVBoxLayout(self) @@ -142,8 +152,45 @@ def _build_settings_group(self) -> QGroupBox: return box def _build_output_group(self) -> QGroupBox: + """The right-hand pane: a Status/Diagnostics tab pair. + + The settings box deliberately stays outside these tabs so it is + reachable from both. + """ box = QGroupBox(_("Live output")) outer = QVBoxLayout(box) + self.output_tabs = QTabWidget() + self.output_tabs.addTab(self._build_status_tab(), _("Status")) + self.output_tabs.addTab(self._build_diagnostics_tab(), _("Diagnostics")) + outer.addWidget(self.output_tabs) + return box + + def _build_status_tab(self) -> QWidget: + page = QWidget() + outer = QVBoxLayout(page) + + # ---- nostr identity ------------------------------------------------ + # This is what a taker pins as SWAPSERVER_NPUB, so it belongs with the + # operator-facing status rather than with the diagnostics. + ident_box = QGroupBox(_("Nostr identity")) + ident_layout = QHBoxLayout(ident_box) + self.npub_icon = QLabel() + self.npub_icon.setFixedWidth(18) + self.npub_icon.setToolTip( + _("Identicon takers see next to this server in their provider list.")) + ident_layout.addWidget(self.npub_icon) + self.npub_label = QLabel("—") + self.npub_label.setWordWrap(True) + self.npub_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse) + self.npub_label.setToolTip( + _("The nostr public key this swap server announces under.\n" + "Give it to a taker to select this server directly.")) + ident_layout.addWidget(self.npub_label, 1) + self.npub_copy_btn = QPushButton(_("Copy")) + self.npub_copy_btn.clicked.connect(self.on_copy_npub) + ident_layout.addWidget(self.npub_copy_btn) + outer.addWidget(ident_box) grid = QGridLayout() self._out_labels: Dict[str, QLabel] = {} @@ -175,7 +222,77 @@ def _build_output_group(self) -> QGroupBox: self.history_tree.setRootIsDecorated(False) outer.addWidget(self.history_tree, 1) - return box + return page + + def _build_diagnostics_tab(self) -> QWidget: + """Everything needed to answer "why can nobody see my server?". + + Each rejection on the taker side is silent (see nostr_check.py), so this + pane spells out the values that have to match and then goes and asks the + relays directly. + """ + page = QWidget() + outer = QVBoxLayout(page) + + # ---- announcement state ------------------------------------------- + ann_box = QGroupBox(_("Announcement")) + ann_layout = QVBoxLayout(ann_box) + self.announce_label = QLabel("—") + self.announce_label.setTextFormat(Qt.TextFormat.RichText) + ann_layout.addWidget(self.announce_label) + self.announce_reason = WWLabel("") + ann_layout.addWidget(self.announce_reason) + self.last_publish_label = QLabel("") + self.last_publish_label.setEnabled(False) + ann_layout.addWidget(self.last_publish_label) + outer.addWidget(ann_box) + + # ---- the fields a taker's filter must agree with -------------------- + match_box = QGroupBox(_("Taker must match")) + match_box.setToolTip(_( + "A taker only considers an offer when all of these are identical on " + "both sides. Mismatches are discarded silently by both wallets.")) + match_form = QFormLayout(match_box) + self._match_labels: Dict[str, QLabel] = {} + for key, text in (("net", _("Network:")), + ("version", _("Event version:")), + ("kind", _("Event kind:")), + ("pow", _("Proof of work:"))): + val = QLabel("—") + val.setTextFormat(Qt.TextFormat.RichText) + val.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self._match_labels[key] = val + match_form.addRow(text, val) + self.pow_warning = WWLabel("") + self.pow_warning.setVisible(False) + match_form.addRow(self.pow_warning) + outer.addWidget(match_box) + + # ---- the self-check ------------------------------------------------ + check_box = QGroupBox(_("Discoverability check")) + check_layout = QVBoxLayout(check_box) + check_row = QHBoxLayout() + self.check_btn = QPushButton(_("Check discoverability")) + self.check_btn.setToolTip(_( + "Query each configured relay the way another wallet would, and " + "report whether this server's offer comes back.")) + self.check_btn.clicked.connect(self.on_check_discoverability) + check_row.addWidget(self.check_btn) + self.check_headline = QLabel("") + check_row.addWidget(self.check_headline, 1) + check_layout.addLayout(check_row) + + self.check_tree = QTreeWidget() + self.check_tree.setHeaderLabels([_("Relay"), _("Result"), _("Detail")]) + self.check_tree.setRootIsDecorated(False) + check_layout.addWidget(self.check_tree, 1) + + self.check_warnings = WWLabel("") + self.check_warnings.setVisible(False) + check_layout.addWidget(self.check_warnings) + outer.addWidget(check_box, 1) + + return page # ------------------------------------------------------------- settings def load_settings_into_widgets(self) -> None: @@ -283,9 +400,87 @@ def on_toggle(self) -> None: self.config.SWAPSERVER_GUI_AUTOSTART = True self.refresh() + def on_copy_npub(self) -> None: + if not self._npub: + return + self.window.do_copy(self._npub, title=_("Nostr pubkey")) + + # ---------------------------------------------------------- diagnostics + def on_check_discoverability(self) -> None: + if self._check_running: + return + try: + fut = self.plugin.check_discoverability() + except SwapServerError as e: + self.window.show_error(str(e)) + return + except Exception as e: # e.g. the asyncio loop is gone + self.plugin.logger.exception("could not start the discoverability check") + self.window.show_error( + _("Could not run the discoverability check: {}").format(e)) + return + self._check_running = True + self.check_btn.setEnabled(False) + self.check_btn.setText(_("Checking…")) + self.check_headline.setText(_("Querying relays…")) + self.check_tree.clear() + self.check_warnings.setVisible(False) + + # A plain list, not a widget attribute: clean_up() flips it so a check + # that lands *while* the tab is being torn down never emits into a + # deleted QObject (the timer is stopped by then, but this future is not + # driven by it). + alive = self._alive + + def _done(f) -> None: + # runs on the asyncio thread: emit only, never touch widgets here + if f.cancelled() or not alive[0]: + return + try: + result = f.result() + except Exception as e: # noqa: BLE001 - surfaced to the user below + result = e + try: + self.checkFinished.emit(result) + except RuntimeError: + pass # the tab was deleted between the guard and the emit + fut.add_done_callback(_done) + + def on_check_finished(self, report: Any) -> None: + self._check_running = False + self.check_btn.setEnabled(True) + self.check_btn.setText(_("Check discoverability")) + if isinstance(report, BaseException): + self.check_headline.setText(_("Check failed.")) + self.window.show_error( + _("Could not run the discoverability check: {}").format(report)) + return + self.check_headline.setText(report.headline()) + self.check_tree.clear() + for result in report.results: + item = QTreeWidgetItem([ + result.relay, + _("discoverable") if result.is_ok else result.status.value.replace("_", " "), + result.detail, + ]) + colour = ColorScheme.GREEN if result.is_ok else ColorScheme.RED + item.setForeground(1, colour.as_color()) + self.check_tree.addTopLevelItem(item) + for i in range(3): + self.check_tree.resizeColumnToContents(i) + warnings = report.warnings() + if warnings: + self.check_warnings.setText("⚠ " + "\n\n⚠ ".join(warnings)) + self.check_warnings.setVisible(True) + else: + self.check_warnings.setVisible(False) + # -------------------------------------------------------------- teardown def clean_up(self) -> None: """Stop the periodic refresh. Idempotent; safe to call during shutdown.""" + self._alive[0] = False + self.plugin.cancel_discovery_check() + self._check_running = False if self._timer is not None: self._timer.stop() try: @@ -320,14 +515,20 @@ def refresh(self) -> None: http_txt = _("configured (port {})").format(st["http_port"]) self._out_labels["http"].setText(http_txt) - if not st["nostr_enabled"]: - nostr_txt = _("disabled") - elif running and st["pow_grinding"]: - nostr_txt = _("waiting for proof of work") - elif running: - nostr_txt = _("announcing to {} relay(s)").format(st["nostr_relay_count"]) - else: - nostr_txt = _("{} relay(s) configured").format(st["nostr_relay_count"]) + # Never claim we are announcing when the announcement path is blocked: + # a locked wallet, an unfinished proof of work and (most commonly) too + # little liquidity all suppress publish_offer silently. See + # swapserver_gui.announce_state. + state = st["announce_state"] + nostr_txt = { + AnnounceState.DISABLED: _("disabled"), + AnnounceState.STOPPED: _("{} relay(s) configured").format(st["nostr_relay_count"]), + AnnounceState.WAITING_UNLOCK: _("waiting for wallet unlock"), + AnnounceState.WAITING_POW: _("waiting for proof of work"), + AnnounceState.NO_LIQUIDITY: _("not announcing — no liquidity"), + AnnounceState.ANNOUNCING: _("announcing to {} relay(s)").format(st["nostr_relay_count"]), + # a state added later must degrade, not crash the 4s refresh + }.get(state, state.value.replace("_", " ")) self._out_labels["nostr"].setText(nostr_txt) target = st["pow_target"] @@ -360,8 +561,74 @@ def refresh(self) -> None: except Exception: pass + self._refresh_identity(st) + self._refresh_diagnostics(st) self._refresh_history() + def _refresh_identity(self, st: Dict[str, Any]) -> None: + npub = st["nostr_npub"] + pubkey = st["nostr_pubkey"] + self._npub = npub + self.npub_copy_btn.setEnabled(bool(npub)) + if not npub or not pubkey: + self.npub_label.setText(_("no nostr key (wallet has no lightning)")) + self.npub_icon.clear() + self.npub_label.setToolTip("") + return + self.npub_label.setText(npub) + self.npub_label.setToolTip(_("hex: {}").format(pubkey)) + self.npub_icon.setPixmap(pubkey_to_q_icon(pubkey).pixmap(16, 16)) + + def _refresh_diagnostics(self, st: Dict[str, Any]) -> None: + state = st["announce_state"] + if state is AnnounceState.ANNOUNCING: + self.announce_label.setText("" + _("Announcing") + "") + else: + self.announce_label.setText( + "" + _("Not announcing") + " — " + state.value.replace("_", " ")) + self.announce_reason.setText(self.plugin.announcement_reason(st)) + + attempt = st["last_publish_attempt_at"] + note = st["last_publish_note"] + if attempt is None and not note: + self.last_publish_label.setText( + _("This plugin has not tried to announce yet in this session.")) + else: + parts = [] + if attempt is not None: + parts.append(_("Last announcement attempt by this plugin: {} ago").format( + nostr_check.format_age(int(time.time() - attempt)))) + if note: + parts.append(str(note)) + self.last_publish_label.setText(" · ".join(parts)) + + self._match_labels["net"].setText(st["r_tag"]) + self._match_labels["version"].setText(st["d_tag"]) + self._match_labels["kind"].setText(str(st["kind"])) + + achieved = st["pow_bits_achieved"] + target = st["pow_target"] + default_target = st["pow_default_taker_target"] + if achieved is None: + self._match_labels["pow"].setText("—") + else: + txt = _("{} bits (this wallet's target: {})").format(achieved, target) + if achieved < default_target: + txt = "" + txt + "" + self._match_labels["pow"].setText(txt) + # The single most common silent failure: SWAPSERVER_POW_TARGET means + # "bits to grind" here but "bits I demand" on the taker, so announcing + # below Electrum's default makes us invisible to stock wallets. + if achieved is not None and achieved < default_target: + self.pow_warning.setText(_( + "Takers using Electrum's default proof-of-work target of {default} " + "will silently ignore this server, because the announcement only " + "carries {achieved} bits. Raise 'Nostr PoW target' to {default} " + "and restart the server.").format(default=default_target, achieved=achieved)) + self.pow_warning.setVisible(True) + else: + self.pow_warning.setVisible(False) + def _refresh_history(self) -> None: try: history = get_swap_history(self.wallet) diff --git a/plugins/swapserver_gui/swapserver_gui.py b/plugins/swapserver_gui/swapserver_gui.py index d7b080f..44abf63 100644 --- a/plugins/swapserver_gui/swapserver_gui.py +++ b/plugins/swapserver_gui/swapserver_gui.py @@ -28,14 +28,17 @@ import asyncio import concurrent.futures import importlib -from typing import TYPE_CHECKING, Optional, List, Dict, Any +import time +from enum import Enum +from typing import TYPE_CHECKING, Optional, List, Dict, Any, Tuple from aiohttp import web +from electrum import constants from electrum.plugin import BasePlugin from electrum.util import get_asyncio_loop from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED -from electrum.submarine_swaps import NostrTransport +from electrum.submarine_swaps import NostrTransport, MIN_SWAP_AMOUNT_SAT # NB: ``from . import pow as swap_pow`` must NOT be used here. When Electrum # loads us as an external *zip* plugin it registers the package in sys.modules @@ -49,6 +52,7 @@ # ``__package__`` is taken from the submodule's own (correctly dotted) spec, so # importing through it works for both the zip and the directory layout. swap_pow = importlib.import_module('.pow', __package__) +nostr_check = importlib.import_module('.nostr_check', __package__) # Importing the bundled swapserver plugin's server module has the useful side # effect of registering the shared config vars (plugins.swapserver.port etc.) @@ -110,6 +114,86 @@ class SwapServerError(Exception): """Raised when the swap server cannot be started with the current settings.""" +class AnnounceState(Enum): + """Why the nostr announcement is (or is not) going out right now. + + The GUI used to say "announcing to N relay(s)" whenever the server was + running with relays configured, which is wrong in three common situations + that all look identical from the outside -- see :func:`announce_state`. + """ + + DISABLED = "disabled" # no relay configured + STOPPED = "stopped" # server not running + WAITING_UNLOCK = "waiting_unlock" + WAITING_POW = "waiting_pow" + NO_LIQUIDITY = "no_liquidity" + ANNOUNCING = "announcing" + + @property + def is_announcing(self) -> bool: + return self is AnnounceState.ANNOUNCING + + +def has_liquidity_to_announce( + *, + min_amount: Optional[int], + max_forward: Optional[int], + max_reverse: Optional[int], +) -> bool: + """Mirror ``NostrTransport.publish_offer``'s liquidity gate. + + Upstream (electrum/submarine_swaps.py) bails out with:: + + if sm._max_forward < sm._min_amount and sm._max_reverse < sm._min_amount: + ... "not publishing swap offer, no liquidity available" ; return + + ``_min_amount`` is ``MIN_SWAP_AMOUNT_SAT`` (20,000 sat) and the maxima come + from ``server_update_pairs``: ``max_forward`` is capped by *both* lightning + inbound capacity and the on-chain spendable balance, ``max_reverse`` by + lightning outbound. Note that ``server_update_pairs`` also rounds the + maxima *down* to two leading digits, so 19,999 sat becomes 19,000. + + A ``None`` anywhere means ``server_update_pairs`` has not run yet; upstream + would raise a TypeError on the comparison, which its ``@ignore_exceptions`` + decorator swallows -- i.e. no announcement either way. + """ + if min_amount is None or max_forward is None or max_reverse is None: + return False + return max_forward >= min_amount or max_reverse >= min_amount + + +def announce_state( + *, + running: bool, + nostr_enabled: bool, + wallet_locked: bool, + pow_grinding: bool, + min_amount: Optional[int], + max_forward: Optional[int], + max_reverse: Optional[int], +) -> AnnounceState: + """Classify the announcement path. Pure, so the GUI cannot drift from it. + + The order matches the order in which the real code blocks: + ``run_nostr_server`` waits for the wallet password *before* it builds a + transport, our ``_nostr_startup`` seeds the proof of work before handing + over to it, and ``publish_offer`` checks liquidity last. + """ + if not nostr_enabled: + return AnnounceState.DISABLED + if not running: + return AnnounceState.STOPPED + if wallet_locked: + return AnnounceState.WAITING_UNLOCK + if pow_grinding: + return AnnounceState.WAITING_POW + if not has_liquidity_to_announce(min_amount=min_amount, + max_forward=max_forward, + max_reverse=max_reverse): + return AnnounceState.NO_LIQUIDITY + return AnnounceState.ANNOUNCING + + class SwapServerGuiPlugin(BasePlugin): """Owns the swap-server lifecycle. The Qt layer subclasses this.""" @@ -134,6 +218,14 @@ def __init__(self, parent: Any, config: 'SimpleConfig', name: str) -> None: self._pow_gate: asyncio.Event = asyncio.Event() self._pow_state: Optional['swap_pow.PowState'] = None self._pow_grinding: bool = False + # Bookkeeping for the announcements *we* trigger (publish_now). Upstream's + # run_nostr_server keeps its transport private and publish_offer is + # decorated with @ignore_exceptions, so neither its schedule nor its + # success is observable from here -- which is exactly why the Diagnostics + # pane asks the relays instead of trusting local state. + self._last_publish_attempt_at: Optional[float] = None + self._last_publish_note: Optional[str] = None + self._check_fut: Optional['concurrent.futures.Future'] = None # ------------------------------------------------------------------ utils @property @@ -183,6 +275,49 @@ def _nostr_pubkey(self) -> Optional[bytes]: return None return bytes(pubkey[1:]) + # ------------------------------------------------------------- identity + def nostr_identity(self) -> Optional[Tuple[str, str]]: + """``(pubkey_hex, npub)`` for this wallet's nostr key, or None. + + This is the identity takers see and pin as ``SWAPSERVER_NPUB``. It is + derived from the wallet seed (``lnworker.nostr_keypair``), so it exists + whether or not the server is running. + + The hex form is x-only -- ``keypair.pubkey.hex()[2:]``, dropping the + compressed-key prefix byte -- exactly as ``NostrTransport.nostr_pubkey`` + computes it, so it matches what the taker's provider list displays. + """ + pubkey = self._nostr_pubkey() + if pubkey is None: + return None + pubkey_hex = pubkey.hex() + try: + from electrum_aionostr.util import to_nip19 + npub = to_nip19('npub', pubkey_hex) + except Exception: + self.logger.debug("could not encode npub", exc_info=True) + return None + return pubkey_hex, npub + + @staticmethod + def nostr_match_fields() -> Dict[str, Any]: + """The values a taker's filter must agree with, byte for byte. + + A mismatch in any of these makes the server invisible with no error on + either side, so they are worth showing verbatim: the network name + distinguishes 'signet' from 'mutinynet', and the event version differs + between Electrum releases. + """ + net_name = constants.net.NET_NAME + version = NostrTransport.NOSTR_EVENT_VERSION + return { + "net_name": net_name, + "event_version": version, + "kind": NostrTransport.USER_STATUS_NIP38, + "d_tag": nostr_check.d_tag_for(version), + "r_tag": nostr_check.r_tag_for(net_name), + } + def _save_pow_state(self, state: 'swap_pow.PowState') -> None: self._pow_state = state try: @@ -377,6 +512,9 @@ async def _one_shot_publish(self) -> None: if loop.time() >= deadline: self.logger.info("publish_now: no liquidity to advertise within " f"{self.PUBLISH_NOW_LIQUIDITY_WAIT_SEC}s; skipping") + self._last_publish_note = ( + f"gave up after {self.PUBLISH_NOW_LIQUIDITY_WAIT_SEC}s: " + f"no liquidity to advertise") return await asyncio.sleep(self.PUBLISH_NOW_POLL_SEC) transport = NostrTransport(self.config, sm, keypair) @@ -388,10 +526,17 @@ async def _one_shot_publish(self) -> None: except asyncio.TimeoutError: self.logger.info("publish_now: no relay connected; " "skipping immediate announcement") + self._last_publish_note = "no relay connected" return + self._last_publish_attempt_at = time.time() + # publish_offer is decorated with @ignore_exceptions upstream, so + # returning normally is NOT proof that a relay accepted the + # event. Only the Diagnostics check can confirm that. await transport.publish_offer(sm) + self._last_publish_note = "announcement sent to relays" self.logger.info("publish_now: immediate swap announcement published") - except Exception: + except Exception as e: + self._last_publish_note = f"failed: {type(e).__name__}: {e}" self.logger.warning("publish_now: immediate announcement failed", exc_info=True) @@ -426,6 +571,55 @@ def stop_server(self) -> None: self._pow_grinding = False self.logger.info("swap server stopped") + # ------------------------------------------------------------ diagnostics + def wallet_is_locked(self) -> bool: + """True while ``run_nostr_server`` would be stuck waiting for a password. + + Upstream loops on exactly this condition before it builds a transport + (electrum/submarine_swaps.py, ``run_nostr_server``), so a password- + protected wallet that was never unlocked announces nothing at all. + """ + wallet = self.wallet + if wallet is None: + return False + try: + return bool(wallet.has_password()) and wallet.get_unlocked_password() is None + except Exception: + return False + + def relay_list(self) -> List[str]: + return [r.strip() for r in (self.config.NOSTR_RELAYS or "").split(",") if r.strip()] + + def check_discoverability(self) -> 'concurrent.futures.Future': + """Ask every configured relay whether a taker could see this server. + + Non-blocking: returns a future resolving to a + ``nostr_check.DiscoveryReport``. Safe to call from the Qt GUI thread. + Raises :class:`SwapServerError` when there is nothing to check. + """ + identity = self.nostr_identity() + if identity is None: + raise SwapServerError("this wallet has no nostr key") + relays = self.relay_list() + if not relays: + raise SwapServerError("no nostr relay is configured") + pubkey_hex, npub = identity + fields = self.nostr_match_fields() + network = self._sm.network if self._sm is not None else None + coro = nostr_check.run_discovery_check( + relays=relays, + pubkey_hex=pubkey_hex, + npub=npub, + net_name=fields["net_name"], + event_version=fields["event_version"], + kind=fields["kind"], + taker_pow_target=int(self.config.SWAPSERVER_POW_TARGET or 0), + pow_bits_fn=swap_pow.pow_bits, + network=network, + ) + self._check_fut = self._spawn(coro, "discovery-check") + return self._check_fut + def request_pairs_update(self) -> None: """Ask the swap manager to recompute the advertised pairs (non-blocking).""" sm = self._sm @@ -471,8 +665,62 @@ def status(self) -> Dict[str, Any]: data["max_forward"] = sm._max_forward data["max_reverse"] = sm._max_reverse data["mining_fee"] = sm.mining_fee + + # ---- nostr identity and announcement diagnostics ------------------ + identity = self.nostr_identity() + data["nostr_pubkey"] = identity[0] if identity else None + data["nostr_npub"] = identity[1] if identity else None + data.update(self.nostr_match_fields()) + data["wallet_locked"] = self.wallet_is_locked() + data["announce_state"] = announce_state( + running=self._running, + nostr_enabled=bool(relays), + wallet_locked=data["wallet_locked"], + pow_grinding=self._pow_grinding, + min_amount=data["min_amount"], + max_forward=data["max_forward"], + max_reverse=data["max_reverse"], + ) + # What the announcement would actually carry, as opposed to the target. + pubkey = self._nostr_pubkey() + data["pow_bits_achieved"] = ( + swap_pow.pow_bits(pubkey, self.config.SWAPSERVER_ANN_POW_NONCE) + if pubkey is not None else None) + data["pow_default_taker_target"] = nostr_check.DEFAULT_TAKER_POW_TARGET + data["min_swap_amount"] = MIN_SWAP_AMOUNT_SAT + data["last_publish_attempt_at"] = self._last_publish_attempt_at + data["last_publish_note"] = self._last_publish_note return data + def announcement_reason(self, st: Optional[Dict[str, Any]] = None) -> str: + """One human sentence explaining :attr:`AnnounceState` for the GUI.""" + if st is None: + st = self.status() + state = st["announce_state"] + if state is AnnounceState.DISABLED: + return "No nostr relay is configured, so nothing is announced." + if state is AnnounceState.STOPPED: + return "The swap server is stopped." + if state is AnnounceState.WAITING_UNLOCK: + return ("Waiting for the wallet password. Electrum does not start " + "the nostr announcement until the wallet is unlocked.") + if state is AnnounceState.WAITING_POW: + return ("Computing the announcement proof of work. Nothing is " + "published until it completes.") + if state is AnnounceState.NO_LIQUIDITY: + min_amount = st.get("min_amount") or st["min_swap_amount"] + return (f"Not announcing: no liquidity to advertise. A swap offer is " + f"only published when max forward or max reverse reaches " + f"{min_amount:,} sat. Max forward is capped by lightning " + f"inbound capacity AND the on-chain balance; max reverse by " + f"lightning outbound capacity.") + return ("Announcing. Use the discoverability check below to confirm the " + "relays are actually serving the offer to takers.") + + def cancel_discovery_check(self) -> None: + self._cancel_fut(self._check_fut) + self._check_fut = None + def save_settings( config: 'SimpleConfig', diff --git a/tests/fake_relay.py b/tests/fake_relay.py new file mode 100644 index 0000000..e5b1fee --- /dev/null +++ b/tests/fake_relay.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""A minimal in-process nostr relay, just enough of NIP-01 for the e2e tests. + +Why not the real thing: ``contrib/regtest_demo/run_demo.sh`` installs the +``nostr-relay`` package into its own venv for manual review, but CI has no such +venv and no network. aiohttp is already an Electrum dependency and speaks +WebSocket on both ends, so we can host a real relay in the test process and let +the real ``electrum_aionostr`` client talk to it over a real socket. Only the +storage layer is fake; the wire protocol, the event signing and the signature +verification on receipt are genuine. + +Implements: + * ``["EVENT", ]`` -> stores, replies ``["OK", id, true, ""]`` + * ``["REQ", , …]`` -> matching ``["EVENT", sub_id, ]`` + then ``["EOSE", sub_id]`` + * ``["CLOSE", ]`` -> ignored (we never stream live events) + +Filters support ``ids``, ``authors``, ``kinds``, ``since``, ``until``, +``limit`` and single-letter tag queries (``#d``, ``#r``, ``#p``…). As NIP-01 +requires, ``limit`` keeps the *newest* events, which is what lets the tests +reproduce a busy relay pushing an offer out of a taker's page. +""" +import asyncio +import json +from typing import Any, Dict, List, Optional + +from aiohttp import web, WSMsgType + + +def event_matches(event: Dict[str, Any], filt: Dict[str, Any]) -> bool: + """NIP-01 filter semantics: every condition must hold (AND across keys).""" + if 'ids' in filt and event['id'] not in filt['ids']: + return False + if 'authors' in filt and event['pubkey'] not in filt['authors']: + return False + if 'kinds' in filt and event['kind'] not in filt['kinds']: + return False + if 'since' in filt and event['created_at'] < filt['since']: + return False + if 'until' in filt and event['created_at'] > filt['until']: + return False + for key, wanted in filt.items(): + if not (key.startswith('#') and len(key) == 2): + continue + name = key[1] + values = {t[1] for t in event.get('tags', []) if len(t) >= 2 and t[0] == name} + if not values.intersection(wanted): + return False + return True + + +class FakeRelay: + """A relay bound to an ephemeral localhost port. Use as an async context.""" + + def __init__(self, *, accept_writes: bool = True) -> None: + self.events: List[Dict[str, Any]] = [] + self.accept_writes = accept_writes + self.reqs: List[List[Any]] = [] # every REQ we were sent, for assertions + self._runner: Optional[web.AppRunner] = None + self._site: Optional[web.TCPSite] = None + self.url: Optional[str] = None + + async def __aenter__(self) -> 'FakeRelay': + await self.start() + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.stop() + + async def start(self) -> str: + app = web.Application() + app.add_routes([web.get('/', self._handle)]) + self._runner = web.AppRunner(app) + await self._runner.setup() + self._site = web.TCPSite(self._runner, host='127.0.0.1', port=0) + await self._site.start() + port = self._site._server.sockets[0].getsockname()[1] + self.url = f"ws://127.0.0.1:{port}" + return self.url + + async def stop(self) -> None: + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + self._site = None + + def store(self, event: Dict[str, Any]) -> None: + self.events.append(event) + + def query(self, filt: Dict[str, Any]) -> List[Dict[str, Any]]: + hits = [e for e in self.events if event_matches(e, filt)] + # NIP-01: with a limit, a relay returns the *most recent* matches. + hits.sort(key=lambda e: e['created_at'], reverse=True) + limit = filt.get('limit') + if isinstance(limit, int) and limit >= 0: + hits = hits[:limit] + return hits + + async def _handle(self, request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse() + await ws.prepare(request) + async for msg in ws: + if msg.type != WSMsgType.TEXT: + continue + try: + message = json.loads(msg.data) + except ValueError: + continue + if not isinstance(message, list) or not message: + continue + verb = message[0] + if verb == 'EVENT' and len(message) >= 2: + event = message[1] + if self.accept_writes: + self.store(event) + await ws.send_str(json.dumps(["OK", event['id'], True, ""])) + else: + await ws.send_str(json.dumps( + ["OK", event['id'], False, "blocked: test relay is read-only"])) + elif verb == 'REQ' and len(message) >= 3: + sub_id = message[1] + filters = message[2:] + self.reqs.append(message) + sent = [] + for filt in filters: + for event in self.query(filt): + if event['id'] in sent: + continue + sent.append(event['id']) + await ws.send_str(json.dumps(["EVENT", sub_id, event])) + await ws.send_str(json.dumps(["EOSE", sub_id])) + elif verb == 'CLOSE': + pass + return ws diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..cd65b19 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Unit tests for the announcement-state machine and the nostr identity. + +Covers the plugin-side half of "why can nobody see my swap server?": + + * :func:`announce_state` -- the GUI must never claim to be announcing while + the announcement path is actually blocked. + * :func:`has_liquidity_to_announce` -- mirrors the gate in + ``NostrTransport.publish_offer`` that silently suppresses the offer. + * the nostr pubkey/npub shown in the Status tab. + +No relay, no network, no PyQt6. + +Run with: python3 -m pytest tests/test_diagnostics.py +""" +import os +import sys +import unittest +from unittest import mock + +# --- make electrum + the plugin importable --------------------------------- +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PROJECT_ROOT = os.path.dirname(os.path.dirname(_HERE)) # /home/user/electrum_swapgui +_ELECTRUM_SRC = os.environ.get("ELECTRUM_SRC", os.path.join(_PROJECT_ROOT, "electrum")) +_PLUGINS_DIR = os.path.join(os.path.dirname(_HERE), "plugins") +for _p in (_ELECTRUM_SRC, _PLUGINS_DIR): + if _p not in sys.path: + sys.path.insert(0, _p) + +from electrum import constants # noqa: E402 +from electrum.submarine_swaps import NostrTransport, MIN_SWAP_AMOUNT_SAT # noqa: E402 +from electrum_aionostr.util import from_nip19 # noqa: E402 + +from swapserver_gui.swapserver_gui import ( # noqa: E402 + AnnounceState, SwapServerGuiPlugin, SwapServerError, announce_state, + has_liquidity_to_announce, +) +from swapserver_gui import nostr_check as nc # noqa: E402 + + +# A valid compressed pubkey: 33 bytes, the first of which is the prefix that +# both the PoW preimage and the nostr x-only encoding drop. +PUBKEY_33 = bytes([0x02]) + bytes(range(32)) +XONLY_HEX = PUBKEY_33[1:].hex() + + +class _Config: + def __init__(self, *, relays="", pow_target=30, nonce=0): + self.SWAPSERVER_PORT = None + self.NOSTR_RELAYS = relays + self.SWAPSERVER_FEE_MILLIONTHS = 5000 + self.SWAPSERVER_POW_TARGET = pow_target + self.SWAPSERVER_ANN_POW_NONCE = nonce + self.SWAPSERVER_GUI_AUTOSTART = False + self.SWAPSERVER_GUI_POW_STATE = None + + +class _SwapManager: + def __init__(self): + self.is_server = False + self.http_server = None + self.network = None + self.percentage = None + self._min_amount = None + self._max_forward = None + self._max_reverse = None + self.mining_fee = None + + +def _make_plugin(config, *, keypair=None, has_password=False, unlocked=True): + # BasePlugin registers @hook methods globally; SwapServerGuiPlugin defines + # none, so nothing leaks into the global hooks table. + plugin = SwapServerGuiPlugin(mock.MagicMock(), config, "swapserver_gui") + sm = _SwapManager() + wallet = mock.MagicMock() + wallet.lnworker.swap_manager = sm + wallet.lnworker.nostr_keypair = keypair + wallet.has_password.return_value = has_password + wallet.get_unlocked_password.return_value = "pw" if unlocked else None + plugin.bind_wallet(wallet) + return plugin, sm + + +class TestHasLiquidityToAnnounce(unittest.TestCase): + """Mirror of ``publish_offer``'s gate: announce if EITHER direction fits.""" + + def test_forward_alone_is_enough(self): + self.assertTrue(has_liquidity_to_announce( + min_amount=20000, max_forward=20000, max_reverse=0)) + + def test_reverse_alone_is_enough(self): + self.assertTrue(has_liquidity_to_announce( + min_amount=20000, max_forward=0, max_reverse=50000)) + + def test_both_below_minimum_suppresses_the_offer(self): + self.assertFalse(has_liquidity_to_announce( + min_amount=20000, max_forward=19999, max_reverse=19999)) + + def test_boundary_is_inclusive(self): + # upstream bails on '<', so exactly the minimum is publishable + self.assertTrue(has_liquidity_to_announce( + min_amount=20000, max_forward=20000, max_reverse=0)) + self.assertFalse(has_liquidity_to_announce( + min_amount=20000, max_forward=19999, max_reverse=0)) + + def test_none_means_not_yet_computed(self): + # server_update_pairs has not run: upstream raises TypeError on the + # comparison and @ignore_exceptions swallows it -> nothing published. + for kwargs in (dict(min_amount=None, max_forward=1, max_reverse=1), + dict(min_amount=20000, max_forward=None, max_reverse=1), + dict(min_amount=20000, max_forward=1, max_reverse=None)): + with self.subTest(**kwargs): + self.assertFalse(has_liquidity_to_announce(**kwargs)) + + def test_matches_the_real_minimum(self): + self.assertEqual(MIN_SWAP_AMOUNT_SAT, 20_000) + + +class TestAnnounceState(unittest.TestCase): + + BASE = dict(running=True, nostr_enabled=True, wallet_locked=False, + pow_grinding=False, min_amount=20000, max_forward=50000, + max_reverse=50000) + + def state(self, **overrides): + return announce_state(**{**self.BASE, **overrides}) + + def test_healthy_server_announces(self): + self.assertIs(self.state(), AnnounceState.ANNOUNCING) + self.assertTrue(self.state().is_announcing) + + def test_no_relays_is_disabled(self): + self.assertIs(self.state(nostr_enabled=False), AnnounceState.DISABLED) + + def test_stopped_server(self): + self.assertIs(self.state(running=False), AnnounceState.STOPPED) + + def test_locked_wallet_blocks_everything(self): + self.assertIs(self.state(wallet_locked=True), AnnounceState.WAITING_UNLOCK) + + def test_pow_grinding(self): + self.assertIs(self.state(pow_grinding=True), AnnounceState.WAITING_POW) + + def test_no_liquidity_is_not_announcing(self): + """The reported symptom: everything looks running, nothing is published.""" + self.assertIs(self.state(max_forward=0, max_reverse=0), + AnnounceState.NO_LIQUIDITY) + self.assertFalse(self.state(max_forward=0, max_reverse=0).is_announcing) + + def test_precedence(self): + # relays first, then running, then unlock, then pow, then liquidity + self.assertIs(self.state(nostr_enabled=False, running=False, + wallet_locked=True, pow_grinding=True, + max_forward=0, max_reverse=0), + AnnounceState.DISABLED) + self.assertIs(self.state(running=False, wallet_locked=True, + pow_grinding=True, max_forward=0, max_reverse=0), + AnnounceState.STOPPED) + self.assertIs(self.state(wallet_locked=True, pow_grinding=True, + max_forward=0, max_reverse=0), + AnnounceState.WAITING_UNLOCK) + self.assertIs(self.state(pow_grinding=True, max_forward=0, max_reverse=0), + AnnounceState.WAITING_POW) + + +class TestNostrIdentity(unittest.TestCase): + + def test_npub_round_trips_to_the_xonly_pubkey(self): + keypair = mock.Mock(pubkey=PUBKEY_33) + plugin, _ = _make_plugin(_Config(), keypair=keypair) + identity = plugin.nostr_identity() + self.assertIsNotNone(identity) + pubkey_hex, npub = identity + # must match NostrTransport.nostr_pubkey == keypair.pubkey.hex()[2:] + self.assertEqual(pubkey_hex, XONLY_HEX) + self.assertEqual(pubkey_hex, PUBKEY_33.hex()[2:]) + self.assertTrue(npub.startswith('npub1')) + self.assertEqual(from_nip19(npub)['object'].hex(), pubkey_hex) + + def test_no_keypair_yields_no_identity(self): + plugin, _ = _make_plugin(_Config(), keypair=None) + self.assertIsNone(plugin.nostr_identity()) + + def test_match_fields_follow_upstream(self): + plugin, _ = _make_plugin(_Config()) + fields = plugin.nostr_match_fields() + self.assertEqual(fields["net_name"], constants.net.NET_NAME) + self.assertEqual(fields["event_version"], NostrTransport.NOSTR_EVENT_VERSION) + self.assertEqual(fields["kind"], NostrTransport.USER_STATUS_NIP38) + self.assertEqual(fields["d_tag"], + f"electrum-swapserver-{NostrTransport.NOSTR_EVENT_VERSION}") + self.assertEqual(fields["r_tag"], f"net:{constants.net.NET_NAME}") + + +class TestWalletLocked(unittest.TestCase): + + def test_unprotected_wallet_is_never_locked(self): + plugin, _ = _make_plugin(_Config(), has_password=False, unlocked=False) + self.assertFalse(plugin.wallet_is_locked()) + + def test_protected_and_not_unlocked(self): + plugin, _ = _make_plugin(_Config(), has_password=True, unlocked=False) + self.assertTrue(plugin.wallet_is_locked()) + + def test_protected_but_unlocked(self): + plugin, _ = _make_plugin(_Config(), has_password=True, unlocked=True) + self.assertFalse(plugin.wallet_is_locked()) + + +class TestStatusSnapshot(unittest.TestCase): + + def test_status_exposes_the_diagnostics(self): + keypair = mock.Mock(pubkey=PUBKEY_33) + plugin, sm = _make_plugin( + _Config(relays="wss://a,wss://b", pow_target=30), keypair=keypair) + sm._min_amount = 20000 + sm._max_forward = 0 + sm._max_reverse = 0 + st = plugin.status() + self.assertEqual(st["nostr_pubkey"], XONLY_HEX) + self.assertTrue(st["nostr_npub"].startswith("npub1")) + self.assertEqual(st["net_name"], constants.net.NET_NAME) + self.assertEqual(st["min_swap_amount"], MIN_SWAP_AMOUNT_SAT) + self.assertEqual(st["pow_default_taker_target"], nc.DEFAULT_TAKER_POW_TARGET) + self.assertEqual(st["pow_bits_achieved"], 0) # nonce 0 == no work + # server not running -> STOPPED, regardless of the missing liquidity + self.assertIs(st["announce_state"], AnnounceState.STOPPED) + + def test_no_liquidity_reason_names_the_threshold(self): + keypair = mock.Mock(pubkey=PUBKEY_33) + plugin, sm = _make_plugin(_Config(relays="wss://a"), keypair=keypair) + sm._min_amount = 20000 + sm._max_forward = 0 + sm._max_reverse = 0 + plugin._running = True + st = plugin.status() + self.assertIs(st["announce_state"], AnnounceState.NO_LIQUIDITY) + reason = plugin.announcement_reason(st) + self.assertIn("20,000 sat", reason) + self.assertIn("no liquidity", reason.lower()) + + def test_every_state_has_a_reason(self): + plugin, _ = _make_plugin(_Config()) + for state in AnnounceState: + with self.subTest(state=state): + st = plugin.status() + st["announce_state"] = state + reason = plugin.announcement_reason(st) + self.assertTrue(reason) + self.assertTrue(reason.endswith(".")) + + +class TestCheckDiscoverabilityGuards(unittest.TestCase): + """The button must fail loudly rather than silently doing nothing.""" + + def test_no_nostr_key(self): + plugin, _ = _make_plugin(_Config(relays="wss://a"), keypair=None) + with self.assertRaises(SwapServerError) as ctx: + plugin.check_discoverability() + self.assertIn("nostr key", str(ctx.exception)) + + def test_no_relays_configured(self): + keypair = mock.Mock(pubkey=PUBKEY_33) + plugin, _ = _make_plugin(_Config(relays=""), keypair=keypair) + with self.assertRaises(SwapServerError) as ctx: + plugin.check_discoverability() + self.assertIn("relay", str(ctx.exception)) + + def test_relay_list_is_trimmed(self): + plugin, _ = _make_plugin(_Config(relays=" wss://a , ,wss://b ")) + self.assertEqual(plugin.relay_list(), ["wss://a", "wss://b"]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_discovery_e2e.py b/tests/test_discovery_e2e.py new file mode 100644 index 0000000..8675755 --- /dev/null +++ b/tests/test_discovery_e2e.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""End-to-end tests for the discoverability check, against a real relay. + +The relay is the minimal NIP-01 server in ``fake_relay.py``, hosted in this +process, but everything above the socket is real: offers are published with +``electrum_aionostr._add_event`` exactly as ``NostrTransport.publish_offer`` +does, they are signed and their signatures are verified by the client on +receipt, and the check under test is the one the GUI button calls. + +The point of these tests is that the check must not just say "not found" -- it +has to name the field that is wrong, because that is the whole reason it exists. + +Run with: python3 -m pytest tests/test_discovery_e2e.py +""" +import asyncio +import json +import os +import sys +import time +import unittest + +# --- make electrum + the plugin importable --------------------------------- +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PROJECT_ROOT = os.path.dirname(os.path.dirname(_HERE)) # /home/user/electrum_swapgui +_ELECTRUM_SRC = os.environ.get("ELECTRUM_SRC", os.path.join(_PROJECT_ROOT, "electrum")) +_PLUGINS_DIR = os.path.join(os.path.dirname(_HERE), "plugins") +for _p in (_ELECTRUM_SRC, _PLUGINS_DIR): + if _p not in sys.path: + sys.path.insert(0, _p) + +import electrum_aionostr as aionostr # noqa: E402 +from electrum_aionostr.key import PrivateKey # noqa: E402 +from electrum_aionostr.util import to_nip19 # noqa: E402 + +from swapserver_gui import nostr_check as nc # noqa: E402 +from swapserver_gui import pow as swap_pow # noqa: E402 +from swapserver_gui.nostr_check import CheckStatus # noqa: E402 + +from fake_relay import FakeRelay # noqa: E402 + + +KIND = 30315 # NostrTransport.USER_STATUS_NIP38 +VERSION = 5 # NostrTransport.NOSTR_EVENT_VERSION +NET = "signet" # the chain the reported problem was on +POW_BITS = 8 # small enough to grind inline, real enough to be checked + + +def find_nonce(pubkey_hex: str, bits: int) -> int: + pubkey = bytes.fromhex(pubkey_hex) + nonce = 1 + while swap_pow.pow_bits(pubkey, nonce) < bits: + nonce += 1 + return nonce + + +class _Server: + """A swap server identity plus the offer it announces.""" + + def __init__(self, *, pow_bits: int = POW_BITS) -> None: + self.privkey = PrivateKey() + self.nsec = to_nip19('nsec', self.privkey.hex()) + self.pubkey_hex = self.privkey.public_key.hex() + self.npub = to_nip19('npub', self.pubkey_hex) + self.pow_nonce = find_nonce(self.pubkey_hex, pow_bits) + + def offer_content(self, **overrides) -> str: + offer = { + 'percentage_fee': 0.5, + 'mining_fee': 1000, + 'min_amount': 20000, + 'max_forward_amount': 150000, + 'max_reverse_amount': 120000, + 'relays': 'wss://relay.example.com', + 'pow_nonce': hex(self.pow_nonce), + } + offer.update(overrides) + return json.dumps(offer) + + def tags(self, *, net: str = NET, version: int = VERSION, created_at: int): + # identical to publish_offer's tag list + return [['d', f'electrum-swapserver-{version}'], + ['r', f'net:{net}'], + ['expiration', str(created_at + 610)]] + + +async def publish(relay_url: str, server: _Server, *, net: str = NET, + version: int = VERSION, created_at=None, content=None) -> None: + """Announce an offer the way the swap server does.""" + if created_at is None: + created_at = int(time.time()) + manager = aionostr.Manager([relay_url], private_key=server.nsec, connect_timeout=5) + await manager.connect() + try: + await aionostr._add_event( + manager, + kind=KIND, + tags=server.tags(net=net, version=version, created_at=created_at), + content=content if content is not None else server.offer_content(), + created_at=created_at, + private_key=server.nsec) + finally: + await manager.close() + + +async def run_check(relays, server: _Server, *, taker_pow_target: int = POW_BITS, + net: str = NET, version: int = VERSION): + return await nc.run_discovery_check( + relays=relays, + pubkey_hex=server.pubkey_hex, + npub=server.npub, + net_name=net, + event_version=version, + kind=KIND, + taker_pow_target=taker_pow_target, + pow_bits_fn=swap_pow.pow_bits, + network=None, + connect_timeout=5, + query_timeout=5.0, + ) + + +def run(coro): + return asyncio.run(asyncio.wait_for(coro, timeout=60)) + + +class DiscoveryE2ETests(unittest.TestCase): + + def test_published_offer_is_discoverable(self): + async def main(): + async with FakeRelay() as relay: + server = _Server() + await publish(relay.url, server) + report = await run_check([relay.url], server) + self.assertEqual(len(report.results), 1) + result = report.results[0] + self.assertEqual(result.status, CheckStatus.DISCOVERABLE, result.detail) + self.assertEqual(report.ok_count, 1) + self.assertIn("Discoverable on 1 of 1", report.headline()) + self.assertGreaterEqual(result.pow_bits, POW_BITS) + self.assertIsNotNone(result.event_age_sec) + # the relay really was asked the taker's question + filters = [f for req in relay.reqs for f in req[2:]] + self.assertTrue(any( + f.get("#d") == [f"electrum-swapserver-{VERSION}"] + and f.get("#r") == [f"net:{NET}"] for f in filters)) + run(main()) + + def test_nothing_published_reports_no_event(self): + async def main(): + async with FakeRelay() as relay: + server = _Server() + report = await run_check([relay.url], server) + result = report.results[0] + self.assertEqual(result.status, CheckStatus.NO_EVENT) + self.assertIn("expired", result.detail) + self.assertEqual(report.ok_count, 0) + run(main()) + + def test_relay_that_rejects_writes_reports_no_event(self): + """A relay that silently drops our announcement looks like never having + announced -- which is exactly what the operator needs to be told.""" + async def main(): + async with FakeRelay(accept_writes=False) as relay: + server = _Server() + # the relay answers ["OK", id, false, …]: the publish call + # completes without raising, but nothing was stored. + await publish(relay.url, server) + self.assertEqual(relay.events, []) + report = await run_check([relay.url], server) + self.assertEqual(report.results[0].status, CheckStatus.NO_EVENT) + run(main()) + + def test_raising_the_taker_target_exposes_low_pow(self): + """The silent killer: same event, stricter taker, no longer visible.""" + async def main(): + async with FakeRelay() as relay: + server = _Server(pow_bits=POW_BITS) + await publish(relay.url, server) + + lenient = await run_check([relay.url], server, taker_pow_target=POW_BITS) + self.assertEqual(lenient.results[0].status, CheckStatus.DISCOVERABLE) + + strict = await run_check([relay.url], server, taker_pow_target=30) + result = strict.results[0] + self.assertEqual(result.status, CheckStatus.LOW_POW) + self.assertIn("30", result.detail) + self.assertEqual(strict.ok_count, 0) + run(main()) + + def test_low_pow_warning_survives_a_healthy_headline(self): + """Discoverable to *us* but not to a stock taker: the report must say so.""" + async def main(): + async with FakeRelay() as relay: + server = _Server(pow_bits=POW_BITS) + await publish(relay.url, server) + report = await run_check([relay.url], server, taker_pow_target=POW_BITS) + self.assertEqual(report.ok_count, 1) + warnings = " ".join(report.warnings()) + self.assertIn(str(nc.DEFAULT_TAKER_POW_TARGET), warnings) + run(main()) + + def test_wrong_network_tag_is_named(self): + """A mutinynet server looking for signet takers, or vice versa.""" + async def main(): + async with FakeRelay() as relay: + server = _Server() + await publish(relay.url, server, net="mutinynet") + report = await run_check([relay.url], server, net="signet") + result = report.results[0] + self.assertEqual(result.status, CheckStatus.WRONG_NET) + self.assertIn("net:mutinynet", result.detail) + self.assertIn("net:signet", result.detail) + run(main()) + + def test_wrong_event_version_is_named(self): + async def main(): + async with FakeRelay() as relay: + server = _Server() + await publish(relay.url, server, version=4) + report = await run_check([relay.url], server, version=5) + result = report.results[0] + self.assertEqual(result.status, CheckStatus.WRONG_VERSION) + self.assertIn("electrum-swapserver-4", result.detail) + run(main()) + + def test_offer_older_than_the_lookback_window_reads_as_no_event(self): + """A server that stopped announcing over an hour ago is simply gone. + + Both queries use ``since = now - 1h`` (upstream's window), so the relay + still holds the event but never returns it. The operator is correctly + told nothing is stored rather than being shown a stale success. + (The explicit STALE verdict is reachable via clock skew; that path is + covered directly in test_nostr_check.py.) + """ + async def main(): + async with FakeRelay() as relay: + server = _Server() + await publish(relay.url, server, created_at=int(time.time()) - 7200) + self.assertEqual(len(relay.events), 1) + report = await run_check([relay.url], server) + self.assertEqual(report.results[0].status, CheckStatus.NO_EVENT) + run(main()) + + def test_unreachable_relay_is_distinguished_from_a_missing_offer(self): + async def main(): + async with FakeRelay() as relay: + server = _Server() + await publish(relay.url, server) + dead = "ws://127.0.0.1:1" # nothing listens here + report = await run_check([relay.url, dead], server) + by_relay = {r.relay: r for r in report.results} + self.assertEqual(len(report.results), 2) + self.assertEqual(by_relay[relay.url].status, CheckStatus.DISCOVERABLE) + self.assertEqual(by_relay[dead].status, CheckStatus.UNREACHABLE) + self.assertIn("Discoverable on 1 of 2", report.headline()) + run(main()) + + def test_busy_relay_can_crowd_us_out(self): + """Takers ask for 10 offers; a relay with newer ones can hide us.""" + async def main(): + async with FakeRelay() as relay: + server = _Server() + now = int(time.time()) + await publish(relay.url, server, created_at=now - 600) + for i in range(nc.TAKER_QUERY_LIMIT): + other = _Server() + await publish(relay.url, other, created_at=now - i) + report = await run_check([relay.url], server) + result = report.results[0] + self.assertEqual(result.status, CheckStatus.CROWDED_OUT, result.detail) + self.assertIn("busy relay", " ".join(report.warnings())) + run(main()) + + def test_no_relays_configured(self): + report = run(run_check([], _Server())) + self.assertEqual(report.results, []) + self.assertIn("No relays configured", report.headline()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_nostr_check.py b/tests/test_nostr_check.py new file mode 100644 index 0000000..0bf36f7 --- /dev/null +++ b/tests/test_nostr_check.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""Unit tests for the discoverability rule engine (``swapserver_gui.nostr_check``). + +These pin the plugin's model of *why a taker rejects an offer* to what +``NostrTransport._get_pairs_loop`` in electrum/submarine_swaps.py actually does. +If upstream changes the filter or the order of its checks, these fail loudly -- +which is the point: a wrong diagnosis is worse than none. + +No relay, no network, no PyQt6. + +Run with: python3 -m pytest tests/test_nostr_check.py +""" +import json +import os +import sys +import time +import unittest + +# --- make electrum + the plugin importable --------------------------------- +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PROJECT_ROOT = os.path.dirname(os.path.dirname(_HERE)) # /home/user/electrum_swapgui +_ELECTRUM_SRC = os.environ.get("ELECTRUM_SRC", os.path.join(_PROJECT_ROOT, "electrum")) +_PLUGINS_DIR = os.path.join(os.path.dirname(_HERE), "plugins") +for _p in (_ELECTRUM_SRC, _PLUGINS_DIR): + if _p not in sys.path: + sys.path.insert(0, _p) + +from swapserver_gui import nostr_check as nc # noqa: E402 +from swapserver_gui import pow as swap_pow # noqa: E402 +from swapserver_gui.nostr_check import CheckStatus, EventView # noqa: E402 + + +NET = "signet" +VERSION = 5 +PUBKEY = "11" * 32 # x-only, valid hex + + +def find_nonce(pubkey_hex: str, bits: int) -> int: + """Smallest nonce giving at least ``bits`` of work. Only usable for small bits.""" + pubkey = bytes.fromhex(pubkey_hex) + nonce = 1 + while swap_pow.pow_bits(pubkey, nonce) < bits: + nonce += 1 + return nonce + + +def make_content(*, pow_nonce: int, **overrides) -> str: + """The offer payload as ``NostrTransport.publish_offer`` builds it.""" + offer = { + 'percentage_fee': 0.5, + 'mining_fee': 1000, + 'min_amount': 20000, + 'max_forward_amount': 150000, + 'max_reverse_amount': 120000, + 'relays': 'wss://relay.example.com', + 'pow_nonce': hex(pow_nonce), + } + offer.update(overrides) + for key in [k for k, v in offer.items() if v is _MISSING]: + del offer[key] + return json.dumps(offer) + + +_MISSING = object() + + +def make_view(*, pow_bits: int = 8, net: str = NET, version: int = VERSION, + created_at=None, tags=None, content=None, pubkey: str = PUBKEY) -> EventView: + if created_at is None: + created_at = int(time.time()) + if tags is None: + tags = [['d', f'electrum-swapserver-{version}'], + ['r', f'net:{net}'], + ['expiration', str(created_at + 610)]] + if content is None: + content = make_content(pow_nonce=find_nonce(pubkey, pow_bits)) + return EventView(pubkey=pubkey, created_at=created_at, tags=tags, content=content) + + +def evaluate(view, *, taker_pow_target=8, net=NET, version=VERSION, now_ts=None): + return nc.evaluate_offer_event( + view, net_name=net, event_version=version, + taker_pow_target=taker_pow_target, pow_bits_fn=swap_pow.pow_bits, + now_ts=now_ts) + + +class TestEvaluateOfferEvent(unittest.TestCase): + + def test_valid_offer_is_discoverable(self): + verdict = evaluate(make_view(pow_bits=8), taker_pow_target=8) + self.assertEqual(verdict.status, CheckStatus.DISCOVERABLE) + self.assertTrue(verdict.is_ok) + self.assertGreaterEqual(verdict.pow_bits, 8) + + def test_wrong_event_version_is_named(self): + # a taker on Electrum v5 looking at a v4 announcement + verdict = evaluate(make_view(version=4)) + self.assertEqual(verdict.status, CheckStatus.WRONG_VERSION) + self.assertIn("electrum-swapserver-4", verdict.detail) + self.assertIn("electrum-swapserver-5", verdict.detail) + + def test_wrong_network_is_named(self): + # the exact reported symptom: a signet server nobody can find. + # 'mutinynet' is its own NET_NAME upstream, not an alias for signet. + verdict = evaluate(make_view(net="mutinynet"), net="signet") + self.assertEqual(verdict.status, CheckStatus.WRONG_NET) + self.assertIn("net:mutinynet", verdict.detail) + self.assertIn("net:signet", verdict.detail) + + def test_offer_older_than_an_hour_is_stale(self): + now = int(time.time()) + verdict = evaluate(make_view(created_at=now - 3601), now_ts=now) + self.assertEqual(verdict.status, CheckStatus.STALE) + + def test_offer_exactly_one_hour_old_still_passes(self): + # upstream compares with '<', so the boundary itself is accepted + now = int(time.time()) + verdict = evaluate(make_view(created_at=now - 3600), now_ts=now) + self.assertEqual(verdict.status, CheckStatus.DISCOVERABLE) + + def test_clock_skew_into_the_future_is_reported(self): + now = int(time.time()) + verdict = evaluate(make_view(created_at=now + 7200), now_ts=now) + self.assertEqual(verdict.status, CheckStatus.STALE) + self.assertIn("clock", verdict.detail) + + def test_pow_below_taker_target_is_rejected(self): + verdict = evaluate(make_view(pow_bits=4), taker_pow_target=16) + self.assertEqual(verdict.status, CheckStatus.LOW_POW) + self.assertIn("16", verdict.detail) + self.assertLess(verdict.pow_bits, 16) + + def test_pow_equal_to_taker_target_is_accepted(self): + # upstream rejects with '<', so meeting the target exactly is fine + nonce = find_nonce(PUBKEY, 8) + bits = swap_pow.pow_bits(bytes.fromhex(PUBKEY), nonce) + verdict = evaluate(make_view(pow_bits=8), taker_pow_target=bits) + self.assertEqual(verdict.status, CheckStatus.DISCOVERABLE) + + def test_malformed_content_is_unparseable(self): + self.assertEqual(evaluate(make_view(content="not json")).status, + CheckStatus.UNPARSEABLE) + self.assertEqual(evaluate(make_view(content='"a string"')).status, + CheckStatus.UNPARSEABLE) + + def test_three_element_tag_kills_the_whole_event(self): + # upstream builds {k: v for k, v in event.tags}, which raises on a tag + # that is not a 2-tuple and drops the event inside a bare except. + now = int(time.time()) + tags = [['d', f'electrum-swapserver-{VERSION}'], + ['r', f'net:{NET}'], + ['e', 'id', 'wss://relay']] + verdict = evaluate(make_view(created_at=now, tags=tags)) + self.assertEqual(verdict.status, CheckStatus.UNPARSEABLE) + self.assertIn("two elements", verdict.detail) + + def test_non_hex_pow_nonce_is_unparseable(self): + content = make_content(pow_nonce=1).replace('"0x1"', '"zzz"') + self.assertEqual(evaluate(make_view(content=content)).status, + CheckStatus.UNPARSEABLE) + + def test_missing_payload_fields_are_unparseable(self): + for field in ('percentage_fee', 'mining_fee', 'min_amount', + 'max_forward_amount', 'max_reverse_amount', 'relays'): + with self.subTest(field=field): + content = make_content(pow_nonce=find_nonce(PUBKEY, 8), + **{field: _MISSING}) + verdict = evaluate(make_view(content=content)) + self.assertEqual(verdict.status, CheckStatus.UNPARSEABLE) + + def test_check_order_matches_upstream(self): + """Version is blamed before network, network before staleness, and both + before proof of work -- the order _get_pairs_loop uses.""" + now = int(time.time()) + # wrong version AND wrong net AND stale AND low pow -> version wins + view = make_view(version=4, net="mutinynet", created_at=now - 99999, + pow_bits=1) + self.assertEqual(evaluate(view, now_ts=now, taker_pow_target=30).status, + CheckStatus.WRONG_VERSION) + # wrong net AND stale AND low pow -> net wins + view = make_view(net="mutinynet", created_at=now - 99999, pow_bits=1) + self.assertEqual(evaluate(view, now_ts=now, taker_pow_target=30).status, + CheckStatus.WRONG_NET) + # stale AND low pow -> stale wins + view = make_view(created_at=now - 99999, pow_bits=1) + self.assertEqual(evaluate(view, now_ts=now, taker_pow_target=30).status, + CheckStatus.STALE) + + +class TestTagDict(unittest.TestCase): + + def test_first_value_wins_per_name(self): + view = EventView(pubkey=PUBKEY, created_at=0, + tags=[['d', 'one'], ['r', 'two']], content="{}") + self.assertEqual(view.tag_dict(), {'d': 'one', 'r': 'two'}) + + def test_bad_arity_returns_none(self): + for tags in ([['d']], [['d', 'a', 'b']], [[]]): + with self.subTest(tags=tags): + view = EventView(pubkey=PUBKEY, created_at=0, tags=tags, content="{}") + self.assertIsNone(view.tag_dict()) + + def test_no_tags_is_an_empty_dict_not_none(self): + view = EventView(pubkey=PUBKEY, created_at=0, tags=[], content="{}") + self.assertEqual(view.tag_dict(), {}) + + +class TestQueries(unittest.TestCase): + """The taker filter must stay byte-identical to upstream's.""" + + def test_taker_query_shape(self): + now = 1_700_000_000 + query = nc.taker_query(kind=30315, net_name="signet", + event_version=5, now_ts=now) + self.assertEqual(query, { + "kinds": [30315], + "limit": 10, + "#d": ["electrum-swapserver-5"], + "#r": ["net:signet"], + "since": now - 3600, + }) + + def test_author_query_is_tag_agnostic(self): + # query A must NOT filter on d/r: its whole job is to find our event + # even when those tags are the thing that is wrong. + query = nc.author_query(PUBKEY, kind=30315, now_ts=1_700_000_000) + self.assertEqual(query["authors"], [PUBKEY]) + self.assertNotIn("#d", query) + self.assertNotIn("#r", query) + + def test_tag_helpers(self): + self.assertEqual(nc.d_tag_for(5), "electrum-swapserver-5") + self.assertEqual(nc.r_tag_for("signet"), "net:signet") + + +class TestReport(unittest.TestCase): + + def _report(self, results, *, taker_pow_target=30): + report = nc.DiscoveryReport( + pubkey_hex=PUBKEY, npub="npub1...", net_name=NET, + event_version=VERSION, kind=30315, + taker_pow_target=taker_pow_target) + report.results = results + return report + + def test_headline_counts(self): + ok = nc.RelayResult("wss://a", CheckStatus.DISCOVERABLE, "", pow_bits=30) + bad = nc.RelayResult("wss://b", CheckStatus.NO_EVENT, "") + self.assertIn("no relays", self._report([]).headline().lower()) + self.assertIn("Discoverable on 1 of 2", self._report([ok, bad]).headline()) + self.assertIn("Not discoverable on any", self._report([bad]).headline()) + + def test_low_pow_warns_even_when_discoverable(self): + """The trap: our own low target accepts an offer stock takers reject.""" + ok = nc.RelayResult("wss://a", CheckStatus.DISCOVERABLE, "", pow_bits=12) + report = self._report([ok], taker_pow_target=12) + self.assertEqual(report.ok_count, 1) + warnings = " ".join(report.warnings()) + self.assertIn("12 bits", warnings) + self.assertIn(str(nc.DEFAULT_TAKER_POW_TARGET), warnings) + + def test_no_warning_when_pow_meets_the_default(self): + ok = nc.RelayResult("wss://a", CheckStatus.DISCOVERABLE, "", pow_bits=30) + self.assertEqual(self._report([ok], taker_pow_target=30).warnings(), []) + + def test_crowded_out_is_called_out(self): + crowded = nc.RelayResult("wss://a", CheckStatus.CROWDED_OUT, "", pow_bits=30) + warnings = " ".join(self._report([crowded]).warnings()) + self.assertIn("busy relay", warnings) + + def test_best_pow_bits_ignores_unknowns(self): + results = [nc.RelayResult("wss://a", CheckStatus.UNREACHABLE, ""), + nc.RelayResult("wss://b", CheckStatus.DISCOVERABLE, "", pow_bits=21)] + self.assertEqual(self._report(results).best_pow_bits, 21) + self.assertIsNone(self._report([results[0]]).best_pow_bits) + + +class TestFormatAge(unittest.TestCase): + + def test_units(self): + self.assertEqual(nc.format_age(5), "5s") + self.assertEqual(nc.format_age(600), "10 min") + self.assertEqual(nc.format_age(7200), "2h") + self.assertEqual(nc.format_age(3 * 86400), "3d") + self.assertEqual(nc.format_age(-1), "just now") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_qt_tab.py b/tests/test_qt_tab.py new file mode 100644 index 0000000..2b7202a --- /dev/null +++ b/tests/test_qt_tab.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Qt-layer tests for the Status/Diagnostics sub-tabs. + +These build the *real* ``SwapServerTab`` against the offscreen QPA platform and +drive ``refresh()``, so the widget wiring is actually exercised rather than +merely imported. + +PyQt6 is not installed in CI, so the whole module skips when it is missing -- +the same compromise ``test_zip_plugin_load.py`` makes for ``qt.py``. Run it +locally (or in any environment with PyQt6) to cover the GUI: + + python3 -m pytest tests/test_qt_tab.py + +What is deliberately NOT covered here: the visual layout, and the live path +from the check button through the asyncio loop to a relay (that is +``test_discovery_e2e.py``'s job; here the report is injected directly). +""" +import os +import sys +import unittest +from unittest import mock + +# --- make electrum + the plugin importable --------------------------------- +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PROJECT_ROOT = os.path.dirname(os.path.dirname(_HERE)) # /home/user/electrum_swapgui +_ELECTRUM_SRC = os.environ.get("ELECTRUM_SRC", os.path.join(_PROJECT_ROOT, "electrum")) +_PLUGINS_DIR = os.path.join(os.path.dirname(_HERE), "plugins") +for _p in (_ELECTRUM_SRC, _PLUGINS_DIR): + if _p not in sys.path: + sys.path.insert(0, _p) + +# A GUI test needs no display; ask Qt for the offscreen platform *before* it is +# imported anywhere. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +try: + from PyQt6.QtWidgets import QApplication, QTabWidget + HAVE_QT = True +except ImportError: + HAVE_QT = False + +from swapserver_gui.swapserver_gui import AnnounceState, SwapServerGuiPlugin # noqa: E402 +from swapserver_gui import nostr_check as nc # noqa: E402 +from swapserver_gui.nostr_check import CheckStatus # noqa: E402 + + +PUBKEY_33 = bytes([0x02]) + bytes(range(32)) + + +class _Config: + def __init__(self, *, pow_target=12, nonce=0): + self.SWAPSERVER_PORT = 5455 + self.NOSTR_RELAYS = "wss://a.example,wss://b.example" + self.SWAPSERVER_FEE_MILLIONTHS = 5000 + self.SWAPSERVER_POW_TARGET = pow_target + self.SWAPSERVER_ANN_POW_NONCE = nonce + self.SWAPSERVER_GUI_AUTOSTART = False + self.SWAPSERVER_GUI_POW_STATE = None + + def format_amount_and_units(self, sat): + return f"{int(sat)} sat" + + +class _SwapManager: + def __init__(self): + self.is_server = False + self.http_server = None + self.network = None + self.percentage = 0.5 + self._min_amount = 20000 + self._max_forward = 0 + self._max_reverse = 0 + self.mining_fee = 1000 + + def server_update_pairs(self): + pass + + +@unittest.skipUnless(HAVE_QT, "PyQt6 not installed") +class QtTabTests(unittest.TestCase): + + @classmethod + def setUpClass(cls): + # exactly one QApplication per process + cls.app = QApplication.instance() or QApplication([]) + + def _make_tab(self, config=None, *, keypair=mock.sentinel.default): + from swapserver_gui import qt as qt_mod + config = config or _Config() + plugin = SwapServerGuiPlugin(mock.MagicMock(), config, "swapserver_gui") + # no asyncio loop in this harness; the GUI's periodic pairs update hops + # onto it, which is covered by test_swapserver_gui.py instead. + plugin.request_pairs_update = lambda: None + sm = _SwapManager() + wallet = mock.MagicMock() + wallet.lnworker.swap_manager = sm + wallet.lnworker.nostr_keypair = ( + mock.Mock(pubkey=PUBKEY_33) if keypair is mock.sentinel.default else keypair) + wallet.lnworker.num_sats_can_send.return_value = 0 + wallet.lnworker.num_sats_can_receive.return_value = 0 + wallet.has_password.return_value = False + wallet.get_full_history.return_value = {} + plugin.bind_wallet(wallet) + window = mock.MagicMock() + window.wallet = wallet + window.config = config + tab = qt_mod.SwapServerTab(plugin, window) + self.addCleanup(tab.clean_up) + return tab, plugin, sm, window + + # ------------------------------------------------------------ structure + def test_output_pane_has_status_and_diagnostics_subtabs(self): + tab, _, _, _ = self._make_tab() + self.assertIsInstance(tab.output_tabs, QTabWidget) + titles = [tab.output_tabs.tabText(i) for i in range(tab.output_tabs.count())] + self.assertEqual(titles, ["Status", "Diagnostics"]) + + def test_settings_box_stays_outside_the_subtabs(self): + """It must be reachable from both sub-tabs, so it cannot live in one.""" + tab, _, _, _ = self._make_tab() + for i in range(tab.output_tabs.count()): + page = tab.output_tabs.widget(i) + self.assertFalse(page.isAncestorOf(tab.save_btn)) + self.assertFalse(page.isAncestorOf(tab.relays_edit)) + # ...but the diagnostics widgets do live inside a sub-tab + diagnostics = tab.output_tabs.widget(1) + self.assertTrue(diagnostics.isAncestorOf(tab.check_btn)) + status = tab.output_tabs.widget(0) + self.assertTrue(status.isAncestorOf(tab.npub_label)) + + # ------------------------------------------------------------- identity + def test_npub_is_shown_with_hex_tooltip_and_identicon(self): + tab, plugin, _, _ = self._make_tab() + expected_hex, expected_npub = plugin.nostr_identity() + self.assertEqual(tab.npub_label.text(), expected_npub) + self.assertTrue(tab.npub_label.text().startswith("npub1")) + self.assertIn(expected_hex, tab.npub_label.toolTip()) + self.assertFalse(tab.npub_icon.pixmap().isNull()) + self.assertTrue(tab.npub_copy_btn.isEnabled()) + + def test_identity_is_shown_while_the_server_is_stopped(self): + # the key is seed-derived; an operator must be able to read it before + # ever starting the server + tab, plugin, _, _ = self._make_tab() + self.assertFalse(plugin.is_running()) + self.assertTrue(tab.npub_label.text().startswith("npub1")) + + def test_copy_button_copies_the_npub(self): + tab, plugin, _, window = self._make_tab() + tab.on_copy_npub() + window.do_copy.assert_called_once() + self.assertEqual(window.do_copy.call_args[0][0], plugin.nostr_identity()[1]) + + def test_wallet_without_nostr_key_degrades_gracefully(self): + tab, _, _, _ = self._make_tab(keypair=None) + self.assertNotIn("npub", tab.npub_label.text()) + self.assertFalse(tab.npub_copy_btn.isEnabled()) + + # --------------------------------------------------------- announcement + def test_running_without_liquidity_does_not_claim_to_announce(self): + """The reported symptom: 'running' everywhere, nothing published.""" + tab, plugin, sm, _ = self._make_tab() + plugin._running = True + sm._max_forward = 0 + sm._max_reverse = 0 + tab.refresh() + self.assertIs(plugin.status()["announce_state"], AnnounceState.NO_LIQUIDITY) + self.assertIn("no liquidity", tab._out_labels["nostr"].text()) + self.assertIn("Not announcing", tab.announce_label.text()) + self.assertIn("20,000 sat", tab.announce_reason.text()) + + def test_with_liquidity_it_announces(self): + tab, plugin, sm, _ = self._make_tab() + plugin._running = True + sm._max_forward = 150000 + tab.refresh() + self.assertIn("announcing to 2 relay(s)", tab._out_labels["nostr"].text()) + self.assertIn("Announcing", tab.announce_label.text()) + + def test_locked_wallet_is_reported(self): + tab, plugin, sm, _ = self._make_tab() + plugin.wallet.has_password.return_value = True + plugin.wallet.get_unlocked_password.return_value = None + plugin._running = True + sm._max_forward = 150000 + tab.refresh() + self.assertIn("unlock", tab._out_labels["nostr"].text()) + self.assertIn("password", tab.announce_reason.text()) + + # ---------------------------------------------------------- match fields + def test_match_fields_are_rendered(self): + tab, plugin, _, _ = self._make_tab() + tab.refresh() + fields = plugin.nostr_match_fields() + self.assertEqual(tab._match_labels["net"].text(), fields["r_tag"]) + self.assertEqual(tab._match_labels["version"].text(), fields["d_tag"]) + self.assertEqual(tab._match_labels["kind"].text(), str(fields["kind"])) + + def test_low_pow_warning_appears_and_clears(self): + tab, plugin, _, _ = self._make_tab() + tab.refresh() + # nonce 0 -> 0 bits of work, far below the default taker target of 30 + self.assertFalse(tab.pow_warning.isHidden()) + self.assertIn(str(nc.DEFAULT_TAKER_POW_TARGET), tab.pow_warning.text()) + self.assertIn("0 bits", tab._match_labels["pow"].text()) + + with mock.patch.object( + sys.modules['swapserver_gui.pow'], 'pow_bits', return_value=30): + tab.refresh() + self.assertTrue(tab.pow_warning.isHidden()) + self.assertIn("30 bits", tab._match_labels["pow"].text()) + + # ----------------------------------------------------------- the report + def _report(self, results, *, taker_pow_target=30): + report = nc.DiscoveryReport( + pubkey_hex="aa" * 32, npub="npub1x", net_name="signet", + event_version=5, kind=30315, taker_pow_target=taker_pow_target) + report.results = results + return report + + def test_report_renders_one_row_per_relay(self): + tab, _, _, _ = self._make_tab() + tab.on_check_finished(self._report([ + nc.RelayResult("wss://a", CheckStatus.DISCOVERABLE, "announced 2 min ago", pow_bits=30), + nc.RelayResult("wss://b", CheckStatus.LOW_POW, "only 12 bits", pow_bits=12), + ])) + self.assertIn("Discoverable on 1 of 2", tab.check_headline.text()) + rows = [(tab.check_tree.topLevelItem(i).text(0), + tab.check_tree.topLevelItem(i).text(1), + tab.check_tree.topLevelItem(i).text(2)) + for i in range(tab.check_tree.topLevelItemCount())] + self.assertEqual(rows[0], ("wss://a", "discoverable", "announced 2 min ago")) + self.assertEqual(rows[1], ("wss://b", "low pow", "only 12 bits")) + + def test_report_warnings_toggle(self): + tab, _, _, _ = self._make_tab() + low = nc.RelayResult("wss://a", CheckStatus.DISCOVERABLE, "ok", pow_bits=12) + tab.on_check_finished(self._report([low], taker_pow_target=12)) + self.assertFalse(tab.check_warnings.isHidden()) + good = nc.RelayResult("wss://a", CheckStatus.DISCOVERABLE, "ok", pow_bits=30) + tab.on_check_finished(self._report([good], taker_pow_target=30)) + self.assertTrue(tab.check_warnings.isHidden()) + + def test_check_failure_is_surfaced_not_swallowed(self): + tab, _, _, window = self._make_tab() + tab._check_running = True + tab.check_btn.setEnabled(False) + tab.on_check_finished(RuntimeError("boom")) + self.assertTrue(tab.check_btn.isEnabled()) + window.show_error.assert_called_once() + self.assertIn("boom", window.show_error.call_args[0][0]) + + def test_result_landing_after_teardown_is_dropped(self): + """The check outlives the refresh timer, so it must not emit into a + tab that close_wallet already tore down.""" + import concurrent.futures + tab, plugin, _, _ = self._make_tab() + fut = concurrent.futures.Future() + plugin.check_discoverability = lambda: fut + plugin.cancel_discovery_check = lambda: None # simulate "too late to cancel" + tab.on_check_discoverability() + self.assertTrue(tab._check_running) + self.assertIn("Querying", tab.check_headline.text()) + + # spy on the signal itself: the done-callback's only job is to emit it + seen = [] + tab.checkFinished.connect(seen.append) + + tab.clean_up() + fut.set_result(self._report([])) # fires the done-callback + self.app.processEvents() + self.assertEqual(seen, []) + # and nothing repainted the torn-down tab + self.assertIn("Querying", tab.check_headline.text()) + + def test_button_reports_missing_prerequisites(self): + config = _Config() + config.NOSTR_RELAYS = "" + tab, _, _, window = self._make_tab(config) + tab.on_check_discoverability() + window.show_error.assert_called_once() + self.assertIn("relay", window.show_error.call_args[0][0]) + self.assertTrue(tab.check_btn.isEnabled()) # not left stuck on "Checking…" + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_zip_plugin_load.py b/tests/test_zip_plugin_load.py index 9031f28..91ab739 100644 --- a/tests/test_zip_plugin_load.py +++ b/tests/test_zip_plugin_load.py @@ -180,7 +180,7 @@ def test_package_name_differs_from_sys_modules_key(self) -> None: def test_non_gui_submodules_import_from_zip(self) -> None: """The regression test: this is the import that crashed the install.""" r = self.run_child(self._loader(""" - for sub in ('pow', 'swapserver_gui'): + for sub in ('pow', 'nostr_check', 'swapserver_gui'): full = BASE + '.' + sub spec = importlib.util.find_spec(full) assert spec is not None, full @@ -189,6 +189,8 @@ def test_non_gui_submodules_import_from_zip(self) -> None: # the module handle must be the *same* module object, not a re-import assert core.swap_pow is sys.modules[BASE + '.pow'], core.swap_pow assert core.swap_pow.pow_bits(bytes(32), 1) >= 0 + assert core.nostr_check is sys.modules[BASE + '.nostr_check'], core.nostr_check + assert core.nostr_check.d_tag_for(5) == 'electrum-swapserver-5' print('OK') """)) self.assertIn("OK", r.stdout, r.stderr) @@ -207,6 +209,7 @@ def test_qt_module_is_resolvable(self) -> None: else: mod = exec_module_from_spec(spec, full) assert mod.swap_pow is sys.modules[BASE + '.pow'] + assert mod.nostr_check is sys.modules[BASE + '.nostr_check'] assert issubclass(mod.Plugin, sys.modules[BASE + '.swapserver_gui'].SwapServerGuiPlugin) print('OK (executed)') """))