Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ From the tab you can:
- **Edit every server setting**: HTTP port, swap fee, nostr relays, and the
nostr announcement proof-of-work target.
- **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.
- **Status** — your nostr pubkey in **both** encodings (npub and hex, each
copyable, 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)
Expand All @@ -40,6 +41,24 @@ 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`).

## npub or hex?

The tab shows the server's key in both forms because the two places you meet it
disagree, and they look nothing alike:

| Where | Encoding |
| --- | --- |
| Swap Server tab | npub **and** hex |
| Electrum's *Choose Swap Provider* dialog, **Pubkey** column | hex only |
| `SWAPSERVER_NPUB` (what a taker pins) | npub |

`swap_dialog.py` renders `SwapOffer.server_pubkey` — the raw hex `event.pubkey`
— and keeps the npub only as hidden item data. Same key, two encodings.

The quickest visual check that a provider entry is your server is the
**identicon**: both sides derive it from the *hex* via `pubkey_to_q_icon`, so
the coloured square matches.

## Why can nobody see my swap server?

A swap server can be running, connected, and grinding a perfectly valid proof of
Expand Down
64 changes: 54 additions & 10 deletions plugins/swapserver_gui/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def __init__(self, plugin: 'Plugin', window: 'ElectrumWindow') -> None:
self.config = plugin.config
self.wallet = window.wallet
self._npub: Optional[str] = None
self._pubkey_hex: 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)
Expand Down Expand Up @@ -172,24 +173,58 @@ def _build_status_tab(self) -> QWidget:
# ---- nostr identity ------------------------------------------------
# This is what a taker pins as SWAPSERVER_NPUB, so it belongs with the
# operator-facing status rather than with the diagnostics.
#
# BOTH encodings are shown on purpose. They are the same key, but they
# look nothing alike, and the two places an operator sees it disagree:
# Electrum's "Choose Swap Provider" dialog renders the *hex*
# (electrum/gui/qt/swap_dialog.py: labels[Columns.PUBKEY] =
# x.server_pubkey) and keeps the npub only as hidden item data, while
# SWAPSERVER_NPUB -- what a taker actually pins -- is the *npub*.
# Showing one alone reads as "the GUI is displaying the wrong key".
ident_box = QGroupBox(_("Nostr identity"))
ident_layout = QHBoxLayout(ident_box)
ident_grid = QGridLayout(ident_box)
ident_grid.setColumnStretch(2, 1)

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)
_("Identicon takers see next to this server in their provider list.\n"
"It is derived from the key, so it is the quickest way to confirm "
"a provider entry is this server."))
ident_grid.addWidget(self.npub_icon, 0, 0)

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)
_("The nostr public key this swap server announces under, in bech32 "
"(NIP-19) form.\nThis is the value a taker stores as their chosen "
"swap provider."))
ident_grid.addWidget(QLabel(_("npub:")), 0, 1)
ident_grid.addWidget(self.npub_label, 0, 2)
self.npub_copy_btn = QPushButton(_("Copy"))
self.npub_copy_btn.clicked.connect(self.on_copy_npub)
ident_layout.addWidget(self.npub_copy_btn)
ident_grid.addWidget(self.npub_copy_btn, 0, 3)

self.pubkey_hex_label = QLabel("—")
self.pubkey_hex_label.setWordWrap(True)
self.pubkey_hex_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse)
self.pubkey_hex_label.setToolTip(
_("The same key in hex. This is the form Electrum's 'Choose Swap "
"Provider' dialog lists,\nso compare against this one when "
"checking that a taker can see this server."))
ident_grid.addWidget(QLabel(_("hex:")), 1, 1)
ident_grid.addWidget(self.pubkey_hex_label, 1, 2)
self.pubkey_hex_copy_btn = QPushButton(_("Copy"))
self.pubkey_hex_copy_btn.clicked.connect(self.on_copy_pubkey_hex)
ident_grid.addWidget(self.pubkey_hex_copy_btn, 1, 3)

hint = QLabel(_("Same key, two encodings. Takers list the hex form."))
hint.setEnabled(False) # renders dimmed, like secondary text
ident_grid.addWidget(hint, 2, 2, 1, 2)

outer.addWidget(ident_box)

grid = QGridLayout()
Expand Down Expand Up @@ -403,7 +438,12 @@ def on_toggle(self) -> None:
def on_copy_npub(self) -> None:
if not self._npub:
return
self.window.do_copy(self._npub, title=_("Nostr pubkey"))
self.window.do_copy(self._npub, title=_("Nostr pubkey (npub)"))

def on_copy_pubkey_hex(self) -> None:
if not self._pubkey_hex:
return
self.window.do_copy(self._pubkey_hex, title=_("Nostr pubkey (hex)"))

# ---------------------------------------------------------- diagnostics
def on_check_discoverability(self) -> None:
Expand Down Expand Up @@ -569,14 +609,18 @@ def _refresh_identity(self, st: Dict[str, Any]) -> None:
npub = st["nostr_npub"]
pubkey = st["nostr_pubkey"]
self._npub = npub
self._pubkey_hex = pubkey
self.npub_copy_btn.setEnabled(bool(npub))
self.pubkey_hex_copy_btn.setEnabled(bool(pubkey))
if not npub or not pubkey:
self.npub_label.setText(_("no nostr key (wallet has no lightning)"))
self.pubkey_hex_label.setText("—")
self.npub_icon.clear()
self.npub_label.setToolTip("")
return
self.npub_label.setText(npub)
self.npub_label.setToolTip(_("hex: {}").format(pubkey))
self.pubkey_hex_label.setText(pubkey)
# Both sides feed the *hex* into pubkey_to_q_icon, so this square is
# identical to the one beside this server in a taker's provider list.
self.npub_icon.setPixmap(pubkey_to_q_icon(pubkey).pixmap(16, 16))

def _refresh_diagnostics(self, st: Dict[str, Any]) -> None:
Expand Down
45 changes: 42 additions & 3 deletions tests/test_qt_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def __init__(self):
self._max_forward = 0
self._max_reverse = 0
self.mining_fee = 1000
self._swaps = {} # read by get_swap_history via the history refresh

def server_update_pairs(self):
pass
Expand Down Expand Up @@ -129,14 +130,34 @@ def test_settings_box_stays_outside_the_subtabs(self):
self.assertTrue(status.isAncestorOf(tab.npub_label))

# ------------------------------------------------------------- identity
def test_npub_is_shown_with_hex_tooltip_and_identicon(self):
def test_both_encodings_are_shown_with_the_identicon(self):
"""npub AND hex: the provider list shows hex, SWAPSERVER_NPUB is npub,
and showing only one reads as the GUI displaying the wrong key."""
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.assertEqual(tab.pubkey_hex_label.text(), expected_hex)
self.assertFalse(tab.npub_icon.pixmap().isNull())
self.assertTrue(tab.npub_copy_btn.isEnabled())
self.assertTrue(tab.pubkey_hex_copy_btn.isEnabled())

def test_the_two_encodings_are_the_same_key(self):
"""Guards the actual confusion: they must never drift apart."""
from electrum_aionostr.util import from_nip19
tab, _, _, _ = self._make_tab()
shown_npub = tab.npub_label.text()
shown_hex = tab.pubkey_hex_label.text()
self.assertEqual(from_nip19(shown_npub)['object'].hex(), shown_hex)
# and the hex is exactly what SwapOffer.server_pubkey would carry:
# NostrTransport.nostr_pubkey == keypair.pubkey.hex()[2:]
self.assertEqual(shown_hex, PUBKEY_33.hex()[2:])

def test_hex_copy_button_copies_the_hex(self):
tab, plugin, _, window = self._make_tab()
tab.on_copy_pubkey_hex()
window.do_copy.assert_called_once()
self.assertEqual(window.do_copy.call_args[0][0], plugin.nostr_identity()[0])

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
Expand All @@ -153,8 +174,26 @@ def test_copy_button_copies_the_npub(self):

def test_wallet_without_nostr_key_degrades_gracefully(self):
tab, _, _, _ = self._make_tab(keypair=None)
self.assertNotIn("npub", tab.npub_label.text())
self.assertNotIn("npub1", tab.npub_label.text())
self.assertEqual(tab.pubkey_hex_label.text(), "—")
self.assertFalse(tab.npub_copy_btn.isEnabled())
self.assertFalse(tab.pubkey_hex_copy_btn.isEnabled())
# copying must be a no-op rather than pasting an empty string
tab.on_copy_npub()
tab.on_copy_pubkey_hex()

def test_identicon_uses_the_hex_like_the_provider_list_does(self):
"""swap_dialog.py feeds x.server_pubkey (hex) to pubkey_to_q_icon; if we
fed it the npub the colours would differ and the visual check would lie."""
from electrum.gui.qt.util import pubkey_to_q_icon
tab, plugin, _, _ = self._make_tab()
pubkey_hex, npub = plugin.nostr_identity()
expected = pubkey_to_q_icon(pubkey_hex).pixmap(16, 16).toImage()
self.assertEqual(tab.npub_icon.pixmap().toImage(), expected)
# upstream's colour helper only accepts the 64-char hex, so feeding it
# the npub is not merely a different colour -- it is a hard error
with self.assertRaises(AssertionError):
pubkey_to_q_icon(npub)

# --------------------------------------------------------- announcement
def test_running_without_liquidity_does_not_claim_to_announce(self):
Expand Down
Loading