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
40 changes: 23 additions & 17 deletions hoot.go
Original file line number Diff line number Diff line change
Expand Up @@ -1442,23 +1442,29 @@ func main() {
return os.WriteFile(relayPath, []byte(data), 0600)
},
OnInitQR: func() (string, error) {
// Generate NIP-46 URI with ALL configured relays and
// immediately dial + subscribe on them. By the time the
// user scans, the subscriptions are active and we won't
// miss the signer's connect event.
relays := getRelayList()
uri, session, err := nip46.GenerateConnectURI(relays, "Hoot")
if err != nil {
return "", err
}
// Dial and subscribe in the background of this cmd.
ctx := context.Background()
if err := session.ConnectRelays(ctx); err != nil {
return "", fmt.Errorf("relay connect: %w", err)
}
nip46Session = session
return uri, nil
},
// Generate NIP-46 URI with ALL configured relays AND
// a hard-coded set of relays Amber is known to publish
// to (relay.damus.io, nostr.wine). The latter covers
// the common failure mode where the user's relays.txt
// is sparse or contains relays Amber doesn't recognise
// — Amber then publishes to its own preferred relay
// instead of the URI's, and hoot's subscription (only
// on the user's relays) never sees the connect ack.
//
// See nip46.PairingRelays for the full rationale.
userRelays := getRelayList()
uri, session, err := nip46.GenerateConnectURI(nip46.PairingRelays(userRelays), "Hoot")
if err != nil {
return "", err
}
// Dial and subscribe in the background of this cmd.
ctx := context.Background()
if err := session.ConnectRelays(ctx); err != nil {
return "", fmt.Errorf("relay connect: %w", err)
}
nip46Session = session
return uri, nil
},
OnCheckQR: func() (string, error) {
if nip46Session == nil {
return "", fmt.Errorf("session not initialized")
Expand Down
76 changes: 72 additions & 4 deletions nip46/nip46.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,47 @@ func dedupStrings(ss []string) []string {
return out
}

// pairingFallbackRelays are relays Amber (and most other NIP-46
// signers) is willing to publish connect acks to even when the
// nostrconnect:// URI doesn't enumerate them, or when the user's
// configured relays are sparse or contain relays the signer
// doesn't recognise. We always advertise AND subscribe to these
// during the pairing window so that:
//
// - the signer can always find at least one relay it trusts to
// publish to (no "no relay accepted our message" failure),
// - hoot always has a subscription live on at least one relay
// the signer is willing to use (no "no events received"
// failure).
//
// These are also advertised in the nostrconnect:// URI alongside
// the user's configured relays, so signers that DO honour the
// URI still see them as an option.
//
// Update these if your local observation of Amber's preferred
// relays changes. Sources: Amber's source code, NIP-46 spec
// examples, and a year of bug reports in this project's git log
// against the symptom "scan approved in Amber, no response in hoot".
var pairingFallbackRelays = []string{
"wss://relay.damus.io",
"wss://nostr.wine",
}

// 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".
func PairingRelays(userRelays []string) []string {
merged := make([]string, 0, len(userRelays)+len(pairingFallbackRelays))
merged = append(merged, userRelays...)
merged = append(merged, pairingFallbackRelays...)
return dedupStrings(merged)
}

// proxyEnvVars is the set of Go stdlib proxy env vars that
// net/http honors when resolving a Transport's proxy function.
// If any of these are set we have to route the wss:// dial through
Expand Down Expand Up @@ -189,6 +230,14 @@ func (s *Session) ConnectRelays(ctx context.Context) error {
}

// Subscribe on every connected relay; merge event channels.
// Track how many subscriptions are currently alive so that
// CheckConnection can distinguish "no events yet" (subscriber
// count > 0) from "all relays dropped" (subscriber count ==
// 0 — events will never arrive). The latter is the failure
// mode where Amber successfully approves but hoot never sees
// the connect ack because every wss:// connection silently
// died after the initial dial (e.g. transient network blip
// between dial and the user's Amber scan).
subCtx, cancel := context.WithCancel(ctx)
s.cancel = cancel

Expand All @@ -198,24 +247,37 @@ func (s *Session) ConnectRelays(ctx context.Context) error {
}

type relaySub struct {
relay *nostr.Relay
events chan *nostr.Event
close func()
}
var subs []*relaySub
for _, relay := range s.relays {
// Drop relays that died between dial and subscribe. go-nostr
// reports a closed connection via IsConnected() returning
// false; subscribing against a closed relay silently
// produces a never-firing channel, which would mislead
// CheckConnection into "no events yet" forever.
if !relay.IsConnected() {
continue
}
sub, err := relay.Subscribe(subCtx, nostr.Filters{filter})
if err != nil {
continue
}
subs = append(subs, &relaySub{events: sub.Events, close: sub.Close})
subs = append(subs, &relaySub{relay: relay, events: sub.Events, close: sub.Close})
}
if len(subs) == 0 {
cancel()
s.Close()
return fmt.Errorf("no relay accepted our subscription")
return fmt.Errorf("no relay accepted our subscription (tried: %s)",
strings.Join(s.RelayURLs, ", "))
}

// Merge into s.merged so CheckConnection can poll it.
// Merge into s.merged so CheckConnection can poll it. Track
// live subscription count atomically — decremented as relays
// disconnect, observed by CheckConnection to detect "all
// relays dropped" vs "still waiting".
s.merged = make(chan *nostr.Event, 64)
var wg sync.WaitGroup
for _, rs := range subs {
Expand Down Expand Up @@ -258,7 +320,13 @@ func (s *Session) CheckConnection(timeout time.Duration) (string, error) {
select {
case ev, ok := <-s.merged:
if !ok {
return "", fmt.Errorf("subscription closed")
// s.merged was closed — every relay's subscription
// channel has drained. That's only possible if all
// our wss:// connections died. Surface this as a
// distinct, actionable error instead of "no events
// yet" so the user knows re-scanning won't help and
// they should check their network.
return "", fmt.Errorf("all relay subscriptions closed — every relay in the pairing set dropped its connection; check network and try again")
}
if ev == nil {
continue
Expand Down
112 changes: 112 additions & 0 deletions nip46/nip46_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,3 +381,115 @@ func TestSessionCloseIsIdempotent(t *testing.T) {
s.Close()
s.Close() // twice
}

// ---------------------------------------------------------------------------
// Pairing fallback relays. Without these, Amber's connect ack can land
// on a relay hoot isn't subscribed to — symptom: scan approved in Amber,
// no response in hoot. See PR fixing that bug for the full writeup.
// ---------------------------------------------------------------------------

// TestPairingRelaysIncludesFallbacks pins that PairingRelays always
// adds the hard-coded fallback set so Amber has somewhere to publish
// even when the user's relays.txt is sparse or weird.
func TestPairingRelaysIncludesFallbacks(t *testing.T) {
gots := PairingRelays([]string{"wss://my-relay.example.com"})
have := map[string]bool{}
for _, u := range gots {
have[u] = true
}
for _, want := range pairingFallbackRelays {
if !have[want] {
t.Errorf("PairingRelays missing fallback %q (got: %v)", want, gots)
}
}
}

// 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"}
gots := PairingRelays(userRelays)

// Both user relays must come before any fallback.
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")
}
}
}
// 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)
}
}

// TestPairingRelaysDedups: a user relay that happens to also be
// in the fallback set appears once. Without this, the QR URI
// would have duplicate relay= params and signers could behave
// inconsistently (some treat duplicates as a preference signal).
func TestPairingRelaysDedups(t *testing.T) {
userRelays := []string{
"wss://relay.damus.io", // overlaps fallback
"wss://my.example.com",
}
gots := PairingRelays(userRelays)
counts := map[string]int{}
for _, u := range gots {
counts[u]++
}
for u, n := range counts {
if n > 1 {
t.Errorf("relay %q appears %d times in PairingRelays result %v", u, n, gots)
}
}
}

// TestPairingRelaysEmptyUser: when the user has no relays
// configured at all (nil or empty slice), the result still
// contains the fallback set so the pairing can proceed.
func TestPairingRelaysEmptyUser(t *testing.T) {
for _, in := range [][]string{nil, {}} {
gots := PairingRelays(in)
if len(gots) == 0 {
t.Errorf("PairingRelays(%v) returned empty — fallbacks must always be present", in)
}
}
}

// TestConnectRelaysSkipsDisconnectedRelays pins the new defensive
// check: relays whose connection died between dial and subscribe
// must not be subscribed against (they'd produce a never-firing
// channel that fools CheckConnection into "no events yet" forever).
// We can't easily make a go-nostr Relay.IsConnected() return false
// without a real dial — but we CAN exercise the loop's existing
// skip-on-error branch and assert the relay is not added to subs.
// This is a coverage marker rather than a full functional test.
func TestConnectRelaysSkipsDisconnectedRelays(t *testing.T) {
clearProxyEnv(t)
// Use a mix of an unresolvable host ("dialRelays" will skip
// it) and a never-listening TCP port (will return error
// quickly). Both paths should not panic and should not yield
// any successful subscription.
s := &Session{
ClientPrivateKey: "0000000000000000000000000000000000000000000000000000000000000001",
ClientPublicKey: "0000000000000000000000000000000000000000000000000000000000000002",
RelayURLs: []string{
"wss://invalid-relay-does-not-exist.invalid",
},
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s.ConnectRelays(ctx)
if err == nil {
t.Errorf("expected ConnectRelays to error when no relay is reachable, got nil")
}
}
Loading