Skip to content

Commit c29bf97

Browse files
committed
feat: optional clearnet initial sync and prefetch lookback
1 parent b5dce0d commit c29bf97

3 files changed

Lines changed: 201 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Two-phase startup for faster initial sync with Tor setups**: Added `--clearnet-initial-sync` / `CLEARNET_INITIAL_SYNC` (default: `true`). When Tor is configured, the node now syncs block headers and filter headers over clearnet first, then restarts the chain service in Tor mode for privacy-sensitive operations.
13+
14+
### Changed
15+
16+
- **Compact filter prefetch is now opt-in**: `--prefetchfilters` / `PREFETCH_FILTERS` now defaults to `false` to avoid downloading and storing the full historical filter set on first startup.
17+
- **New prefetch lookback control**: Added `--prefetchlookback` / `PREFETCH_LOOKBACK` (default: `105120`, about 2 years). When prefetch is enabled and `prefetchstart=0`, the prefetch start height is auto-computed as `tip - lookback`.
18+
1019
### Fixed
1120

1221
- **Spent UTXO detection in bulk endpoint**: `POST /v1/utxos` could return already-spent UTXOs because the batch `MatchAny` filter scan missed spending blocks in certain cases. Added a per-UTXO spend verification pass after the main scan that uses single-script `filter.Match` (the same approach used by the reliable `GET /v1/utxo/{txid}/{vout}` endpoint) to catch any spends missed by the batch scan.

neutrino_server/cmd/neutrinod/main.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ func main() {
3535
logLevel := flag.String("loglevel", getEnv("LOG_LEVEL", "info"), "Log level (trace, debug, info, warn, error)")
3636
addPeers := flag.String("addpeer", getEnv("ADD_PEERS", ""), "Comma-separated list of peers to add while still allowing discovery")
3737
torProxy := flag.String("torproxy", getEnv("TOR_PROXY", ""), "Tor SOCKS5 proxy address (e.g., 127.0.0.1:9050)")
38-
prefetchFilters := flag.Bool("prefetchfilters", getEnvBool("PREFETCH_FILTERS", true), "Enable background compact filter prefetch")
38+
prefetchFilters := flag.Bool("prefetchfilters", getEnvBool("PREFETCH_FILTERS", false), "Enable background compact filter prefetch (default: disabled to save storage)")
3939
prefetchWorkers := flag.Int("prefetchworkers", getEnvInt("PREFETCH_WORKERS", 0), "Number of workers for background filter prefetch (0=auto)")
4040
prefetchStart := flag.Int("prefetchstart", getEnvInt("PREFETCH_START", 0), "Start height for background filter prefetch")
41+
prefetchLookback := flag.Int("prefetchlookback", getEnvInt("PREFETCH_LOOKBACK", 105120), "When >0 and prefetchstart=0, auto-compute prefetch start as tip minus this many blocks (~2 years default)")
42+
clearnetInitialSync := flag.Bool("clearnet-initial-sync", getEnvBool("CLEARNET_INITIAL_SYNC", true), "Sync block headers over clearnet before switching to Tor (safe: headers are public data)")
4143
showVersion := flag.Bool("version", false, "Show version and exit")
4244
flag.Parse()
4345

@@ -58,6 +60,9 @@ func main() {
5860
logger.Infof("Data directory: %s", *dataDir)
5961
if *torProxy != "" {
6062
logger.Infof("Tor proxy: %s", *torProxy)
63+
if *clearnetInitialSync {
64+
logger.Infof("Clearnet initial sync: enabled (headers are public data, safe over clearnet)")
65+
}
6166
}
6267

6368
// Ensure data directory exists
@@ -68,17 +73,19 @@ func main() {
6873

6974
// Create neutrino node
7075
nodeConfig := &neutrino.Config{
71-
Network: *network,
72-
DataDir: *dataDir,
73-
TorProxy: *torProxy,
74-
AddPeers: *addPeers,
75-
MaxPeers: 8,
76-
FilterCacheSize: 100 * 1024 * 1024,
77-
PrefetchFilters: *prefetchFilters,
78-
PrefetchWorkers: *prefetchWorkers,
79-
PrefetchStart: int32(*prefetchStart),
80-
Logger: backend,
81-
LogLevel: *logLevel,
76+
Network: *network,
77+
DataDir: *dataDir,
78+
TorProxy: *torProxy,
79+
AddPeers: *addPeers,
80+
MaxPeers: 8,
81+
FilterCacheSize: 100 * 1024 * 1024,
82+
PrefetchFilters: *prefetchFilters,
83+
PrefetchWorkers: *prefetchWorkers,
84+
PrefetchStart: int32(*prefetchStart),
85+
PrefetchLookback: int32(*prefetchLookback),
86+
ClearnetInitialSync: *clearnetInitialSync,
87+
Logger: backend,
88+
LogLevel: *logLevel,
8289
}
8390

8491
node, err := neutrino.NewNode(nodeConfig)

neutrino_server/internal/neutrino/node.go

Lines changed: 173 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,20 @@ import (
3232

3333
// Config holds configuration for the neutrino node.
3434
type Config struct {
35-
Network string
36-
DataDir string
37-
TorProxy string
38-
AddPeers string
39-
MaxPeers int
40-
BanDuration time.Duration
41-
FilterCacheSize int
42-
PrefetchFilters bool
43-
PrefetchWorkers int
44-
PrefetchStart int32
45-
Logger *btclog.Backend
46-
LogLevel string
35+
Network string
36+
DataDir string
37+
TorProxy string
38+
AddPeers string
39+
MaxPeers int
40+
BanDuration time.Duration
41+
FilterCacheSize int
42+
PrefetchFilters bool
43+
PrefetchWorkers int
44+
PrefetchStart int32
45+
PrefetchLookback int32 // >0: auto-compute start as tip-lookback when PrefetchStart==0
46+
ClearnetInitialSync bool // When true and TorProxy set, sync headers over clearnet first
47+
Logger *btclog.Backend
48+
LogLevel string
4749
}
4850

4951
// Node wraps a neutrino ChainService with additional functionality.
@@ -133,6 +135,13 @@ func NewNode(config *Config) (*Node, error) {
133135
}
134136

135137
// Start initializes and starts the neutrino node.
138+
// When ClearnetInitialSync is enabled and a TorProxy is configured, the node
139+
// performs a two-phase startup:
140+
// 1. Phase 1 (clearnet): Sync block headers and filter headers without Tor.
141+
// This is safe because headers are deterministic public data identical for
142+
// all nodes -- downloading them reveals nothing about watched addresses.
143+
// 2. Phase 2 (Tor): Stop the clearnet chain service and restart with Tor for
144+
// all subsequent operations (filter fetches, block downloads, broadcasts).
136145
func (n *Node) Start() error {
137146
n.logger.Info("Starting neutrino node...")
138147

@@ -157,10 +166,79 @@ func (n *Node) Start() error {
157166
neutrinoLogger.SetLevel(level)
158167
neutrino.UseLogger(neutrinoLogger)
159168

169+
// Two-phase startup: clearnet headers sync, then switch to Tor
170+
if n.config.ClearnetInitialSync && n.config.TorProxy != "" {
171+
n.logger.Info("=== Phase 1: Syncing headers over clearnet (fast) ===")
172+
n.logger.Info("Block headers and filter headers are public deterministic data,")
173+
n.logger.Info("downloading them over clearnet does not reveal watched addresses.")
174+
175+
if err := n.startChainService(false); err != nil {
176+
n.db.Close()
177+
return fmt.Errorf("clearnet sync failed: %w", err)
178+
}
179+
180+
// Wait for header sync to complete
181+
n.logger.Info("Waiting for block header and filter header sync to complete...")
182+
if err := n.waitForHeaderSync(); err != nil {
183+
// Non-fatal: fall through to Tor startup with partial headers
184+
n.logger.Warnf("Clearnet header sync did not complete: %v", err)
185+
n.logger.Info("Proceeding to Tor mode with partial headers (will catch up via Tor)")
186+
} else {
187+
n.logger.Info("Header sync complete over clearnet")
188+
}
189+
190+
// Stop the clearnet chain service (DB stays open)
191+
n.logger.Info("=== Phase 2: Switching to Tor for privacy-sensitive operations ===")
192+
if err := n.chainService.Stop(); err != nil {
193+
n.logger.Warnf("Failed to stop clearnet chain service: %v", err)
194+
}
195+
n.chainService = nil
196+
197+
// Start with Tor
198+
if err := n.startChainService(true); err != nil {
199+
n.db.Close()
200+
return fmt.Errorf("Tor chain service start failed: %w", err)
201+
}
202+
n.logger.Info("Chain service restarted with Tor proxy")
203+
} else {
204+
// Single-phase startup (no Tor, or clearnet-initial-sync disabled)
205+
useTor := n.config.TorProxy != ""
206+
if err := n.startChainService(useTor); err != nil {
207+
n.db.Close()
208+
return err
209+
}
210+
}
211+
212+
// Open rescan state store for persistence
213+
stateStore, err := OpenStateStore(n.config.DataDir, n.logger)
214+
if err != nil {
215+
n.logger.Warnf("Failed to open rescan state store (persistence disabled): %v", err)
216+
// Non-fatal: fall back to in-memory only mode.
217+
stateStore = nil
218+
}
219+
220+
// Create rescan manager with persistence
221+
n.rescanMgr = NewRescanManager(n.chainService, n.logger, stateStore)
222+
223+
// Start sync monitoring goroutine
224+
go n.monitorSync()
225+
226+
if n.config.PrefetchFilters {
227+
go n.prefetchFilters()
228+
}
229+
230+
n.logger.Info("Neutrino node started")
231+
return nil
232+
}
233+
234+
// startChainService creates and starts a neutrino.ChainService.
235+
// When useTor is true, all connections are routed through the configured Tor
236+
// SOCKS5 proxy. The method reuses the already-open n.db database.
237+
func (n *Node) startChainService(useTor bool) error {
160238
// Create neutrino config
161239
neutrinoConfig := neutrino.Config{
162240
DataDir: n.config.DataDir,
163-
Database: db,
241+
Database: n.db,
164242
ChainParams: *n.chainParams,
165243
FilterCacheSize: uint64(n.config.FilterCacheSize),
166244
PersistToDisk: true,
@@ -186,34 +264,26 @@ func (n *Node) Start() error {
186264
n.logger.Infof("Using %d DNS seeds for discovery", len(seeds))
187265
}
188266

189-
// Configure Tor proxy if specified
190-
if n.config.TorProxy != "" {
267+
// Configure Tor proxy if requested
268+
if useTor && n.config.TorProxy != "" {
191269
n.logger.Infof("Configuring Tor SOCKS5 proxy: %s", n.config.TorProxy)
192270

193271
// Create a SOCKS5 dialer
194272
torDialer, err := proxy.SOCKS5("tcp", n.config.TorProxy, nil, proxy.Direct)
195273
if err != nil {
196-
n.db.Close()
197274
return fmt.Errorf("failed to create Tor SOCKS5 dialer: %w", err)
198275
}
199276

200277
// Set up DNS resolution through Tor to prevent DNS leaks
201-
// Use btcd's connmgr.TorLookupIP for actual DNS resolution via Tor
202278
neutrinoConfig.NameResolver = func(host string) ([]net.IP, error) {
203-
// If already an IP, return it directly
204279
if ip := net.ParseIP(host); ip != nil {
205280
return []net.IP{ip}, nil
206281
}
207282

208-
// For .onion addresses, encode as IP bytes to preserve the hostname
209-
// Note: This causes "unsupported IP type" warnings in neutrino's logs,
210-
// but they're cosmetic and don't affect functionality. The connection works perfectly.
211283
if strings.HasSuffix(host, ".onion") {
212284
return []net.IP{net.IP([]byte(host))}, nil
213285
}
214286

215-
// For regular DNS names, resolve through Tor
216-
// This performs actual DNS resolution via Tor's SOCKS proxy
217287
ips, err := connmgr.TorLookupIP(host, n.config.TorProxy)
218288
if err != nil {
219289
n.logger.Warnf("Tor DNS lookup failed for %s: %v", host, err)
@@ -227,27 +297,28 @@ func (n *Node) Start() error {
227297
neutrinoConfig.Dialer = func(addr net.Addr) (net.Conn, error) {
228298
targetAddr := addr.String()
229299

230-
// Check if this is an encoded .onion address (IP length > 16)
231300
if tcpAddr, ok := addr.(*net.TCPAddr); ok && len(tcpAddr.IP) > 16 {
232-
// Recover the original .onion hostname from the IP bytes
233301
hostname := string(tcpAddr.IP)
234302
targetAddr = net.JoinHostPort(hostname, fmt.Sprintf("%d", tcpAddr.Port))
235303
}
236304

237-
// Dial through Tor - it will handle .onion addresses
238-
// For regular IPs, they've already been resolved via TorLookupIP
239305
return torDialer.Dial("tcp", targetAddr)
240306
}
241307

242308
n.logger.Info("Tor proxy configured successfully (DNS resolution via Tor)")
309+
} else if useTor {
310+
n.logger.Warn("Tor requested but no proxy configured, using clearnet")
243311
}
244312

245-
n.logger.Infof("Creating chain service for network: %s", n.chainParams.Name)
313+
modeStr := "clearnet"
314+
if useTor && n.config.TorProxy != "" {
315+
modeStr = "Tor"
316+
}
317+
n.logger.Infof("Creating chain service for network %s (%s mode)", n.chainParams.Name, modeStr)
246318

247319
// Create chain service
248320
chainService, err := neutrino.NewChainService(neutrinoConfig)
249321
if err != nil {
250-
n.db.Close()
251322
return fmt.Errorf("failed to create chain service: %w", err)
252323
}
253324

@@ -257,31 +328,70 @@ func (n *Node) Start() error {
257328
// Start the chain service
258329
n.logger.Info("Starting chain service...")
259330
if err := n.chainService.Start(); err != nil {
260-
n.db.Close()
261331
return fmt.Errorf("failed to start chain service: %w", err)
262332
}
263-
n.logger.Info("Chain service started successfully")
333+
n.logger.Infof("Chain service started successfully (%s)", modeStr)
264334

265-
// Open rescan state store for persistence
266-
stateStore, err := OpenStateStore(n.config.DataDir, n.logger)
267-
if err != nil {
268-
n.logger.Warnf("Failed to open rescan state store (persistence disabled): %v", err)
269-
// Non-fatal: fall back to in-memory only mode.
270-
stateStore = nil
271-
}
335+
return nil
336+
}
272337

273-
// Create rescan manager with persistence
274-
n.rescanMgr = NewRescanManager(n.chainService, n.logger, stateStore)
338+
// waitForHeaderSync blocks until the chain service reports IsCurrent() or a
339+
// timeout is reached. Uses a generous timeout since mainnet header sync over
340+
// clearnet typically takes 2-10 minutes.
341+
func (n *Node) waitForHeaderSync() error {
342+
const (
343+
timeout = 30 * time.Minute
344+
pollInterval = 5 * time.Second
345+
logInterval = 30 * time.Second
346+
)
347+
348+
start := time.Now()
349+
lastLog := start
350+
lastHeight := int32(-1)
275351

276-
// Start sync monitoring goroutine
277-
go n.monitorSync()
352+
for {
353+
if n.chainService == nil {
354+
return errors.New("chain service is nil")
355+
}
278356

279-
if n.config.PrefetchFilters {
280-
go n.prefetchFilters()
281-
}
357+
if n.chainService.IsCurrent() {
358+
bestBlock, err := n.chainService.BestBlock()
359+
if err == nil {
360+
elapsed := time.Since(start)
361+
n.logger.Infof("Header sync complete: height %d in %s", bestBlock.Height, elapsed.Round(time.Second))
362+
}
363+
return nil
364+
}
282365

283-
n.logger.Info("Neutrino node started")
284-
return nil
366+
// Log progress
367+
now := time.Now()
368+
if now.Sub(lastLog) >= logInterval {
369+
bestBlock, _ := n.chainService.BestBlock()
370+
height := int32(0)
371+
if bestBlock != nil {
372+
height = bestBlock.Height
373+
}
374+
peers := len(n.chainService.Peers())
375+
elapsed := now.Sub(start)
376+
377+
if height > lastHeight && lastHeight >= 0 {
378+
blocksPerSec := float64(height-lastHeight) / logInterval.Seconds()
379+
n.logger.Infof("Header sync: height %d, peers %d, %.0f headers/sec (%s elapsed)",
380+
height, peers, blocksPerSec, elapsed.Round(time.Second))
381+
} else {
382+
n.logger.Infof("Header sync: height %d, peers %d (%s elapsed)",
383+
height, peers, elapsed.Round(time.Second))
384+
}
385+
lastHeight = height
386+
lastLog = now
387+
}
388+
389+
if time.Since(start) >= timeout {
390+
return fmt.Errorf("header sync did not complete within %s", timeout)
391+
}
392+
393+
time.Sleep(pollInterval)
394+
}
285395
}
286396

287397
// Stop gracefully stops the neutrino node.
@@ -677,7 +787,8 @@ func (n *Node) prefetchFilters() {
677787
defer close(n.prefetchDone)
678788

679789
workers := computePrefetchWorkerCount(n.config.PrefetchWorkers)
680-
n.logger.Infof("Background filter prefetch enabled: workers=%d, start_height=%d", workers, n.config.PrefetchStart)
790+
n.logger.Infof("Background filter prefetch enabled: workers=%d, start_height=%d, lookback=%d",
791+
workers, n.config.PrefetchStart, n.config.PrefetchLookback)
681792

682793
for {
683794
select {
@@ -702,6 +813,20 @@ func (n *Node) prefetchFilters() {
702813
continue
703814
}
704815

816+
// Auto-compute prefetch start from lookback if not explicitly set.
817+
// This runs once after initial sync when we know the chain tip.
818+
n.prefetchMu.Lock()
819+
if n.config.PrefetchStart == 0 && n.config.PrefetchLookback > 0 && n.prefetchLastHeight < 0 {
820+
bestBlock, err := n.chainService.BestBlock()
821+
if err == nil && bestBlock.Height > n.config.PrefetchLookback {
822+
computedStart := bestBlock.Height - n.config.PrefetchLookback
823+
n.prefetchLastHeight = computedStart - 1
824+
n.logger.Infof("Prefetch: auto-computed start height %d (tip %d - lookback %d)",
825+
computedStart, bestBlock.Height, n.config.PrefetchLookback)
826+
}
827+
}
828+
n.prefetchMu.Unlock()
829+
705830
n.runPrefetchPass(workers)
706831

707832
// Keep running in the background to fetch filters for newly arrived blocks.

0 commit comments

Comments
 (0)