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