Skip to content

Commit aea7210

Browse files
committed
feat: Persistent state across restarts, incremental rescans, and logging
1 parent 846ed3a commit aea7210

13 files changed

Lines changed: 1476 additions & 88 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+
- **Persistent state across restarts** (`rescan_state.db`): Watched addresses, UTXO set, and rescan metadata (last scanned tip, start height) are now persisted to a separate bbolt database. On restart, the server restores its previous state so UTXOs are available immediately without re-scanning. The state store uses three buckets (`watched_addrs`, `utxo_set`, `rescan_meta`) and is optional (nil = no persistence) for backward compatibility with tests.
13+
- **Incremental rescan**: When a rescan is requested with a `start_height` within the already-scanned range, the scan starts from `LastScannedTip+1` instead of re-scanning the entire range. If already up-to-date, returns immediately. This avoids redundant 52k+ block rescans after restarts.
14+
- **Rescan fallback to watched addresses**: `POST /v1/rescan` with an empty `addresses` field now falls back to all previously watched addresses instead of silently doing nothing.
15+
- **HTTP request/response logging middleware**: Every API call is logged with method, path, status code, and duration. 4xx/5xx responses log at WARN level; 2xx at INFO level.
16+
- **Rescan progress logging**: During block scanning, progress is logged every 10 seconds with blocks scanned/total, percentage, current height, blocks/sec, estimated time remaining, and filter match count. A final summary includes total blocks, duration, speed, filter matches, and detailed UTXO accounting (found/added/removed).
17+
- **Rescan handler request logging**: `POST /v1/rescan` now logs start_height, address count, and outpoint count at INFO level.
18+
1019
## [0.8.0] - 2026-03-17
1120

1221
### Added

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ docker run -d \
4141
-p 8334:8334 \
4242
-v neutrino-data:/data/neutrino \
4343
-e NETWORK=regtest \
44-
-e CONNECT_PEERS=bitcoin-node:18444 \
44+
-e ADD_PEERS=bitcoin-node:18444 \
4545
-e LOG_LEVEL=debug \
4646
ghcr.io/m0wer/neutrino-api
4747
```
@@ -92,7 +92,7 @@ Anyone can reproduce and verify a release locally with one command:
9292
| `LISTEN_ADDR` | `0.0.0.0:8334` | REST API listen address |
9393
| `DATA_DIR` | `/data/neutrino` | Data directory for headers and filters |
9494
| `LOG_LEVEL` | `info` | Log level (trace, debug, info, warn, error) |
95-
| `CONNECT_PEERS` | | Comma-separated list of peers (e.g., `node1:8333,node2:8333`) |
95+
| `ADD_PEERS` | | Comma-separated list of preferred peers (e.g., `node1:8333,node2:8333`) while still allowing peer discovery |
9696
| `TOR_PROXY` | | Tor SOCKS5 proxy address (e.g., `127.0.0.1:9050`) |
9797
| `MAX_PEERS` | `8` | Maximum number of peers to connect to |
9898

@@ -104,7 +104,7 @@ Anyone can reproduce and verify a release locally with one command:
104104
--listen=0.0.0.0:8334 \
105105
--datadir=/data/neutrino \
106106
--loglevel=info \
107-
--connect=peer1:8333,peer2:8333 \
107+
--addpeer=peer1:8333,peer2:8333 \
108108
--torproxy=127.0.0.1:9050 \
109109
--maxpeers=8
110110
```

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ services:
4848
- LISTEN_ADDR=0.0.0.0:8334
4949
- DATA_DIR=/data/neutrino
5050
- LOG_LEVEL=debug
51-
- CONNECT_PEERS=bitcoin:18444
51+
- ADD_PEERS=bitcoin:18444
5252
- MAX_PEERS=8
5353
ports:
5454
- "8334:8334"

neutrino_server/cmd/neutrinod/main.go

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"net/http"
1212
"os"
1313
"os/signal"
14+
"strconv"
1415
"syscall"
1516
"time"
1617

@@ -32,9 +33,11 @@ func main() {
3233
listen := flag.String("listen", getEnv("LISTEN_ADDR", "0.0.0.0:8334"), "REST API listen address")
3334
dataDir := flag.String("datadir", getEnv("DATA_DIR", "/data/neutrino"), "Data directory for headers and filters")
3435
logLevel := flag.String("loglevel", getEnv("LOG_LEVEL", "info"), "Log level (trace, debug, info, warn, error)")
35-
connectPeers := flag.String("connect", getEnv("CONNECT_PEERS", ""), "Comma-separated list of peers to connect to")
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")
39+
prefetchWorkers := flag.Int("prefetchworkers", getEnvInt("PREFETCH_WORKERS", 0), "Number of workers for background filter prefetch (0=auto)")
40+
prefetchStart := flag.Int("prefetchstart", getEnvInt("PREFETCH_START", 0), "Start height for background filter prefetch")
3841
showVersion := flag.Bool("version", false, "Show version and exit")
3942
flag.Parse()
4043

@@ -65,14 +68,17 @@ func main() {
6568

6669
// Create neutrino node
6770
nodeConfig := &neutrino.Config{
68-
Network: *network,
69-
DataDir: *dataDir,
70-
TorProxy: *torProxy,
71-
ConnectPeers: *connectPeers,
72-
AddPeers: *addPeers,
73-
MaxPeers: 8,
74-
Logger: backend,
75-
LogLevel: *logLevel,
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,
7682
}
7783

7884
node, err := neutrino.NewNode(nodeConfig)
@@ -142,3 +148,31 @@ func getEnv(key, defaultValue string) string {
142148
}
143149
return defaultValue
144150
}
151+
152+
// getEnvBool returns a bool env var or a default value.
153+
func getEnvBool(key string, defaultValue bool) bool {
154+
value := os.Getenv(key)
155+
if value == "" {
156+
return defaultValue
157+
}
158+
159+
parsed, err := strconv.ParseBool(value)
160+
if err != nil {
161+
return defaultValue
162+
}
163+
return parsed
164+
}
165+
166+
// getEnvInt returns an int env var or a default value.
167+
func getEnvInt(key string, defaultValue int) int {
168+
value := os.Getenv(key)
169+
if value == "" {
170+
return defaultValue
171+
}
172+
173+
parsed, err := strconv.Atoi(value)
174+
if err != nil {
175+
return defaultValue
176+
}
177+
return parsed
178+
}

neutrino_server/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/btcsuite/btcwallet/walletdb v1.3.5
1111
github.com/gorilla/mux v1.8.1
1212
github.com/lightninglabs/neutrino v0.16.0
13+
go.etcd.io/bbolt v1.3.5-0.20200615073812-232d8fc87f50
1314
golang.org/x/net v0.48.0
1415
)
1516

@@ -28,7 +29,6 @@ require (
2829
github.com/lightningnetwork/lnd/clock v1.0.1 // indirect
2930
github.com/lightningnetwork/lnd/queue v1.0.1 // indirect
3031
github.com/lightningnetwork/lnd/ticker v1.0.0 // indirect
31-
go.etcd.io/bbolt v1.3.5-0.20200615073812-232d8fc87f50 // indirect
3232
golang.org/x/crypto v0.46.0 // indirect
3333
golang.org/x/sys v0.39.0 // indirect
3434
)

neutrino_server/internal/api/handler.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"errors"
1111
"net/http"
1212
"strconv"
13+
"time"
1314

1415
"github.com/btcsuite/btcd/chaincfg/chainhash"
1516
"github.com/btcsuite/btcd/wire"
@@ -49,6 +50,9 @@ func NewHandler(node NodeInterface, logger btclog.Logger) *Handler {
4950

5051
// RegisterRoutes registers all API routes.
5152
func (h *Handler) RegisterRoutes(r *mux.Router) {
53+
// Add request logging middleware.
54+
r.Use(h.loggingMiddleware)
55+
5256
// Status
5357
r.HandleFunc("/v1/status", h.handleGetStatus).Methods("GET")
5458

@@ -76,6 +80,34 @@ func (h *Handler) RegisterRoutes(r *mux.Router) {
7680
r.HandleFunc("/v1/peers", h.handleGetPeers).Methods("GET")
7781
}
7882

83+
// statusRecorder wraps http.ResponseWriter to capture the status code.
84+
type statusRecorder struct {
85+
http.ResponseWriter
86+
statusCode int
87+
}
88+
89+
func (rec *statusRecorder) WriteHeader(code int) {
90+
rec.statusCode = code
91+
rec.ResponseWriter.WriteHeader(code)
92+
}
93+
94+
// loggingMiddleware logs every HTTP request with method, path, status, and duration.
95+
func (h *Handler) loggingMiddleware(next http.Handler) http.Handler {
96+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
97+
start := time.Now()
98+
rec := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
99+
next.ServeHTTP(rec, r)
100+
duration := time.Since(start)
101+
102+
// Use Warn level for errors (4xx/5xx), Info for everything else.
103+
if rec.statusCode >= 400 {
104+
h.logger.Warnf("%s %s -> %d (%s)", r.Method, r.URL.Path, rec.statusCode, duration.Round(time.Millisecond))
105+
} else {
106+
h.logger.Infof("%s %s -> %d (%s)", r.Method, r.URL.Path, rec.statusCode, duration.Round(time.Millisecond))
107+
}
108+
})
109+
}
110+
79111
// Response helpers
80112

81113
func (h *Handler) jsonResponse(w http.ResponseWriter, data any) {
@@ -316,6 +348,9 @@ func (h *Handler) handleRescan(w http.ResponseWriter, r *http.Request) {
316348
return
317349
}
318350

351+
h.logger.Infof("Rescan requested: start_height=%d, addresses=%d, outpoints=%d",
352+
req.StartHeight, len(req.Addresses), len(req.Outpoints))
353+
319354
// Start rescan in background goroutine to not block HTTP response
320355
go func() {
321356
if err := h.node.Rescan(req.StartHeight, req.Addresses); err != nil {

neutrino_server/internal/api/handler_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,3 +585,54 @@ func TestHandleGetRescanStatus_NotInProgress(t *testing.T) {
585585
t.Error("expected last_scanned_tip in response")
586586
}
587587
}
588+
589+
func TestLoggingMiddleware_Success(t *testing.T) {
590+
backend := btclog.NewBackend(os.Stdout)
591+
logger := backend.Logger("TEST")
592+
593+
handler := NewHandler(&mockNode{}, logger)
594+
595+
router := mux.NewRouter()
596+
handler.RegisterRoutes(router)
597+
598+
req, err := http.NewRequest("GET", "/v1/status", nil)
599+
if err != nil {
600+
t.Fatal(err)
601+
}
602+
603+
rr := httptest.NewRecorder()
604+
router.ServeHTTP(rr, req)
605+
606+
if status := rr.Code; status != http.StatusOK {
607+
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
608+
}
609+
610+
// Verify the response is valid JSON (middleware should not interfere)
611+
var response neutrino.Status
612+
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
613+
t.Fatalf("could not decode response: %v", err)
614+
}
615+
}
616+
617+
func TestLoggingMiddleware_Error(t *testing.T) {
618+
backend := btclog.NewBackend(os.Stdout)
619+
logger := backend.Logger("TEST")
620+
621+
handler := NewHandler(&mockNode{}, logger)
622+
623+
router := mux.NewRouter()
624+
handler.RegisterRoutes(router)
625+
626+
// Invalid body should trigger 400
627+
req, err := http.NewRequest("POST", "/v1/rescan", bytes.NewBufferString("invalid json"))
628+
if err != nil {
629+
t.Fatal(err)
630+
}
631+
632+
rr := httptest.NewRecorder()
633+
router.ServeHTTP(rr, req)
634+
635+
if status := rr.Code; status != http.StatusBadRequest {
636+
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusBadRequest)
637+
}
638+
}

0 commit comments

Comments
 (0)