From 0baec923887c34e9e87f482909267721c0f9f384 Mon Sep 17 00:00:00 2001 From: BareBits Date: Tue, 28 Jul 2026 14:22:00 -0700 Subject: [PATCH] Show the nostr pubkey, and explain why a server is undiscoverable Adds a Status/Diagnostics sub-tab pair to the Swap Server tab's output pane (the Settings box stays outside them, reachable from both). Status now shows the server's nostr identity: the npub, the hex pubkey in a tooltip, the identicon takers see in their provider list, and a Copy button. The key is seed-derived (lnworker.nostr_keypair), so it renders whether or not the server is running, and it is exactly the string a taker pins as SWAPSERVER_NPUB. Diagnostics addresses the second half: 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. NostrTransport._get_pairs_loop drops a candidate offer with at most a debug log line, and publish_offer returns early -- with one warning -- when there is no liquidity to advertise. * The tab no longer claims to be "announcing" when the announcement path is blocked. announce_state() classifies it: no relays, stopped, waiting for the wallet password (run_nostr_server blocks on this), waiting for the proof of work, or no liquidity. The liquidity gate mirrors publish_offer's exactly, including that max_forward is capped by BOTH lightning inbound and the on-chain balance and that both maxima are rounded down to two leading digits, so the 20,000 sat minimum is easy to miss. * The three values a taker's filter must match byte-for-byte are shown verbatim: net: (signet and mutinynet are distinct), the electrum-swapserver- d-tag, and the event kind. * SWAPSERVER_POW_TARGET means "bits to grind" on the server but "bits I demand" on the taker, so lowering it to save CPU makes the server invisible to every taker still on Electrum's default of 30. The tab warns whenever the announcement carries fewer bits than that default. * "Check discoverability" queries each configured relay the way another wallet would, with a throwaway key, and names the first rule that rejects us -- per relay. It runs two queries: one by author (is our announcement stored here at all?) and one with the taker's verbatim filter (does it survive it?). That split is what separates a wrong tag from a missing event from being crowded out of a busy relay's 10-result page. No change to upstream's announcement cadence: the NIP-40 expiry and the 10-minute republish interval are left alone, and a stale offer is instead made visible through the check. Tests: * test_nostr_check.py - the rule engine, pinned to the order of checks _get_pairs_loop performs so the field we blame is the one that rejects us first upstream. * test_diagnostics.py - the state machine, the liquidity gate boundary, and the npub round-tripping back to the x-only pubkey. * test_discovery_e2e.py + fake_relay.py - the check driven against a real NIP-01 relay hosted in-process over aiohttp WebSocket. Offers are published with electrum_aionostr exactly as the server does and signatures are verified on receipt; low PoW, wrong net, wrong version, crowded out, unreachable and rejected-write are each asserted to be named correctly. * test_qt_tab.py - builds the real SwapServerTab on Qt's offscreen platform and drives refresh(). Skips without PyQt6; CI now installs it so these run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013zETFC6MhnsCuxvQDGxqV5 --- .github/workflows/build-plugin.yml | 8 + README.md | 77 +++- contrib/make_zip.sh | 1 + plugins/swapserver_gui/nostr_check.py | 537 +++++++++++++++++++++++ plugins/swapserver_gui/qt.py | 297 ++++++++++++- plugins/swapserver_gui/swapserver_gui.py | 254 ++++++++++- tests/fake_relay.py | 134 ++++++ tests/test_diagnostics.py | 275 ++++++++++++ tests/test_discovery_e2e.py | 281 ++++++++++++ tests/test_nostr_check.py | 289 ++++++++++++ tests/test_qt_tab.py | 287 ++++++++++++ tests/test_zip_plugin_load.py | 5 +- 12 files changed, 2422 insertions(+), 23 deletions(-) create mode 100644 plugins/swapserver_gui/nostr_check.py create mode 100644 tests/fake_relay.py create mode 100644 tests/test_diagnostics.py create mode 100644 tests/test_discovery_e2e.py create mode 100644 tests/test_nostr_check.py create mode 100644 tests/test_qt_tab.py 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)') """))