main.go raw

   1  // Command nostrino-neutrino is a TinyGo-buildable compact-filter light client.
   2  // It syncs block headers, filter headers and compact filters over either a
   3  // direct TCP P2P connection or the encrypted Nostr relay tunnel.
   4  //
   5  //	nostrino-neutrino -uri 'nostr+chaindata://<gateway>?relay=ws://127.0.0.1:3334' -chain regtest
   6  //	nostrino-neutrino -tcp 127.0.0.1:18444 -chain regtest -store ./data
   7  //
   8  // The client verifies the header chain and the filter header chain itself. A
   9  // bridge can withhold data; it cannot forge it.
  10  package main
  11  
  12  import (
  13  	"context"
  14  	"encoding/hex"
  15  	"encoding/json"
  16  	"errors"
  17  	"flag"
  18  	"fmt"
  19  	"net"
  20  	"net/url"
  21  	"os"
  22  	"os/signal"
  23  	"path/filepath"
  24  	"strings"
  25  	"syscall"
  26  	"time"
  27  
  28  	"git.smesh.lol/nostrino/pkg/chain"
  29  	"git.smesh.lol/nostrino/pkg/neutrino"
  30  	"git.smesh.lol/nostrino/pkg/tunnel"
  31  	"git.smesh.lol/nostrino/pkg/ws"
  32  )
  33  
  34  type stringsFlag []string
  35  
  36  func (s *stringsFlag) String() string { return strings.Join(*s, ",") }
  37  func (s *stringsFlag) Set(v string) error {
  38  	for _, part := range strings.Split(v, ",") {
  39  		if part = strings.TrimSpace(part); part != "" {
  40  			*s = append(*s, part)
  41  		}
  42  	}
  43  	return nil
  44  }
  45  
  46  type options struct {
  47  	uri               string
  48  	gateway           string
  49  	relays            stringsFlag
  50  	tcp               string
  51  	chainName         string
  52  	profilePath       string
  53  	cpHeight          uint
  54  	cpHash            string
  55  	cpFilter          string
  56  	storeDir          string
  57  	memory            bool
  58  	filtersFrom       uint
  59  	maxFilters        int
  60  	headersOnly       bool
  61  	filterHeadersOnly bool
  62  	watch             stringsFlag
  63  	timeout           time.Duration
  64  	json              bool
  65  	noBridge          bool
  66  	verbose           bool
  67  }
  68  
  69  type report struct {
  70  	Chain         string   `json:"chain"`
  71  	TipHeight     uint32   `json:"tip_height"`
  72  	TipHash       string   `json:"tip_hash"`
  73  	StartHeight   uint32   `json:"start_height"`
  74  	HeadersAdded  int      `json:"headers_added"`
  75  	FilterHeaders int      `json:"filter_headers_added"`
  76  	FiltersAdded  int      `json:"filters_added"`
  77  	FilterBytes   int      `json:"filter_bytes"`
  78  	Elapsed       string   `json:"elapsed"`
  79  	FilterTip     uint32   `json:"filter_tip,omitempty"`
  80  	Matches       []uint32 `json:"matches,omitempty"`
  81  	Store         string   `json:"store"`
  82  	PeerTip       int32    `json:"peer_tip"`
  83  	ElapsedMillis int64    `json:"elapsed_millis"`
  84  }
  85  
  86  func main() {
  87  	if err := run(); err != nil {
  88  		fmt.Fprintln(os.Stderr, "nostrino-neutrino:", err)
  89  		os.Exit(1)
  90  	}
  91  }
  92  
  93  func run() error {
  94  	var o options
  95  	fs := flag.NewFlagSet("nostrino-neutrino", flag.ContinueOnError)
  96  	fs.StringVar(&o.uri, "uri", "", "nostr+chaindata connection URI")
  97  	fs.StringVar(&o.gateway, "gateway", "", "gateway pubkey (with -relay) instead of -uri")
  98  	fs.Var(&o.relays, "relay", "relay URL; repeat or comma-separate")
  99  	fs.StringVar(&o.tcp, "tcp", "", "direct node P2P address instead of the tunnel")
 100  	fs.StringVar(&o.chainName, "chain", "regtest", "chain profile: btc, testnet3, regtest, signet, or a name in -profile")
 101  	fs.StringVar(&o.profilePath, "profile", "", "chain profile JSON (for XBT or a custom network)")
 102  	fs.UintVar(&o.cpHeight, "checkpoint-height", 0, "checkpoint height (with the two hashes)")
 103  	fs.StringVar(&o.cpHash, "checkpoint-hash", "", "checkpoint block hash (display order)")
 104  	fs.StringVar(&o.cpFilter, "checkpoint-filter-header", "", "checkpoint filter header (display order)")
 105  	fs.StringVar(&o.storeDir, "store", "", "store directory (default ~/.nostrino/neutrino/<chain>)")
 106  	fs.BoolVar(&o.memory, "memory", false, "keep no state on disk")
 107  	fs.UintVar(&o.filtersFrom, "filters-from", 0, "first filter height to download (0 = earliest)")
 108  	fs.IntVar(&o.maxFilters, "max-filters", 0, "maximum filters to download (0 = all)")
 109  	fs.BoolVar(&o.headersOnly, "headers-only", false, "sync block headers only")
 110  	fs.BoolVar(&o.filterHeadersOnly, "filter-headers-only", false, "sync block and filter headers, no filters")
 111  	fs.Var(&o.watch, "match", "script hex to match against downloaded filters; repeat or comma-separate")
 112  	fs.DurationVar(&o.timeout, "timeout", 10*time.Minute, "overall sync timeout")
 113  	fs.BoolVar(&o.json, "json", false, "print a JSON report")
 114  	fs.BoolVar(&o.noBridge, "no-bridge", false, "ignore bridge= URLs and use the relay stream")
 115  	fs.BoolVar(&o.verbose, "v", false, "verbose logging")
 116  	if err := fs.Parse(os.Args[1:]); err != nil {
 117  		return err
 118  	}
 119  	profile, err := loadProfile(o.profilePath, o.chainName)
 120  	if err != nil {
 121  		return err
 122  	}
 123  	if o.cpHash != "" || o.cpFilter != "" {
 124  		if o.cpHash == "" || o.cpFilter == "" {
 125  			return errors.New("a checkpoint needs both -checkpoint-hash and -checkpoint-filter-header")
 126  		}
 127  		cp, err := chain.ParseCheckpoint(uint32(o.cpHeight), o.cpHash, o.cpFilter)
 128  		if err != nil {
 129  			return err
 130  		}
 131  		profile.Checkpoint = cp
 132  	}
 133  	if err := profile.Validate(); err != nil {
 134  		return err
 135  	}
 136  
 137  	var dial func(ctx context.Context) (net.Conn, error)
 138  	var describe string
 139  	switch {
 140  	case o.tcp != "":
 141  		addr := o.tcp
 142  		dial = func(ctx context.Context) (net.Conn, error) {
 143  			var d net.Dialer
 144  			return d.DialContext(ctx, "tcp", addr)
 145  		}
 146  		describe = "tcp " + addr
 147  	default:
 148  		u, err := connectionURI(o)
 149  		if err != nil {
 150  			return err
 151  		}
 152  		var key [32]byte
 153  		dial = func(ctx context.Context) (net.Conn, error) {
 154  			var bridgeErr error
 155  			if !o.noBridge {
 156  				for _, raw := range u.Bridges {
 157  					c, err := ws.DialStream(withChain(raw, profile.Name))
 158  					if err == nil {
 159  						return c, nil
 160  					}
 161  					bridgeErr = err
 162  				}
 163  			}
 164  			c, err := tunnel.Dial(ctx, tunnel.DialConfig{
 165  				URI:   u,
 166  				Key:   key,
 167  				Chain: profile.Name,
 168  				Logf: func(format string, args ...any) {
 169  					if o.verbose {
 170  						fmt.Fprintf(os.Stderr, "tunnel: "+format+"\n", args...)
 171  					}
 172  				},
 173  			})
 174  			if err != nil && bridgeErr != nil {
 175  				return nil, fmt.Errorf("websocket bridge: %v; relay: %w", bridgeErr, err)
 176  			}
 177  			return c, err
 178  		}
 179  		describe = "nostr " + u.Relays[0] + " gateway " + nostrHex(u.Gateway)
 180  		if len(u.Bridges) > 0 && !o.noBridge {
 181  			describe = "websocket " + u.Bridges[0] + " (relay fallback)"
 182  		}
 183  	}
 184  
 185  	storePath := o.storeDir
 186  	if storePath == "" && !o.memory {
 187  		storePath = filepath.Join(defaultNeutrinoDir(), profile.Name)
 188  	}
 189  	var store neutrino.Store
 190  	var fileStore *neutrino.FileStore
 191  	if o.memory {
 192  		store = neutrino.NewMemory()
 193  	} else {
 194  		fileStore, err = neutrino.OpenFileStore(storePath)
 195  		if err != nil {
 196  			return err
 197  		}
 198  		defer fileStore.Close()
 199  		store = fileStore
 200  	}
 201  
 202  	logf := func(string, ...any) {}
 203  	if o.verbose {
 204  		logf = func(format string, args ...any) {
 205  			fmt.Fprintf(os.Stderr, "neutrino: "+format+"\n", args...)
 206  		}
 207  	}
 208  	client, err := neutrino.New(neutrino.Config{
 209  		Profile:               profile,
 210  		Store:                 store,
 211  		Dial:                  dial,
 212  		RequireCompactFilters: true,
 213  		ReadTimeout:           120 * time.Second,
 214  		Logf:                  logf,
 215  	})
 216  	if err != nil {
 217  		return err
 218  	}
 219  	defer client.Close()
 220  
 221  	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
 222  	defer cancel()
 223  	ctx, cancelTimeout := context.WithTimeout(ctx, o.timeout)
 224  	defer cancelTimeout()
 225  
 226  	startTip, startHash, _ := store.HeaderTip()
 227  	rep := report{Chain: profile.Name, StartHeight: startTip, TipHash: startHash.String(), Store: storePath}
 228  	started := time.Now()
 229  	if err := client.Connect(ctx); err != nil {
 230  		return err
 231  	}
 232  	switch {
 233  	case o.headersOnly:
 234  		n, err := client.SyncHeaders(ctx)
 235  		if err != nil {
 236  			return err
 237  		}
 238  		rep.HeadersAdded = n
 239  	case o.filterHeadersOnly:
 240  		n, err := client.SyncHeaders(ctx)
 241  		if err != nil {
 242  			return err
 243  		}
 244  		rep.HeadersAdded = n
 245  		m, err := client.SyncFilterHeaders(ctx)
 246  		if err != nil {
 247  			return err
 248  		}
 249  		rep.FilterHeaders = m
 250  	default:
 251  		n, err := client.SyncHeaders(ctx)
 252  		if err != nil {
 253  			return err
 254  		}
 255  		rep.HeadersAdded = n
 256  		m, err := client.SyncFilterHeaders(ctx)
 257  		if err != nil {
 258  			return err
 259  		}
 260  		rep.FilterHeaders = m
 261  		k, bytes, err := client.SyncFilters(ctx, uint32(o.filtersFrom), o.maxFilters)
 262  		if err != nil {
 263  			return err
 264  		}
 265  		rep.FiltersAdded = k
 266  		rep.FilterBytes = bytes
 267  	}
 268  	if scripts, err := parseScripts(o.watch); err != nil {
 269  		return err
 270  	} else if len(scripts) > 0 {
 271  		matches, err := client.MatchScripts(0, 0, scripts)
 272  		if err != nil {
 273  			return err
 274  		}
 275  		rep.Matches = matches
 276  	}
 277  	rep.Elapsed = time.Since(started).Round(time.Millisecond).String()
 278  	rep.ElapsedMillis = time.Since(started).Milliseconds()
 279  	rep.PeerTip = client.PeerTip()
 280  	if tip, hash, ok := store.HeaderTip(); ok {
 281  		rep.TipHeight = tip
 282  		rep.TipHash = hash.String()
 283  	}
 284  	if ftip, ok := store.FilterTip(); ok {
 285  		rep.FilterTip = ftip
 286  	}
 287  	if o.json {
 288  		out, _ := json.MarshalIndent(rep, "", "  ")
 289  		fmt.Println(string(out))
 290  		return nil
 291  	}
 292  	fmt.Printf("chain: %s via %s\n", profile.Name, describe)
 293  	fmt.Printf("tip: %d %s (peer advertised %d)\n", rep.TipHeight, rep.TipHash, rep.PeerTip)
 294  	fmt.Printf("added: headers=%d filter-headers=%d filters=%d bytes=%d in %s\n",
 295  		rep.HeadersAdded, rep.FilterHeaders, rep.FiltersAdded, rep.FilterBytes, rep.Elapsed)
 296  	if rep.FilterTip != 0 || rep.FiltersAdded > 0 {
 297  		fmt.Printf("filter tip: %d\n", rep.FilterTip)
 298  	}
 299  	if storePath != "" {
 300  		fmt.Printf("store: %s\n", storePath)
 301  	}
 302  	if len(rep.Matches) > 0 {
 303  		fmt.Printf("matching heights: %v\n", rep.Matches)
 304  	} else if len(o.watch) > 0 {
 305  		fmt.Println("matching heights: none")
 306  	}
 307  	return nil
 308  }
 309  
 310  func withChain(raw, chain string) string {
 311  	u, err := url.Parse(raw)
 312  	if err != nil {
 313  		return raw
 314  	}
 315  	q := u.Query()
 316  	q.Set("chain", chain)
 317  	u.RawQuery = q.Encode()
 318  	return u.String()
 319  }
 320  
 321  func connectionURI(o options) (*tunnel.URI, error) {
 322  	if o.uri != "" {
 323  		return tunnel.ParseURI(o.uri)
 324  	}
 325  	if o.gateway == "" {
 326  		return nil, errors.New("one of -uri, -gateway or -tcp is required")
 327  	}
 328  	if len(o.relays) == 0 {
 329  		return nil, errors.New("-gateway needs at least one -relay")
 330  	}
 331  	var pub [32]byte
 332  	if err := decodeHex32(o.gateway, pub[:]); err != nil {
 333  		return nil, err
 334  	}
 335  	return &tunnel.URI{Gateway: pub, Relays: o.relays, Version: 1}, nil
 336  }
 337  
 338  func decodeHex32(s string, dst []byte) error {
 339  	b, err := hex.DecodeString(strings.TrimSpace(s))
 340  	if err != nil || len(b) != 32 {
 341  		return errors.New("pubkey must be 64 hex characters")
 342  	}
 343  	copy(dst, b)
 344  	return nil
 345  }
 346  
 347  func nostrHex(h [32]byte) string { return hex.EncodeToString(h[:]) }
 348  
 349  func parseScripts(values []string) ([][]byte, error) {
 350  	var out [][]byte
 351  	for _, v := range values {
 352  		b, err := hex.DecodeString(strings.TrimSpace(v))
 353  		if err != nil {
 354  			return nil, fmt.Errorf("script %q is not hex: %w", v, err)
 355  		}
 356  		if len(b) == 0 {
 357  			return nil, errors.New("empty script")
 358  		}
 359  		out = append(out, b)
 360  	}
 361  	return out, nil
 362  }
 363  
 364  func loadProfile(path, name string) (*chain.Profile, error) {
 365  	if path != "" {
 366  		return chain.LoadProfile(path)
 367  	}
 368  	p, err := chain.ByName(name)
 369  	if err != nil {
 370  		return nil, err
 371  	}
 372  	if err := p.Validate(); err != nil {
 373  		return nil, fmt.Errorf("chain %q is not configured in this build (use -profile): %w", name, err)
 374  	}
 375  	return p, nil
 376  }
 377  
 378  func defaultNeutrinoDir() string {
 379  	home, err := os.UserHomeDir()
 380  	if err != nil || home == "" {
 381  		return ".nostrino/neutrino"
 382  	}
 383  	return filepath.Join(home, ".nostrino", "neutrino")
 384  }
 385