diff --git a/README.md b/README.md index b7c11fe..837b4d8 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/plugins/swapserver_gui/qt.py b/plugins/swapserver_gui/qt.py index 4337134..8526879 100644 --- a/plugins/swapserver_gui/qt.py +++ b/plugins/swapserver_gui/qt.py @@ -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) @@ -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() @@ -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: @@ -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: diff --git a/tests/test_qt_tab.py b/tests/test_qt_tab.py index 2b7202a..32566f8 100644 --- a/tests/test_qt_tab.py +++ b/tests/test_qt_tab.py @@ -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 @@ -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 @@ -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):