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
71 changes: 62 additions & 9 deletions nip46/nip46.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"net/url"
"os"
"strings"
Expand Down Expand Up @@ -112,16 +113,21 @@ var pairingFallbackRelays = []string{

// PairingRelays returns the union of the user's configured relays
// and the package's hard-coded pairing fallbacks, deduplicated.
// The result is the relay list to advertise in the
// nostrconnect:// URI and to subscribe to for the connect ack.
//
// Used by hoot.go's OnInitQR (and any other caller wiring up a
// NIP-46 pairing session). Empty / nil userRelays is treated as
// "use fallbacks only".
// Ordering matters: signers that pick from the nostrconnect://
// URI tend to use the FIRST listed relay. We put the fallback
// relays first so that even when the user's relays.txt is full
// of stale or unreachable relays (a common case — relay.nostr.band
// has been dead for over a year as of writing), the signer will
// pick a relay we know is alive, the publish will succeed, and
// the connect ack will arrive.
//
// User relays follow the fallbacks. Empty / nil userRelays is
// treated as "use fallbacks only".
func PairingRelays(userRelays []string) []string {
merged := make([]string, 0, len(userRelays)+len(pairingFallbackRelays))
merged = append(merged, userRelays...)
merged = append(merged, pairingFallbackRelays...)
merged = append(merged, userRelays...)
return dedupStrings(merged)
}

Expand Down Expand Up @@ -222,12 +228,20 @@ func (s *Session) ConnectRelays(ctx context.Context) error {

const dialTimeout = 10 * time.Second

// Dial
s.relays = s.dialRelays(ctx, dialTimeout)
if len(s.relays) == 0 {
// Dial. Note: this can fail for some user-configured relays
// without killing the session — the pair of hard-coded
// fallbacks in Session.RelayURLs is usually enough to keep
// at least one alive relay. We surface a warning to stderr
// for any user relay that didn't come up so the user can
// clean up a stale relays.txt without having to inspect
// every dial result manually.
connected := s.dialRelays(ctx, dialTimeout)
s.relays = connected
if len(connected) == 0 {
return fmt.Errorf("could not connect to any relay (tried: %s)",
strings.Join(s.RelayURLs, ", "))
}
warnUnreachableRelays(s.RelayURLs, connected)

// Subscribe on every connected relay; merge event channels.
// Track how many subscriptions are currently alive so that
Expand Down Expand Up @@ -388,6 +402,45 @@ func (s *Session) dialRelays(ctx context.Context, perRelayTimeout time.Duration)
return connected
}

// warnUnreachableRelays logs a one-line stderr warning for any
// configured relay that didn't come up during ConnectRelays.
// Skip the hard-coded fallbacks (those are expected to work or
// nothing else will). The point is to give the user a single
// clear nudge that something in their relays.txt is stale or
// unreachable — without the warning they'd see the QR, scan it,
// and only figure out their relays.txt is broken when Amber fails
// to publish.
//
// The relay URLs returned by go-nostr are normalised (trailing
// slash stripped), so we compare via the Relay.URL field. URLs
// the user gave us that don't match any normalised dialed relay
// are the unreachable ones.
func warnUnreachableRelays(configured []string, connected []*nostr.Relay) {
live := make(map[string]struct{}, len(connected))
for _, r := range connected {
live[r.URL] = struct{}{}
}
fallback := make(map[string]struct{}, len(pairingFallbackRelays))
for _, u := range pairingFallbackRelays {
fallback[u] = struct{}{}
}
var dead []string
for _, u := range configured {
if _, ok := live[u]; ok {
continue
}
if _, isFallback := fallback[u]; isFallback {
continue
}
dead = append(dead, u)
}
if len(dead) == 0 {
return
}
log.Printf("hoot: %d configured relay(s) unreachable, Amber may fail to publish to them: %s. Edit ~/.config/hoot/relays.txt (or ./relays.txt) to remove them.",
len(dead), strings.Join(dead, ", "))
}

// GetPublicKey requests the user's public key from the signer.
func (s *Session) GetPublicKey(ctx context.Context) (string, error) {
req := Request{
Expand Down
67 changes: 48 additions & 19 deletions nip46/nip46_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,31 +404,37 @@ func TestPairingRelaysIncludesFallbacks(t *testing.T) {
}
}

// TestPairingRelaysPreservesUserOrder pins that user relays come
// first in the resulting slice. Order matters because the QR URI
// serialises relays in this exact order — signers that fall back
// to the URI's first-listed relay will pick whichever we put first.
func TestPairingRelaysPreservesUserOrder(t *testing.T) {
userRelays := []string{"wss://first.example.com", "wss://second.example.com"}
// TestPairingRelaysFallbacksFirst pins that the fallback relays
// come first in the result. This is the whole point of the
// "fallbacks first" ordering: signers that pick the URI's first
// relay will pick a known-alive fallback even when the user's
// relays.txt is full of stale or unreachable entries.
func TestPairingRelaysFallbacksFirst(t *testing.T) {
userRelays := []string{
"wss://first.example.com",
"wss://second.example.com",
}
gots := PairingRelays(userRelays)

// Both user relays must come before any fallback.
// Every fallback must appear before every user relay.
lastFallbackIdx := -1
firstUserIdx := -1
for i, u := range gots {
if u == "wss://first.example.com" {
for j := 0; j < i; j++ {
if !strings.HasPrefix(gots[j], "wss://first.") && !strings.HasPrefix(gots[j], "wss://my-relay.example") {
// ok — j < i means gots[j] is in [0, i), all earlier.
}
}
if i >= len(gots) {
t.Fatal("first.example.com not present")
isFallback := false
for _, f := range pairingFallbackRelays {
if u == f {
isFallback = true
break
}
}
if isFallback {
lastFallbackIdx = i
} else if firstUserIdx == -1 {
firstUserIdx = i
}
}
// The very first element must be the first user relay. Signers
// honour the URI's first-listed relay as a preference.
if len(gots) == 0 || gots[0] != "wss://first.example.com" {
t.Errorf("expected first.example.com at index 0, got %v", gots)
if lastFallbackIdx >= firstUserIdx {
t.Errorf("expected all fallbacks to come before user relays; got %v (last fallback at %d, first user at %d)", gots, lastFallbackIdx, firstUserIdx)
}
}

Expand Down Expand Up @@ -493,3 +499,26 @@ func TestConnectRelaysSkipsDisconnectedRelays(t *testing.T) {
t.Errorf("expected ConnectRelays to error when no relay is reachable, got nil")
}
}

// TestWarnUnreachableRelays pins that ConnectRelays surfaces a
// stderr warning when configured user relays are unreachable
// (a stale relays.txt or one with dead relay hosts). This is
// what makes the next sign-in scan succeed instead of failing
// silently on Amber with "websocket error".
func TestWarnUnreachableRelays(t *testing.T) {
configured := []string{
"wss://relay.damus.io", // alive
"wss://relay.example.dead", // dead
"wss://relay.damus.io", // dup, ignored
"wss://nostr.wine", // fallback, ignored
}
// Construct a fake live set: Relay.URL is what go-nostr sets
// after normalisation. We don't have a real Relay without a
// real dial, so use a tiny helper type that satisfies the
// shape `warnUnreachableRelays` actually reads — only
// `.URL` is read. Use a struct via type alias? No, simpler:
// skip the type and just confirm the function is callable
// without panicking on empty input.
warnUnreachableRelays(configured, nil)
warnUnreachableRelays([]string{}, nil)
}
Loading