// Package registry publishes and discovers chain-data gateways as // addressable Nostr events. The URI is the direct answer; the registry is how // a page finds a gateway without already knowing its public key. package registry import ( "context" "crypto/rand" "encoding/json" "fmt" "sort" "sync" "time" "git.smesh.lol/nostrino/pkg/crypto/secp256k1" "git.smesh.lol/nostrino/pkg/nostr" "git.smesh.lol/nostrino/pkg/relay" ) // KindGateway is the addressable event kind a gateway publishes. The d tag is // the gateway public key, so a renewal replaces the previous announcement. const KindGateway = 30333 func contains(values []string, want string) bool { for _, v := range values { if v == want { return true } } return false } // Info is the announcement payload and the discovery result. type Info struct { // Gateway is filled in by Discover from the event author. Gateway string `json:"gateway,omitempty"` Version string `json:"version"` URI string `json:"uri"` Chains []string `json:"chains"` Relays []string `json:"relays,omitempty"` Tip int64 `json:"tip,omitempty"` UpdatedAt int64 `json:"updated_at,omitempty"` } // Event builds and signs a gateway announcement. func Event(info Info, key [32]byte) (*nostr.Event, error) { pub, ok := secp256k1.PubKey(key) if !ok { return nil, fmt.Errorf("registry: invalid key") } pubHex := nostr.Hex(pub[:]) if info.Gateway == "" { info.Gateway = pubHex } if info.UpdatedAt == 0 { info.UpdatedAt = time.Now().Unix() } body, err := json.Marshal(info) if err != nil { return nil, fmt.Errorf("registry: encode: %w", err) } tags := nostr.Tags{{"d", pubHex}} for _, c := range info.Chains { // t is a single-letter tag, so a relay can index and filter it; the // discovery filter below still checks the payload because a gateway // may serve several chains. tags = append(tags, nostr.Tag{"t", c}) } for _, r := range info.Relays { tags = append(tags, nostr.Tag{"relay", r}) } ev := &nostr.Event{ Kind: KindGateway, CreatedAt: time.Now().Unix(), Tags: tags, Content: string(body), } if err := ev.Sign(key); err != nil { return nil, err } return ev, nil } // Discover asks relays for announcements for chain and collects them until // EOSE plus a short grace period, so several relays can answer. func Discover(ctx context.Context, factory func(string) (relay.Transport, error), relays []string, chain string, collect time.Duration) ([]Info, error) { if len(relays) == 0 { return nil, fmt.Errorf("registry: no relay") } var key [32]byte if _, err := rand.Read(key[:]); err != nil { return nil, err } filter := nostr.Filter{Kinds: []int{KindGateway}} type found struct { info Info at int64 } results := make(map[string]found) var mu sync.Mutex var clients []*relay.Client var subs []*relay.Subscription defer func() { for _, s := range subs { s.Close() } for _, c := range clients { c.Close() } }() for _, url := range relays { t, err := factory(url) if err != nil { continue } c, err := relay.New(t, key, relay.Options{RelayURL: url}) if err != nil { t.Close() continue } sub, err := c.Subscribe(filter) if err != nil { c.Close() continue } if err := c.WaitEOSE(ctx, sub); err != nil { c.Close() continue } clients = append(clients, c) subs = append(subs, sub) go func(sub *relay.Subscription) { for { select { case <-ctx.Done(): return case <-sub.Done(): return case ev := <-sub.Events: if ev == nil || ev.Kind != KindGateway { continue } var info Info if err := json.Unmarshal([]byte(ev.Content), &info); err != nil { continue } if chain != "" && !contains(info.Chains, chain) { continue } info.Gateway = nostr.Hex(ev.PubKey[:]) if info.URI == "" { info.URI = "nostr+chaindata://" + info.Gateway + "?v=1" } if info.UpdatedAt == 0 { info.UpdatedAt = ev.CreatedAt } mu.Lock() prev, ok := results[info.Gateway] if !ok || info.UpdatedAt >= prev.at { results[info.Gateway] = found{info: info, at: info.UpdatedAt} } mu.Unlock() } } }(sub) } if len(clients) == 0 { return nil, fmt.Errorf("registry: no relay connected") } if collect == 0 { collect = 1500 * time.Millisecond } select { case <-time.After(collect): case <-ctx.Done(): } mu.Lock() out := make([]Info, 0, len(results)) for _, f := range results { out = append(out, f.info) } mu.Unlock() sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) return out, nil }