// Command nostrino-neutrino is a TinyGo-buildable compact-filter light client. // It syncs block headers, filter headers and compact filters over either a // direct TCP P2P connection or the encrypted Nostr relay tunnel. // // nostrino-neutrino -uri 'nostr+chaindata://?relay=ws://127.0.0.1:3334' -chain regtest // nostrino-neutrino -tcp 127.0.0.1:18444 -chain regtest -store ./data // // The client verifies the header chain and the filter header chain itself. A // bridge can withhold data; it cannot forge it. package main import ( "context" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "net" "net/url" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "git.smesh.lol/nostrino/pkg/chain" "git.smesh.lol/nostrino/pkg/neutrino" "git.smesh.lol/nostrino/pkg/tunnel" "git.smesh.lol/nostrino/pkg/ws" ) type stringsFlag []string func (s *stringsFlag) String() string { return strings.Join(*s, ",") } func (s *stringsFlag) Set(v string) error { for _, part := range strings.Split(v, ",") { if part = strings.TrimSpace(part); part != "" { *s = append(*s, part) } } return nil } type options struct { uri string gateway string relays stringsFlag tcp string chainName string profilePath string cpHeight uint cpHash string cpFilter string storeDir string memory bool filtersFrom uint maxFilters int headersOnly bool filterHeadersOnly bool watch stringsFlag timeout time.Duration json bool noBridge bool verbose bool } type report struct { Chain string `json:"chain"` TipHeight uint32 `json:"tip_height"` TipHash string `json:"tip_hash"` StartHeight uint32 `json:"start_height"` HeadersAdded int `json:"headers_added"` FilterHeaders int `json:"filter_headers_added"` FiltersAdded int `json:"filters_added"` FilterBytes int `json:"filter_bytes"` Elapsed string `json:"elapsed"` FilterTip uint32 `json:"filter_tip,omitempty"` Matches []uint32 `json:"matches,omitempty"` Store string `json:"store"` PeerTip int32 `json:"peer_tip"` ElapsedMillis int64 `json:"elapsed_millis"` } func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "nostrino-neutrino:", err) os.Exit(1) } } func run() error { var o options fs := flag.NewFlagSet("nostrino-neutrino", flag.ContinueOnError) fs.StringVar(&o.uri, "uri", "", "nostr+chaindata connection URI") fs.StringVar(&o.gateway, "gateway", "", "gateway pubkey (with -relay) instead of -uri") fs.Var(&o.relays, "relay", "relay URL; repeat or comma-separate") fs.StringVar(&o.tcp, "tcp", "", "direct node P2P address instead of the tunnel") fs.StringVar(&o.chainName, "chain", "regtest", "chain profile: btc, testnet3, regtest, signet, or a name in -profile") fs.StringVar(&o.profilePath, "profile", "", "chain profile JSON (for XBT or a custom network)") fs.UintVar(&o.cpHeight, "checkpoint-height", 0, "checkpoint height (with the two hashes)") fs.StringVar(&o.cpHash, "checkpoint-hash", "", "checkpoint block hash (display order)") fs.StringVar(&o.cpFilter, "checkpoint-filter-header", "", "checkpoint filter header (display order)") fs.StringVar(&o.storeDir, "store", "", "store directory (default ~/.nostrino/neutrino/)") fs.BoolVar(&o.memory, "memory", false, "keep no state on disk") fs.UintVar(&o.filtersFrom, "filters-from", 0, "first filter height to download (0 = earliest)") fs.IntVar(&o.maxFilters, "max-filters", 0, "maximum filters to download (0 = all)") fs.BoolVar(&o.headersOnly, "headers-only", false, "sync block headers only") fs.BoolVar(&o.filterHeadersOnly, "filter-headers-only", false, "sync block and filter headers, no filters") fs.Var(&o.watch, "match", "script hex to match against downloaded filters; repeat or comma-separate") fs.DurationVar(&o.timeout, "timeout", 10*time.Minute, "overall sync timeout") fs.BoolVar(&o.json, "json", false, "print a JSON report") fs.BoolVar(&o.noBridge, "no-bridge", false, "ignore bridge= URLs and use the relay stream") fs.BoolVar(&o.verbose, "v", false, "verbose logging") if err := fs.Parse(os.Args[1:]); err != nil { return err } profile, err := loadProfile(o.profilePath, o.chainName) if err != nil { return err } if o.cpHash != "" || o.cpFilter != "" { if o.cpHash == "" || o.cpFilter == "" { return errors.New("a checkpoint needs both -checkpoint-hash and -checkpoint-filter-header") } cp, err := chain.ParseCheckpoint(uint32(o.cpHeight), o.cpHash, o.cpFilter) if err != nil { return err } profile.Checkpoint = cp } if err := profile.Validate(); err != nil { return err } var dial func(ctx context.Context) (net.Conn, error) var describe string switch { case o.tcp != "": addr := o.tcp dial = func(ctx context.Context) (net.Conn, error) { var d net.Dialer return d.DialContext(ctx, "tcp", addr) } describe = "tcp " + addr default: u, err := connectionURI(o) if err != nil { return err } var key [32]byte dial = func(ctx context.Context) (net.Conn, error) { var bridgeErr error if !o.noBridge { for _, raw := range u.Bridges { c, err := ws.DialStream(withChain(raw, profile.Name)) if err == nil { return c, nil } bridgeErr = err } } c, err := tunnel.Dial(ctx, tunnel.DialConfig{ URI: u, Key: key, Chain: profile.Name, Logf: func(format string, args ...any) { if o.verbose { fmt.Fprintf(os.Stderr, "tunnel: "+format+"\n", args...) } }, }) if err != nil && bridgeErr != nil { return nil, fmt.Errorf("websocket bridge: %v; relay: %w", bridgeErr, err) } return c, err } describe = "nostr " + u.Relays[0] + " gateway " + nostrHex(u.Gateway) if len(u.Bridges) > 0 && !o.noBridge { describe = "websocket " + u.Bridges[0] + " (relay fallback)" } } storePath := o.storeDir if storePath == "" && !o.memory { storePath = filepath.Join(defaultNeutrinoDir(), profile.Name) } var store neutrino.Store var fileStore *neutrino.FileStore if o.memory { store = neutrino.NewMemory() } else { fileStore, err = neutrino.OpenFileStore(storePath) if err != nil { return err } defer fileStore.Close() store = fileStore } logf := func(string, ...any) {} if o.verbose { logf = func(format string, args ...any) { fmt.Fprintf(os.Stderr, "neutrino: "+format+"\n", args...) } } client, err := neutrino.New(neutrino.Config{ Profile: profile, Store: store, Dial: dial, RequireCompactFilters: true, ReadTimeout: 120 * time.Second, Logf: logf, }) if err != nil { return err } defer client.Close() ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() ctx, cancelTimeout := context.WithTimeout(ctx, o.timeout) defer cancelTimeout() startTip, startHash, _ := store.HeaderTip() rep := report{Chain: profile.Name, StartHeight: startTip, TipHash: startHash.String(), Store: storePath} started := time.Now() if err := client.Connect(ctx); err != nil { return err } switch { case o.headersOnly: n, err := client.SyncHeaders(ctx) if err != nil { return err } rep.HeadersAdded = n case o.filterHeadersOnly: n, err := client.SyncHeaders(ctx) if err != nil { return err } rep.HeadersAdded = n m, err := client.SyncFilterHeaders(ctx) if err != nil { return err } rep.FilterHeaders = m default: n, err := client.SyncHeaders(ctx) if err != nil { return err } rep.HeadersAdded = n m, err := client.SyncFilterHeaders(ctx) if err != nil { return err } rep.FilterHeaders = m k, bytes, err := client.SyncFilters(ctx, uint32(o.filtersFrom), o.maxFilters) if err != nil { return err } rep.FiltersAdded = k rep.FilterBytes = bytes } if scripts, err := parseScripts(o.watch); err != nil { return err } else if len(scripts) > 0 { matches, err := client.MatchScripts(0, 0, scripts) if err != nil { return err } rep.Matches = matches } rep.Elapsed = time.Since(started).Round(time.Millisecond).String() rep.ElapsedMillis = time.Since(started).Milliseconds() rep.PeerTip = client.PeerTip() if tip, hash, ok := store.HeaderTip(); ok { rep.TipHeight = tip rep.TipHash = hash.String() } if ftip, ok := store.FilterTip(); ok { rep.FilterTip = ftip } if o.json { out, _ := json.MarshalIndent(rep, "", " ") fmt.Println(string(out)) return nil } fmt.Printf("chain: %s via %s\n", profile.Name, describe) fmt.Printf("tip: %d %s (peer advertised %d)\n", rep.TipHeight, rep.TipHash, rep.PeerTip) fmt.Printf("added: headers=%d filter-headers=%d filters=%d bytes=%d in %s\n", rep.HeadersAdded, rep.FilterHeaders, rep.FiltersAdded, rep.FilterBytes, rep.Elapsed) if rep.FilterTip != 0 || rep.FiltersAdded > 0 { fmt.Printf("filter tip: %d\n", rep.FilterTip) } if storePath != "" { fmt.Printf("store: %s\n", storePath) } if len(rep.Matches) > 0 { fmt.Printf("matching heights: %v\n", rep.Matches) } else if len(o.watch) > 0 { fmt.Println("matching heights: none") } return nil } func withChain(raw, chain string) string { u, err := url.Parse(raw) if err != nil { return raw } q := u.Query() q.Set("chain", chain) u.RawQuery = q.Encode() return u.String() } func connectionURI(o options) (*tunnel.URI, error) { if o.uri != "" { return tunnel.ParseURI(o.uri) } if o.gateway == "" { return nil, errors.New("one of -uri, -gateway or -tcp is required") } if len(o.relays) == 0 { return nil, errors.New("-gateway needs at least one -relay") } var pub [32]byte if err := decodeHex32(o.gateway, pub[:]); err != nil { return nil, err } return &tunnel.URI{Gateway: pub, Relays: o.relays, Version: 1}, nil } func decodeHex32(s string, dst []byte) error { b, err := hex.DecodeString(strings.TrimSpace(s)) if err != nil || len(b) != 32 { return errors.New("pubkey must be 64 hex characters") } copy(dst, b) return nil } func nostrHex(h [32]byte) string { return hex.EncodeToString(h[:]) } func parseScripts(values []string) ([][]byte, error) { var out [][]byte for _, v := range values { b, err := hex.DecodeString(strings.TrimSpace(v)) if err != nil { return nil, fmt.Errorf("script %q is not hex: %w", v, err) } if len(b) == 0 { return nil, errors.New("empty script") } out = append(out, b) } return out, nil } func loadProfile(path, name string) (*chain.Profile, error) { if path != "" { return chain.LoadProfile(path) } p, err := chain.ByName(name) if err != nil { return nil, err } if err := p.Validate(); err != nil { return nil, fmt.Errorf("chain %q is not configured in this build (use -profile): %w", name, err) } return p, nil } func defaultNeutrinoDir() string { home, err := os.UserHomeDir() if err != nil || home == "" { return ".nostrino/neutrino" } return filepath.Join(home, ".nostrino", "neutrino") }