Skip to content

Commit a746463

Browse files
committed
feat(mempool): add watched-only mempool tracker
Subscribe to peers, fetch announced txs, and track unconfirmed UTXOs and spends for watched addresses. Surface them via /v1/utxos (height 0), /v1/utxo spend overlay, /v1/tx, and /v1/status. Toggle with MEMPOOL_ENABLED (default true). Bumps the neutrino fork to 0d5f911.
1 parent 221a295 commit a746463

12 files changed

Lines changed: 1883 additions & 20 deletions

File tree

CHANGELOG.md

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

1010
### Added
1111

12+
- **Watched-only mempool tracker (`MEMPOOL_ENABLED`).** The daemon now
13+
subscribes to every connected peer's incoming P2P messages, fetches each
14+
announced transaction, and records the ones that pay or spend a watched
15+
address. Unconfirmed UTXOs are surfaced in the existing `/v1/utxos`
16+
endpoint with `height: 0`, unconfirmed spends are overlaid on
17+
`/v1/utxo/{txid}/{vout}` via new `mempool_*` fields, and watched mempool
18+
txs can be fetched verbatim from a new `/v1/tx/{txid}` endpoint. Tracker
19+
state is in-memory only; after each successful rescan pass it evicts
20+
precisely the entries that just confirmed on-chain (rather than wiping
21+
the whole view, which would drop still-unconfirmed entries because
22+
mempool peers do not reliably re-announce already-acked txs). A 14-day
23+
TTL sweeps stale entries; RBF replacements automatically evict the
24+
prior entry.
25+
- New config: `MEMPOOL_ENABLED` / `--mempool` (default: `true`) toggles
26+
the tracker.
27+
- New request flag: `include_mempool` on `/v1/utxos` (request body) and
28+
on `/v1/utxo/{txid}/{vout}` (query string), default `true`. Set to
29+
`false` to receive only the chain-only view.
30+
- `/v1/status` gains `mempool_enabled` and (when enabled) a `mempool`
31+
object reporting tracker counts (`entries`, `utxos`, `spends`,
32+
`peers`).
33+
- Privacy model: the daemon asks every peer for every announced tx
34+
rather than only those matching a local heuristic, so peers cannot
35+
learn which addresses the operator cares about. The bandwidth cost
36+
is small because we only fetch the tx body once per inv (deduped by
37+
txid across peers) and discard txs that don't touch a watched
38+
script. Backed by a fork patch on `lightninglabs/neutrino` that
39+
stops dropping `MSG_TX` advertisements when the local node hasn't
40+
asked the peer to relay txs.
1241
- Add a production-ready `systemd` service example in README for running the
1342
`neutrinod` binary directly on Linux hosts (including Raspberry Pi), with a
1443
signet-oriented configuration example.
@@ -24,6 +53,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2453
`windows-amd64` outputs.
2554
- README now documents a signet Docker run example using
2655
`LISTEN_ADDR=0.0.0.0:38334` and host port mapping `38334:38334`.
56+
- Bump the `lightninglabs/neutrino` fork to
57+
`0d5f911` (`m0wer/neutrino`), which gates the relaxed `MSG_TX`
58+
relay behaviour behind the new `Config.MempoolEnabled` flag instead
59+
of unconditionally enabling it.
2760

2861
## [1.2.0] - 2026-04-30
2962

README.md

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ Anyone can reproduce and verify a release locally with one command:
112112
| `CFILTER_CDN_URL` | | Override block-dn base URL for compact filter CDN downloads |
113113
| `AUTO_SYNC_WATCHED` | `true` | Continuously scan new blocks for watched addresses in the background, keeping the UTXO set up-to-date so `/v1/utxos` is instant. Reacts to block-connected notifications from the chain service in real time |
114114
| `AUTO_SYNC_INTERVAL_SEC` | `30` | Fallback poll interval (in seconds) used while waiting for initial header sync, and as a safety net if block-notification subscription is unavailable. Only used when `AUTO_SYNC_WATCHED=true` |
115+
| `MEMPOOL_ENABLED` | `true` | Enable the watched-only mempool tracker. The daemon subscribes to every connected peer's incoming `inv` messages, fetches each announced tx, and records the ones that pay or spend a watched address. Unconfirmed UTXOs are surfaced in `/v1/utxos` (with `height: 0`) and unconfirmed spends are overlaid on `/v1/utxo/{txid}/{vout}`. Disable with `MEMPOOL_ENABLED=false` to keep the chain-only behaviour |
115116
| `MAX_PEERS` | `8` | Maximum number of peers to connect to |
116117
| `NO_AUTH` | `false` | Disable TLS and token authentication (for development/regtest) |
117118

@@ -128,6 +129,7 @@ Anyone can reproduce and verify a release locally with one command:
128129
--clearnet-initial-sync=true \
129130
--cfilter-cdn-auto=true \
130131
--maxpeers=8 \
132+
--mempool=true \
131133
--no-auth # Disable TLS + auth (dev/regtest only)
132134
# --reset-auth # Regenerate TLS cert and auth token, then exit
133135
```
@@ -238,10 +240,19 @@ Response:
238240
"synced": true,
239241
"block_height": 820000,
240242
"filter_height": 820000,
241-
"peers": 8
243+
"peers": 8,
244+
"mempool_enabled": true,
245+
"mempool": {
246+
"entries": 4,
247+
"utxos": 3,
248+
"spends": 1,
249+
"peers": 8
250+
}
242251
}
243252
```
244253

254+
The `mempool` object is omitted when `MEMPOOL_ENABLED=false`. `entries` counts watched mempool txs, `utxos` counts unconfirmed outputs paying watched addresses, `spends` counts unconfirmed spends of watched outpoints, and `peers` reflects how many connected peers the tracker is subscribed to.
255+
245256
### Block Header
246257

247258
Get block header by height:
@@ -305,10 +316,15 @@ curl -X POST http://localhost:8334/v1/rescan \
305316
"addresses": ["12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S"]
306317
}'
307318

308-
# Then query UTXOs
319+
# Then query UTXOs (mempool entries are included by default)
309320
curl -X POST http://localhost:8334/v1/utxos \
310321
-H "Content-Type: application/json" \
311322
-d '{"addresses": ["12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S"]}'
323+
324+
# Suppress unconfirmed mempool entries
325+
curl -X POST http://localhost:8334/v1/utxos \
326+
-H "Content-Type: application/json" \
327+
-d '{"addresses": ["12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S"], "include_mempool": false}'
312328
```
313329

314330
> **Note:** When `AUTO_SYNC_WATCHED=true` (the default), the daemon
@@ -318,6 +334,13 @@ curl -X POST http://localhost:8334/v1/utxos \
318334
> without requiring another `/v1/rescan` — the daemon stays caught up
319335
> in real time and also re-syncs on every restart.
320336
337+
> **Mempool:** When `MEMPOOL_ENABLED=true` (the default), unconfirmed
338+
> outputs paying a watched address are returned in the same `utxos`
339+
> array with `height: 0`. Set `include_mempool: false` in the request
340+
> body to opt out and receive only confirmed UTXOs. If the same outpoint
341+
> appears in both sets (e.g., the mempool tracker has not yet evicted a
342+
> just-confirmed tx), the confirmed entry wins.
343+
321344
Response:
322345
```json
323346
{
@@ -329,6 +352,14 @@ Response:
329352
"address": "12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S",
330353
"scriptpubkey": "410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac",
331354
"height": 9
355+
},
356+
{
357+
"txid": "ea44e97271691990157559d0bdd9959e02790c34db6c006d779e82fa5aee708e",
358+
"vout": 1,
359+
"value": 12345,
360+
"address": "12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S",
361+
"scriptpubkey": "76a914...",
362+
"height": 0
332363
}
333364
]
334365
}
@@ -364,12 +395,50 @@ Response for spent UTXO:
364395
}
365396
```
366397

398+
Response for an on-chain unspent UTXO that has an unconfirmed spend in the
399+
mempool (when `MEMPOOL_ENABLED=true`, default):
400+
```json
401+
{
402+
"unspent": true,
403+
"value": 11516,
404+
"scriptpubkey": "001481f291ca5498ec941b014fff4719201ba68939d5",
405+
"mempool_spending_txid": "ccccdddd...",
406+
"mempool_spending_input": 0,
407+
"mempool_spend_first_seen": 1714501234
408+
}
409+
```
410+
411+
Append `?include_mempool=false` to suppress the mempool overlay and receive
412+
only the on-chain status. A confirmed spend always takes precedence over
413+
any tracked mempool spend.
414+
367415
**Important Notes**:
368416
- The `address` parameter is **required**. Compact block filters (BIP158) work by matching on scripts, not transaction IDs. Without the address, filter matching cannot work correctly.
369417
- Specifying a `start_height` parameter is **highly recommended** for performance. Set it to the block height where the UTXO was created (or slightly before). Without it, the scan could take a very long time as it scans from the provided height to the current chain tip.
370418
- The `start_height` means "start scanning FROM this height going FORWARD to the chain tip", not backwards.
371419
- Performance scales with the scan range: scanning 1 block takes ~0.01s, scanning 100 blocks takes ~0.5s, scanning 10,000+ blocks can take minutes.
372420

421+
### Get Transaction (mempool only)
422+
423+
Fetch a serialized transaction by txid. With `MEMPOOL_ENABLED=true` (default)
424+
the daemon returns the watched mempool tx if it has been observed; for any
425+
other txid it responds with `501 Not Implemented` because compact block
426+
filters do not allow looking up arbitrary historical transactions without a
427+
full block download.
428+
429+
```bash
430+
curl http://localhost:8334/v1/tx/<txid>
431+
```
432+
433+
Response when the tx is in the watched mempool:
434+
```json
435+
{
436+
"txid": "ccccdddd...",
437+
"hex": "0200000001...",
438+
"mempool": true
439+
}
440+
```
441+
373442
### Rescan
374443

375444
Trigger a blockchain rescan from a specific height:

neutrino_server/cmd/neutrinod/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func main() {
4646
cfilterCDNURL := flag.String("cfilter-cdn-url", getEnv("CFILTER_CDN_URL", ""), "Override block-dn base URL for compact filter CDN downloads")
4747
autoSyncWatched := flag.Bool("auto-sync-watched", getEnvBool("AUTO_SYNC_WATCHED", true), "Continuously scan new blocks for watched addresses in the background, keeping the UTXO set up-to-date so /v1/utxos is instant")
4848
autoSyncIntervalSec := flag.Int("auto-sync-interval", getEnvInt("AUTO_SYNC_INTERVAL_SEC", 30), "Seconds between auto-sync polling passes for new blocks (only used when --auto-sync-watched is enabled)")
49+
mempoolEnabled := flag.Bool("mempool", getEnvBool("MEMPOOL_ENABLED", true), "Enable watched-only mempool tracking: relay tx invs from peers and track unconfirmed transactions matching watched addresses")
4950
noAuth := flag.Bool("no-auth", getEnvBool("NO_AUTH", false), "Disable TLS and token authentication (for development/regtest)")
5051
resetAuth := flag.Bool("reset-auth", false, "Regenerate TLS cert and auth token, clear watched addresses, then exit")
5152
showVersion := flag.Bool("version", false, "Show version and exit")
@@ -131,6 +132,7 @@ func main() {
131132
CFilterCDNURL: *cfilterCDNURL,
132133
AutoSyncWatched: *autoSyncWatched,
133134
AutoSyncInterval: time.Duration(*autoSyncIntervalSec) * time.Second,
135+
MempoolEnabled: *mempoolEnabled,
134136
Logger: backend,
135137
LogLevel: *logLevel,
136138
}

neutrino_server/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ require (
1414
golang.org/x/net v0.48.0
1515
)
1616

17-
replace github.com/lightninglabs/neutrino => github.com/m0wer/neutrino v0.0.0-20260409110914-c1b598b97446
17+
replace github.com/lightninglabs/neutrino => github.com/m0wer/neutrino v0.0.0-20260522161657-0d5f911e647c
1818

1919
require (
2020
github.com/aead/siphash v1.0.1 // indirect

neutrino_server/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ github.com/lightningnetwork/lnd/queue v1.0.1 h1:jzJKcTy3Nj5lQrooJ3aaw9Lau3I0IwvQ
9191
github.com/lightningnetwork/lnd/queue v1.0.1/go.mod h1:vaQwexir73flPW43Mrm7JOgJHmcEFBWWSl9HlyASoms=
9292
github.com/lightningnetwork/lnd/ticker v1.0.0 h1:S1b60TEGoTtCe2A0yeB+ecoj/kkS4qpwh6l+AkQEZwU=
9393
github.com/lightningnetwork/lnd/ticker v1.0.0/go.mod h1:iaLXJiVgI1sPANIF2qYYUJXjoksPNvGNYowB8aRbpX0=
94-
github.com/m0wer/neutrino v0.0.0-20260409110914-c1b598b97446 h1:c6MpEEzMXuQaIZw+GkvrqcyQG5QceDEUEKBBkA9xU5A=
95-
github.com/m0wer/neutrino v0.0.0-20260409110914-c1b598b97446/go.mod h1:fNjnbuSPw4lRsVAzvjC1JG7IE7rqae/mbek2tNkN/Dw=
94+
github.com/m0wer/neutrino v0.0.0-20260522161657-0d5f911e647c h1:DEv0lannTrzm70LLj/svu0EgBlb2JN359QB9wMs/ayI=
95+
github.com/m0wer/neutrino v0.0.0-20260522161657-0d5f911e647c/go.mod h1:tcwCgRTGWcaua0L/xzdwllW8eslHDbux4XkiYsivvHE=
9696
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
9797
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
9898
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=

neutrino_server/internal/api/handler.go

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ type NodeInterface interface {
3232
Rescan(startHeight int32, addresses []string) error
3333
IsRescanInProgress() bool
3434
RescanStatus() neutrino.RescanStatus
35+
36+
// Mempool — return zero values when the tracker is disabled.
37+
GetMempoolUTXOs(addresses []string) []neutrino.MempoolUTXO
38+
GetMempoolSpend(txid string, vout uint32) (neutrino.MempoolSpend, bool)
39+
GetMempoolTx(txid string) (*wire.MsgTx, bool)
40+
MempoolStats() neutrino.MempoolStats
3541
}
3642

3743
// Handler provides REST API endpoints for the neutrino node.
@@ -210,10 +216,25 @@ func (h *Handler) handleGetTransaction(w http.ResponseWriter, r *http.Request) {
210216
vars := mux.Vars(r)
211217
txid := vars["txid"]
212218

213-
// Neutrino doesn't store full transactions by default
214-
// This would require fetching from a peer or having received it
219+
// Mempool tracker holds the full tx for every watched unconfirmed
220+
// entry; serve those without needing a block download.
221+
if tx, ok := h.node.GetMempoolTx(txid); ok {
222+
var buf bytes.Buffer
223+
if err := tx.Serialize(&buf); err != nil {
224+
h.errorResponse(w, http.StatusInternalServerError, "failed to serialize transaction")
225+
return
226+
}
227+
h.jsonResponse(w, map[string]any{
228+
"txid": txid,
229+
"hex": hex.EncodeToString(buf.Bytes()),
230+
"mempool": true,
231+
})
232+
return
233+
}
234+
235+
// Confirmed-tx lookup is unimplemented — neutrino doesn't store full
236+
// blocks/txs by default.
215237
h.errorResponse(w, http.StatusNotImplemented, "transaction lookup requires full block download")
216-
_ = txid
217238
}
218239

219240
// Broadcast transaction endpoint
@@ -255,7 +276,8 @@ func (h *Handler) handleBroadcastTransaction(w http.ResponseWriter, r *http.Requ
255276
// UTXOs endpoint
256277
func (h *Handler) handleGetUTXOs(w http.ResponseWriter, r *http.Request) {
257278
var req struct {
258-
Addresses []string `json:"addresses"`
279+
Addresses []string `json:"addresses"`
280+
IncludeMempool *bool `json:"include_mempool,omitempty"`
259281
}
260282

261283
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -269,11 +291,47 @@ func (h *Handler) handleGetUTXOs(w http.ResponseWriter, r *http.Request) {
269291
return
270292
}
271293

294+
// include_mempool defaults to true; clients opt out by sending false.
295+
includeMempool := true
296+
if req.IncludeMempool != nil {
297+
includeMempool = *req.IncludeMempool
298+
}
299+
300+
if includeMempool {
301+
mempoolUTXOs := h.node.GetMempoolUTXOs(req.Addresses)
302+
// Drop confirmed UTXOs that the mempool tracker hasn't yet
303+
// evicted post-confirmation: a (txid,vout) appearing in both
304+
// sets is the confirmed copy. Keying on "txid:vout" preserves
305+
// the confirmed entry (Height>=1) over the mempool sentinel.
306+
seen := make(map[string]struct{}, len(utxos))
307+
for _, u := range utxos {
308+
seen[utxoKey(u.TxID, u.Vout)] = struct{}{}
309+
}
310+
for _, mu := range mempoolUTXOs {
311+
if _, ok := seen[utxoKey(mu.TxID, mu.Vout)]; ok {
312+
continue
313+
}
314+
utxos = append(utxos, neutrino.UTXO{
315+
TxID: mu.TxID,
316+
Vout: mu.Vout,
317+
Value: mu.Value,
318+
Address: mu.Address,
319+
ScriptPubKey: mu.ScriptPubKey,
320+
Height: 0, // mempool sentinel
321+
})
322+
}
323+
}
324+
272325
h.jsonResponse(w, map[string]any{
273326
"utxos": utxos,
274327
})
275328
}
276329

330+
// utxoKey builds the dedup key used when merging confirmed and mempool UTXOs.
331+
func utxoKey(txid string, vout uint32) string {
332+
return txid + ":" + strconv.FormatUint(uint64(vout), 10)
333+
}
334+
277335
// UTXO lookup endpoint
278336
func (h *Handler) handleGetUTXO(w http.ResponseWriter, r *http.Request) {
279337
vars := mux.Vars(r)
@@ -317,6 +375,32 @@ func (h *Handler) handleGetUTXO(w http.ResponseWriter, r *http.Request) {
317375
return
318376
}
319377

378+
// include_mempool defaults to true; clients opt out with ?include_mempool=false.
379+
includeMempool := true
380+
if v := r.URL.Query().Get("include_mempool"); v != "" {
381+
if parsed, perr := strconv.ParseBool(v); perr == nil {
382+
includeMempool = parsed
383+
}
384+
}
385+
386+
// If the UTXO is reported unspent on-chain but a mempool spend exists,
387+
// surface it via dedicated mempool fields without overwriting confirmed
388+
// state. Confirmed-spend wins over mempool-spend.
389+
if includeMempool && report.Unspent {
390+
if spend, ok := h.node.GetMempoolSpend(txid, uint32(vout)); ok {
391+
h.jsonResponse(w, map[string]any{
392+
"unspent": report.Unspent,
393+
"value": report.Value,
394+
"scriptpubkey": report.ScriptPubKey,
395+
"block_height": report.BlockHeight,
396+
"mempool_spending_txid": spend.SpendingTxID,
397+
"mempool_spending_input": spend.InputIndex,
398+
"mempool_spend_first_seen": spend.FirstSeen,
399+
})
400+
return
401+
}
402+
}
403+
320404
h.jsonResponse(w, report)
321405
}
322406

0 commit comments

Comments
 (0)