Show the nostr pubkey, and explain why a swap server is undiscoverable - #5
Merged
nothing-stops-this-train merged 1 commit intoJul 28, 2026
Conversation
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:<NET_NAME> (signet and mutinynet are distinct), the
electrum-swapserver-<version> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zETFC6MhnsCuxvQDGxqV5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Splits the Swap Server tab's output pane into Status and Diagnostics
sub-tabs. The Settings box stays outside them, reachable from both.
Status: the nostr identity
Shows the server's npub, the identicon takers see in their provider list, the
hex pubkey in a tooltip, 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: why nobody can see the server
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_loopdrops a candidate offer with at most adebuglog line, andpublish_offerreturns early (one warning) when there isno liquidity to advertise.
The tab no longer claims to be "announcing" when the path is blocked.
announce_state()classifies it: no relays / stopped / waiting for the walletpassword (
run_nostr_serverblocks on this) / waiting for proof of work / noliquidity. The liquidity gate mirrors
publish_offer's exactly, includingthat
max_forwardis capped by both lightning inbound and the on-chainbalance, 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:<NET_NAME>(signetandmutinynetare distinct upstream),the
electrum-swapserver-<version>d-tag, and the event kind.Proof-of-work asymmetry is called out.
SWAPSERVER_POW_TARGETmeans"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.
"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. A stale offer is instead made
visible through the check.
Tests
160 tests, green with and without PyQt6.
test_nostr_check.py— the rule engine, pinned to the order of checks_get_pairs_loopperforms, so the field we blame is the one that rejects usfirst upstream.
test_diagnostics.py— the announcement state machine, the liquidity gateboundary, and the npub round-tripping back to the x-only pubkey.
test_discovery_e2e.py+fake_relay.py— the check driven against a realNIP-01 relay hosted in-process over aiohttp WebSocket. Offers are published
with
electrum_aionostrexactly as the server does and signatures are verifiedon receipt; low PoW, wrong net, wrong version, crowded out, unreachable and
rejected-write are each asserted to be named correctly, not merely detected.
test_qt_tab.py— builds the realSwapServerTabon Qt's offscreen platformand drives
refresh(). Skips without PyQt6; CI now installs it so these runthere (this is why the workflow file changes).
Caveat
This makes the failure legible, it does not fix it. If the cause is missing
liquidity or a network/version mismatch, the check will name it, but resolving
it is a funding or config action on the operator's side.
🤖 Generated with Claude Code
https://claude.ai/code/session_013zETFC6MhnsCuxvQDGxqV5