registry.go raw
1 // Package registry publishes and discovers chain-data gateways as
2 // addressable Nostr events. The URI is the direct answer; the registry is how
3 // a page finds a gateway without already knowing its public key.
4 package registry
5
6 import (
7 "context"
8 "crypto/rand"
9 "encoding/json"
10 "fmt"
11 "sort"
12 "sync"
13 "time"
14
15 "git.smesh.lol/nostrino/pkg/crypto/secp256k1"
16 "git.smesh.lol/nostrino/pkg/nostr"
17 "git.smesh.lol/nostrino/pkg/relay"
18 )
19
20 // KindGateway is the addressable event kind a gateway publishes. The d tag is
21 // the gateway public key, so a renewal replaces the previous announcement.
22 const KindGateway = 30333
23
24 func contains(values []string, want string) bool {
25 for _, v := range values {
26 if v == want {
27 return true
28 }
29 }
30 return false
31 }
32
33 // Info is the announcement payload and the discovery result.
34 type Info struct {
35 // Gateway is filled in by Discover from the event author.
36 Gateway string `json:"gateway,omitempty"`
37 Version string `json:"version"`
38 URI string `json:"uri"`
39 Chains []string `json:"chains"`
40 Relays []string `json:"relays,omitempty"`
41 Tip int64 `json:"tip,omitempty"`
42 UpdatedAt int64 `json:"updated_at,omitempty"`
43 }
44
45 // Event builds and signs a gateway announcement.
46 func Event(info Info, key [32]byte) (*nostr.Event, error) {
47 pub, ok := secp256k1.PubKey(key)
48 if !ok {
49 return nil, fmt.Errorf("registry: invalid key")
50 }
51 pubHex := nostr.Hex(pub[:])
52 if info.Gateway == "" {
53 info.Gateway = pubHex
54 }
55 if info.UpdatedAt == 0 {
56 info.UpdatedAt = time.Now().Unix()
57 }
58 body, err := json.Marshal(info)
59 if err != nil {
60 return nil, fmt.Errorf("registry: encode: %w", err)
61 }
62 tags := nostr.Tags{{"d", pubHex}}
63 for _, c := range info.Chains {
64 // t is a single-letter tag, so a relay can index and filter it; the
65 // discovery filter below still checks the payload because a gateway
66 // may serve several chains.
67 tags = append(tags, nostr.Tag{"t", c})
68 }
69 for _, r := range info.Relays {
70 tags = append(tags, nostr.Tag{"relay", r})
71 }
72 ev := &nostr.Event{
73 Kind: KindGateway,
74 CreatedAt: time.Now().Unix(),
75 Tags: tags,
76 Content: string(body),
77 }
78 if err := ev.Sign(key); err != nil {
79 return nil, err
80 }
81 return ev, nil
82 }
83
84 // Discover asks relays for announcements for chain and collects them until
85 // EOSE plus a short grace period, so several relays can answer.
86 func Discover(ctx context.Context, factory func(string) (relay.Transport, error), relays []string, chain string, collect time.Duration) ([]Info, error) {
87 if len(relays) == 0 {
88 return nil, fmt.Errorf("registry: no relay")
89 }
90 var key [32]byte
91 if _, err := rand.Read(key[:]); err != nil {
92 return nil, err
93 }
94 filter := nostr.Filter{Kinds: []int{KindGateway}}
95 type found struct {
96 info Info
97 at int64
98 }
99 results := make(map[string]found)
100 var mu sync.Mutex
101 var clients []*relay.Client
102 var subs []*relay.Subscription
103 defer func() {
104 for _, s := range subs {
105 s.Close()
106 }
107 for _, c := range clients {
108 c.Close()
109 }
110 }()
111 for _, url := range relays {
112 t, err := factory(url)
113 if err != nil {
114 continue
115 }
116 c, err := relay.New(t, key, relay.Options{RelayURL: url})
117 if err != nil {
118 t.Close()
119 continue
120 }
121 sub, err := c.Subscribe(filter)
122 if err != nil {
123 c.Close()
124 continue
125 }
126 if err := c.WaitEOSE(ctx, sub); err != nil {
127 c.Close()
128 continue
129 }
130 clients = append(clients, c)
131 subs = append(subs, sub)
132 go func(sub *relay.Subscription) {
133 for {
134 select {
135 case <-ctx.Done():
136 return
137 case <-sub.Done():
138 return
139 case ev := <-sub.Events:
140 if ev == nil || ev.Kind != KindGateway {
141 continue
142 }
143 var info Info
144 if err := json.Unmarshal([]byte(ev.Content), &info); err != nil {
145 continue
146 }
147 if chain != "" && !contains(info.Chains, chain) {
148 continue
149 }
150 info.Gateway = nostr.Hex(ev.PubKey[:])
151 if info.URI == "" {
152 info.URI = "nostr+chaindata://" + info.Gateway + "?v=1"
153 }
154 if info.UpdatedAt == 0 {
155 info.UpdatedAt = ev.CreatedAt
156 }
157 mu.Lock()
158 prev, ok := results[info.Gateway]
159 if !ok || info.UpdatedAt >= prev.at {
160 results[info.Gateway] = found{info: info, at: info.UpdatedAt}
161 }
162 mu.Unlock()
163 }
164 }
165 }(sub)
166 }
167 if len(clients) == 0 {
168 return nil, fmt.Errorf("registry: no relay connected")
169 }
170 if collect == 0 {
171 collect = 1500 * time.Millisecond
172 }
173 select {
174 case <-time.After(collect):
175 case <-ctx.Done():
176 }
177 mu.Lock()
178 out := make([]Info, 0, len(results))
179 for _, f := range results {
180 out = append(out, f.info)
181 }
182 mu.Unlock()
183 sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt })
184 return out, nil
185 }
186