Skip to content

Commit f305d2a

Browse files
committed
feat(api): expose version and watched-address diagnostics
Add /v1/version, include version and watched_addresses in /v1/status, include watched_addresses and server_version in /v1/rescan/status, and attach X-Neutrino-Version response headers for easier client diagnostics. Changelog: Expose server version and watched-address diagnostics via API metadata endpoints and headers.
1 parent 888d1ff commit f305d2a

8 files changed

Lines changed: 199 additions & 19 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `GET /v1/version` endpoint returning `{"version": "..."}` for lightweight version diagnostics.
13+
- Include `version` and `watched_addresses` fields in `GET /v1/status` responses.
14+
- Include `watched_addresses` and `server_version` in `GET /v1/rescan/status` responses.
15+
- Add `X-Neutrino-Version` response header on JSON API responses.
16+
1017
## [0.10.0] - 2026-04-06
1118

1219
### Added

neutrino_server/cmd/neutrinod/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func main() {
7777
DataDir: *dataDir,
7878
TorProxy: *torProxy,
7979
AddPeers: *addPeers,
80+
Version: version,
8081
MaxPeers: 8,
8182
FilterCacheSize: 100 * 1024 * 1024,
8283
PrefetchFilters: *prefetchFilters,
@@ -103,7 +104,7 @@ func main() {
103104
// Create API handler
104105
apiLogger := backend.Logger("API")
105106
apiLogger.SetLevel(level)
106-
handler := api.NewHandler(node, apiLogger)
107+
handler := api.NewHandler(node, apiLogger, version)
107108

108109
// Set up router
109110
router := mux.NewRouter()

neutrino_server/internal/api/handler.go

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,22 @@ type NodeInterface interface {
3636

3737
// Handler provides REST API endpoints for the neutrino node.
3838
type Handler struct {
39-
node NodeInterface
40-
logger btclog.Logger
39+
node NodeInterface
40+
logger btclog.Logger
41+
version string
4142
}
4243

4344
// NewHandler creates a new API handler.
44-
func NewHandler(node NodeInterface, logger btclog.Logger) *Handler {
45+
func NewHandler(node NodeInterface, logger btclog.Logger, version ...string) *Handler {
46+
resolvedVersion := ""
47+
if len(version) > 0 {
48+
resolvedVersion = version[0]
49+
}
50+
4551
return &Handler{
46-
node: node,
47-
logger: logger,
52+
node: node,
53+
logger: logger,
54+
version: resolvedVersion,
4855
}
4956
}
5057

@@ -55,6 +62,7 @@ func (h *Handler) RegisterRoutes(r *mux.Router) {
5562

5663
// Status
5764
r.HandleFunc("/v1/status", h.handleGetStatus).Methods("GET")
65+
r.HandleFunc("/v1/version", h.handleGetVersion).Methods("GET")
5866

5967
// Block queries
6068
r.HandleFunc("/v1/block/{height}/header", h.handleGetBlockHeader).Methods("GET")
@@ -112,9 +120,21 @@ func (h *Handler) loggingMiddleware(next http.Handler) http.Handler {
112120

113121
func (h *Handler) jsonResponse(w http.ResponseWriter, data any) {
114122
w.Header().Set("Content-Type", "application/json")
123+
if version := h.effectiveVersion(); version != "" {
124+
w.Header().Set("X-Neutrino-Version", version)
125+
}
115126
json.NewEncoder(w).Encode(data)
116127
}
117128

129+
func (h *Handler) effectiveVersion() string {
130+
if h.version != "" {
131+
return h.version
132+
}
133+
134+
status := h.node.GetStatus()
135+
return status.Version
136+
}
137+
118138
func (h *Handler) errorResponse(w http.ResponseWriter, code int, message string) {
119139
w.Header().Set("Content-Type", "application/json")
120140
w.WriteHeader(code)
@@ -124,9 +144,17 @@ func (h *Handler) errorResponse(w http.ResponseWriter, code int, message string)
124144
// Status endpoint
125145
func (h *Handler) handleGetStatus(w http.ResponseWriter, r *http.Request) {
126146
status := h.node.GetStatus()
147+
if status.Version == "" {
148+
status.Version = h.effectiveVersion()
149+
}
127150
h.jsonResponse(w, status)
128151
}
129152

153+
// Version endpoint.
154+
func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
155+
h.jsonResponse(w, map[string]any{"version": h.effectiveVersion()})
156+
}
157+
130158
// Block header endpoint
131159
func (h *Handler) handleGetBlockHeader(w http.ResponseWriter, r *http.Request) {
132160
vars := mux.Vars(r)
@@ -365,6 +393,7 @@ func (h *Handler) handleRescan(w http.ResponseWriter, r *http.Request) {
365393

366394
// Rescan status endpoint
367395
func (h *Handler) handleGetRescanStatus(w http.ResponseWriter, r *http.Request) {
396+
nodeStatus := h.node.GetStatus()
368397
status := h.node.RescanStatus()
369398
h.jsonResponse(w, map[string]any{
370399
"in_progress": status.InProgress,
@@ -373,6 +402,8 @@ func (h *Handler) handleGetRescanStatus(w http.ResponseWriter, r *http.Request)
373402
"last_start_height": status.LastStartHeight,
374403
"last_scanned_tip": status.LastScannedTip,
375404
"last_error": status.LastError,
405+
"watched_addresses": nodeStatus.WatchedAddresses,
406+
"server_version": h.effectiveVersion(),
376407
})
377408
}
378409

neutrino_server/internal/api/handler_test.go

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ type mockNode struct{}
2121

2222
func (m *mockNode) GetStatus() neutrino.Status {
2323
return neutrino.Status{
24-
Synced: true,
25-
BlockHeight: 8543,
26-
FilterHeight: 8543,
27-
Peers: 1,
24+
Synced: true,
25+
BlockHeight: 8543,
26+
FilterHeight: 8543,
27+
Peers: 1,
28+
Version: testVersion,
29+
WatchedAddresses: 3,
2830
}
2931
}
3032

@@ -78,11 +80,13 @@ func (m *mockNode) RescanStatus() neutrino.RescanStatus {
7880
return neutrino.RescanStatus{}
7981
}
8082

83+
const testVersion = "v0.10.1-test"
84+
8185
func TestHandleGetStatus(t *testing.T) {
8286
backend := btclog.NewBackend(os.Stdout)
8387
logger := backend.Logger("TEST")
8488

85-
handler := NewHandler(&mockNode{}, logger)
89+
handler := NewHandler(&mockNode{}, logger, testVersion)
8690

8791
router := mux.NewRouter()
8892
router.HandleFunc("/v1/status", handler.handleGetStatus).Methods("GET")
@@ -119,6 +123,53 @@ func TestHandleGetStatus(t *testing.T) {
119123
if response.Peers != 1 {
120124
t.Errorf("expected peers=1, got %v", response.Peers)
121125
}
126+
127+
if response.Version != testVersion {
128+
t.Errorf("expected version=%s, got %v", testVersion, response.Version)
129+
}
130+
131+
if response.WatchedAddresses != 3 {
132+
t.Errorf("expected watched_addresses=3, got %v", response.WatchedAddresses)
133+
}
134+
135+
if got := rr.Header().Get("X-Neutrino-Version"); got != testVersion {
136+
t.Errorf("expected X-Neutrino-Version=%s, got %v", testVersion, got)
137+
}
138+
}
139+
140+
func TestHandleGetVersion(t *testing.T) {
141+
backend := btclog.NewBackend(os.Stdout)
142+
logger := backend.Logger("TEST")
143+
144+
handler := NewHandler(&mockNode{}, logger, testVersion)
145+
146+
router := mux.NewRouter()
147+
router.HandleFunc("/v1/version", handler.handleGetVersion).Methods("GET")
148+
149+
req, err := http.NewRequest("GET", "/v1/version", nil)
150+
if err != nil {
151+
t.Fatal(err)
152+
}
153+
154+
rr := httptest.NewRecorder()
155+
router.ServeHTTP(rr, req)
156+
157+
if status := rr.Code; status != http.StatusOK {
158+
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
159+
}
160+
161+
var response map[string]any
162+
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
163+
t.Fatalf("could not decode response: %v", err)
164+
}
165+
166+
if response["version"] != testVersion {
167+
t.Errorf("expected version=%s, got %v", testVersion, response["version"])
168+
}
169+
170+
if got := rr.Header().Get("X-Neutrino-Version"); got != testVersion {
171+
t.Errorf("expected X-Neutrino-Version=%s, got %v", testVersion, got)
172+
}
122173
}
123174

124175
func TestHandleBroadcastTransaction_InvalidJSON(t *testing.T) {
@@ -584,6 +635,12 @@ func TestHandleGetRescanStatus_NotInProgress(t *testing.T) {
584635
if _, ok := response["last_scanned_tip"]; !ok {
585636
t.Error("expected last_scanned_tip in response")
586637
}
638+
if watched, ok := response["watched_addresses"].(float64); !ok || int(watched) != 3 {
639+
t.Errorf("expected watched_addresses=3, got %v", response["watched_addresses"])
640+
}
641+
if version, ok := response["server_version"].(string); !ok || version != testVersion {
642+
t.Errorf("expected server_version=%s, got %v", testVersion, response["server_version"])
643+
}
587644
}
588645

589646
func TestLoggingMiddleware_Success(t *testing.T) {

neutrino_server/internal/neutrino/node.go

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type Config struct {
3636
DataDir string
3737
TorProxy string
3838
AddPeers string
39+
Version string
3940
MaxPeers int
4041
BanDuration time.Duration
4142
FilterCacheSize int
@@ -51,6 +52,7 @@ type Config struct {
5152
// Node wraps a neutrino ChainService with additional functionality.
5253
type Node struct {
5354
config *Config
55+
version string
5456
chainParams *chaincfg.Params
5557
chainService *neutrino.ChainService
5658
rescanMgr *RescanManager
@@ -89,10 +91,12 @@ type Transaction struct {
8991

9092
// Status represents the current node status.
9193
type Status struct {
92-
Synced bool `json:"synced"`
93-
BlockHeight int32 `json:"block_height"`
94-
FilterHeight int32 `json:"filter_height"`
95-
Peers int `json:"peers"`
94+
Synced bool `json:"synced"`
95+
BlockHeight int32 `json:"block_height"`
96+
FilterHeight int32 `json:"filter_height"`
97+
Peers int `json:"peers"`
98+
Version string `json:"version"`
99+
WatchedAddresses int `json:"watched_addresses"`
96100
}
97101

98102
// NewNode creates a new neutrino node.
@@ -124,11 +128,15 @@ func NewNode(config *Config) (*Node, error) {
124128

125129
node := &Node{
126130
config: config,
131+
version: strings.TrimSpace(config.Version),
127132
chainParams: chainParams,
128133
logger: logger,
129134
prefetchQuit: make(chan struct{}),
130135
prefetchDone: make(chan struct{}),
131136
}
137+
if node.version == "" {
138+
node.version = "dev"
139+
}
132140
node.prefetchLastHeight = config.PrefetchStart - 1
133141

134142
return node, nil
@@ -436,11 +444,18 @@ func (n *Node) GetStatus() Status {
436444
peers = len(n.chainService.Peers())
437445
}
438446

447+
watchedAddresses := 0
448+
if n.rescanMgr != nil {
449+
watchedAddresses = n.rescanMgr.WatchedAddressCount()
450+
}
451+
439452
return Status{
440-
Synced: n.synced,
441-
BlockHeight: n.blockHeight,
442-
FilterHeight: n.filterHeight,
443-
Peers: peers,
453+
Synced: n.synced,
454+
BlockHeight: n.blockHeight,
455+
FilterHeight: n.filterHeight,
456+
Peers: peers,
457+
Version: n.version,
458+
WatchedAddresses: watchedAddresses,
444459
}
445460
}
446461

neutrino_server/internal/neutrino/node_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,35 @@ func TestGetStatus(t *testing.T) {
196196
if status.Peers != 0 {
197197
t.Errorf("expected 0 peers, got %d", status.Peers)
198198
}
199+
200+
if status.Version != "dev" {
201+
t.Errorf("expected default version dev, got %q", status.Version)
202+
}
203+
204+
if status.WatchedAddresses != 0 {
205+
t.Errorf("expected watched address count 0, got %d", status.WatchedAddresses)
206+
}
207+
}
208+
209+
func TestGetStatusIncludesConfiguredVersion(t *testing.T) {
210+
backend := btclog.NewBackend(os.Stdout)
211+
212+
node, err := NewNode(&Config{
213+
Network: "regtest",
214+
DataDir: "/tmp/test",
215+
Version: "v1.2.3-test",
216+
FilterCacheSize: 4096,
217+
Logger: backend,
218+
LogLevel: "info",
219+
})
220+
if err != nil {
221+
t.Fatalf("NewNode() failed: %v", err)
222+
}
223+
224+
status := node.GetStatus()
225+
if status.Version != "v1.2.3-test" {
226+
t.Errorf("expected configured version, got %q", status.Version)
227+
}
199228
}
200229

201230
func TestComputePrefetchWorkerCount(t *testing.T) {

neutrino_server/internal/neutrino/rescan.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,13 @@ func (r *RescanManager) IsRescanInProgress() bool {
199199
return r.rescanInProgress.Load() > 0
200200
}
201201

202+
// WatchedAddressCount returns the number of currently watched addresses.
203+
func (r *RescanManager) WatchedAddressCount() int {
204+
r.mu.RLock()
205+
defer r.mu.RUnlock()
206+
return len(r.watchedAddrs)
207+
}
208+
202209
// GetRescanStatus returns the current detailed rescan status.
203210
func (r *RescanManager) GetRescanStatus() RescanStatus {
204211
r.statusMu.RLock()

neutrino_server/internal/neutrino/rescan_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,39 @@ func TestWatchAddress(t *testing.T) {
111111
}
112112
}
113113

114+
func TestWatchedAddressCount(t *testing.T) {
115+
backend := btclog.NewBackend(nil)
116+
logger := backend.Logger("TEST")
117+
118+
mgr := &RescanManager{
119+
chainParams: &chaincfg.MainNetParams,
120+
logger: logger,
121+
watchedAddrs: make(map[string]btcutil.Address),
122+
utxoSet: make(map[string]UTXO),
123+
}
124+
125+
if got := mgr.WatchedAddressCount(); got != 0 {
126+
t.Fatalf("expected empty watched count=0, got %d", got)
127+
}
128+
129+
if err := mgr.WatchAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"); err != nil {
130+
t.Fatalf("failed to watch address: %v", err)
131+
}
132+
133+
if got := mgr.WatchedAddressCount(); got != 1 {
134+
t.Fatalf("expected watched count=1, got %d", got)
135+
}
136+
137+
// Duplicate should not increase count.
138+
if err := mgr.WatchAddress("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"); err != nil {
139+
t.Fatalf("failed to watch duplicate address: %v", err)
140+
}
141+
142+
if got := mgr.WatchedAddressCount(); got != 1 {
143+
t.Fatalf("expected watched count to remain 1, got %d", got)
144+
}
145+
}
146+
114147
// TestAddUTXO tests adding UTXOs to the set.
115148
func TestAddUTXO(t *testing.T) {
116149
backend := btclog.NewBackend(nil)

0 commit comments

Comments
 (0)