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