Skip to content

fix(nip46): dial all configured relays instead of only the first - #69

Merged
oth-body merged 1 commit into
masterfrom
fix/nip46-multiple-relays
Sep 14, 2026
Merged

oth-body merged 1 commit into
masterfrom
fix/nip46-multiple-relays

Conversation

@oth-body

Copy link
Copy Markdown
Owner

Summary

When scanning the QR login (NIP-46 / Amber), hoot always picked relays[0] — only the first configured relay — and dialed just that one. If that single relay was slow or unreachable at scan time, the websocket connection failed and the user got an opaque error with no fallback to the other configured relays.

NIP-46 explicitly allows the nostrconnect:// URI to advertise multiple relays and lets the remote signer publish its response on any one of them. So the right fix is to advertise all of them AND listen on all of them, accepting the connection on whichever fires first.

Root cause

hoot.go's OnInitQR callback did relays := getRelayList(); relayURL := relays[0]; uri, session, err := nip46.GenerateConnectURI(relayURL, "Hoot"). The QR encoded a single relay= query parameter and nip46.Session dialed that one relay only.

When that one relay was down (relay.nostr.band times out from many networks — I verified), the user got a websocket error and login was broken even though the user's other configured relays were perfectly healthy.

Fix

nip46/nip46.go — Session now supports multiple relays throughout:

  • Session.RelayURL (single string) replaced with Session.RelayURLs ([]string).
  • GenerateConnectURI(relayURLs []string, appName) deduplicates the input and emits one relay= query parameter per URL via url.Values.Add. Falls back to wss://relay.damus.io if the slice is empty so pre-existing callers and tests still produce a valid URI.
  • New dialRelays: parallel nostr.RelayConnect on every URL with a per-dial timeout; returns the subset that came up rather than failing the whole session on one bad relay.
  • WaitForConnection subscribes for kind 24133 events on every connected relay and merges their event channels. First event wins.
  • sendRequest publishes the request event to all connected relays (so it lands on whichever the signer is listening on), and subscribes for the response across all of them too.
  • Close now closes every relay connection.

hoot.go — OnInitQR passes getRelayList() (the full list — falls back to the 3-relay defaultRelays when there's no relays.txt) to GenerateConnectURI instead of relays[0].

Tests

New nip46/nip46_test.go, 4 tests:

  • TestGenerateConnectURIMultipleRelays — three relays in, three relay= params out, full list carried in session.RelayURLs, URI host = session.ClientPublicKey.
  • TestGenerateConnectURIDedupsRelays — duplicated input relays are deduplicated in the URI output (defensive — defaultRelays already dedupes, but the helper should be robust on its own).
  • TestGenerateConnectURISingleRelayFallback — empty list still produces a valid URI with the damus default.
  • TestDialRelaysContinuesOnIndividualFailure — both invalid URLs return empty cleanly without panicking. This is the exact failure mode that produced the user's websocket error.
=== RUN   TestGenerateConnectURIMultipleRelays
--- PASS
=== RUN   TestGenerateConnectURIDedupsRelays
--- PASS
=== RUN   TestGenerateConnectURISingleRelayFallback
--- PASS
=== RUN   TestDialRelaysContinuesOnIndividualFailure
--- PASS
ok  hoot/nip46

Sample generated URI

go run ./cmd/showuri/ confirms the URI now carries every configured relay:

nostrconnect://ecbb4e6b927ebb4f96e57922b4a6dbd6c9166dc279b5930ab54394776ff0a6ce
    ?metadata=%7B%22name%22%3A%22Hoot%22%7D
    &relay=wss%3A%2F%2Frelay.damus.io
    &relay=wss%3A%2F%2Frelay.nostr.band
    &relay=wss%3A%2F%2Fnostr.wine

Amber (or any other NIP-46 signer) now sees all three and picks whichever it can reach; hoot listens on all three and accepts the response from any of them.

When scanning the QR login with a remote signer (e.g. Amber), hoot
chose only relays[0] from the relay list and dialed just that
one. If the chosen relay was down or unreachable at scan-time —
which is common; relay.nostr.band frequently times out from many
networks, for example — the websocket connect failed and the
user saw a websocket error with no fallback to the other
configured relays.

NIP-46 explicitly allows the URI to advertise multiple relays,
and remote signers pick whichever they can reach and publish
their response there. So the right fix is to advertise all of
them AND listen on all of them, accepting the connection on
whichever fires first.

nip46/nip46.go:
- Session.RelayURL (string) replaced with Session.RelayURLs
  ([]string).
- GenerateConnectURI takes a slice of relay URLs, deduplicates,
  and emits one 'relay=' query parameter per URL via url.Values.
  Falls back to wss://relay.damus.io if the slice is empty so
  pre-existing callers and tests still work.
- Added dialRelays: parallel RelayConnect on every URL with a
  per-dial timeout; returns the subset that came up rather than
  failing the whole session on one bad relay.
- WaitForConnection subscribes for kind 24133 events on every
  connected relay and merges their event channels. First event
  wins.
- sendRequest publishes the request event to ALL connected
  relays (so it lands on whichever the signer is listening on),
  and subscribes for the response across all of them too.
- Close now closes every relay connection.

hoot.go:
- OnInitQR passes getRelayList() (the full list — falls back to
  the 3-relay defaultRelays when the user has no relays.txt)
  to GenerateConnectURI instead of relays[0].

Adds nip46/nip46_test.go with 4 tests:
- TestGenerateConnectURIMultipleRelays — three relays in,
  three 'relay=' params out, full list carried in
  session.RelayURLs, host = session.ClientPublicKey.
- TestGenerateConnectURIDedupsRelays — duplicated input
  relays are deduplicated in the URI output.
- TestGenerateConnectURISingleRelayFallback — empty list
  still produces a valid URI (with the damus default).
- TestDialRelaysContinuesOnIndividualFailure — both invalid
  URLs return empty cleanly without panicking; this is the
  exact failure mode that produced the user's websocket
  error before.

Verified locally: go test -count=1 ./... passes including the
new nip46 tests; go build ./... clean. Programmatic check:
GenerateConnectURI now emits the URI with all three default
relays advertised.

Sample generated URI:
  nostrconnect://ecbb...?metadata=...&relay=wss://relay.damus.io&relay=wss://relay.nostr.band&relay=wss://nostr.wine
@oth-body
oth-body merged commit 04b2c75 into master Sep 14, 2026
7 checks passed
@oth-body
oth-body deleted the fix/nip46-multiple-relays branch September 14, 2026 22:21
oth-body pushed a commit that referenced this pull request Sep 15, 2026
Hoot's NIP-46 sign-in flow uses github.com/nbd-wtf/go-nostr's
nostr.RelayConnect, which delegates to net/http and therefore
honors HTTPS_PROXY / HTTP_PROXY / ALL_PROXY / SOCKS_PROXY env
vars inherited from the parent shell. If the user runs hoot
inside torsocks, a launcher that exports HTTPS_PROXY, or a shell
that already has Tor configured for monerod / curl, the wss://
dials to relay.damus.io / relay.nostr.band / nostr.wine (or
whatever the user picked) get routed through 127.0.0.1:9050.
When nothing is listening there — which is the typical case on
a workstation without an active Tor — every dial fails with
'dial tcp 127.0.0.1:9050: connect: connection refused', which
bubbles up as 'failed to connect to port 9050' in the TUI and
gives the user no actionable signal.

Fix it two ways:

  - nip46.ConnectRelays now refuses to dial at all when any
    *_PROXY env var is set, returning a clear error that names
    the offending env var and the proxy host:port. The user can
    either unset it (or run hoot outside torsocks). This matches
    what every other Nostr TUI does — nak, lume, etc. — none
    of them proxy wss:// connections by default.

  - Up-front validation of every configured relay URL (rejecting
    blanks and non-ws:// / non-wss:// schemes) so a misconfigured
    relays.txt surfaces a clear error instead of a confusing
    websocket one.

Anchored against master after PR #69 / #70 / #72 restructured
the NIP-46 dial path (RelayURL string -> RelayURLs []string,
WaitForConnection -> ConnectRelays + CheckConnection). Tests
updated accordingly: ConnectRelays is the new dial entry point,
and validateRelayURLs is exercised with single-bad-URL and
mixed-good-and-bad inputs.

Tests in nip46/nip46_test.go pin:
  - the proxy env var detection order (HTTPS_PROXY beats
    HTTP_PROXY beats ALL_PROXY; socks fallbacks come last)
  - that malformed or empty proxy values fall through to the
    next valid var (matching Go's stdlib httpproxy behaviour)
  - that ConnectRelays surfaces the env var name and port on
    the user-visible error path (this is the bug's regression
    guard)
  - that blank and non-wss relay URLs are rejected up front
    instead of producing a confusing websocket error later
  - that Session.Close is idempotent when no dial was attempted
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant