Skip to content

Commit 0e93476

Browse files
committed
docs(mempool): clarify per-peer getdata routing and lock it in with tests
The package-level comment claimed we 'ask every peer for every announced transaction', which suggested a per-tx fan-out. handleInv actually issues the getdata back to the single announcing peer; the privacy property comes from not filtering on a local heuristic, not from broadcasting. Tighten the docstring to match the implementation and add two tests that lock in the contract: getdata is sent only to the announcing peer, and handleInv exits early when no addresses are watched.
1 parent 40166ea commit 0e93476

2 files changed

Lines changed: 95 additions & 3 deletions

File tree

neutrino_server/internal/neutrino/mempool.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ subscribing to every connected peer's incoming P2P messages, fetching
44
each announced transaction, and matching it against the watched script
55
set / confirmed UTXO set tracked by RescanManager.
66
7-
Privacy model: we ask every peer for every announced transaction (rather
8-
than only those that match a local heuristic). This avoids leaking which
9-
addresses we care about, at the cost of a small bandwidth overhead.
7+
Privacy model: for every tx inv received from a peer we issue a getdata
8+
back to that same peer (not to all peers) without filtering on any local
9+
heuristic. Because the request set is "every announced tx", peers cannot
10+
infer which addresses or scripts we care about from our fetch pattern.
11+
The bandwidth cost is one extra tx download per inv on top of a normal
12+
SPV flow.
1013
1114
Eviction:
1215
- A tx is removed when the block-connect hook (RescanManager.runAutoSyncPass)

neutrino_server/internal/neutrino/mempool_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,95 @@ func TestNotifyMempoolConfirmed_EmptyTxidsIsNoOp(t *testing.T) {
413413
}
414414
}
415415

416+
// fakePeer is a minimal query.Peer used to assert handleInv's getdata
417+
// routing. It records every QueueMessageWithEncoding invocation; the
418+
// channels and Addr exist only so the type satisfies the interface.
419+
type fakePeer struct {
420+
addr string
421+
sent []wire.Message
422+
disconCh chan struct{}
423+
mu sync.Mutex
424+
}
425+
426+
func newFakePeer(addr string) *fakePeer {
427+
return &fakePeer{addr: addr, disconCh: make(chan struct{})}
428+
}
429+
430+
func (p *fakePeer) Addr() string { return p.addr }
431+
func (p *fakePeer) OnDisconnect() <-chan struct{} { return p.disconCh }
432+
func (p *fakePeer) SubscribeRecvMsg() (<-chan wire.Message, func()) {
433+
ch := make(chan wire.Message)
434+
return ch, func() { close(ch) }
435+
}
436+
func (p *fakePeer) QueueMessageWithEncoding(m wire.Message, _ chan<- struct{},
437+
_ wire.MessageEncoding,
438+
) {
439+
p.mu.Lock()
440+
p.sent = append(p.sent, m)
441+
p.mu.Unlock()
442+
}
443+
444+
// TestHandleInv_GetDataGoesOnlyToAnnouncingPeer locks in the privacy
445+
// contract documented at the top of mempool.go: getdata for a tx inv is
446+
// issued only to the peer that announced the tx, never broadcast. If we
447+
// fanned out we would leak nothing about *which* txs we care about, but we
448+
// would still expand bandwidth quadratically and give a network-wide
449+
// observer extra fingerprintable behaviour.
450+
func TestHandleInv_GetDataGoesOnlyToAnnouncingPeer(t *testing.T) {
451+
mgr := newTestRescanMgr(t, watchedAddrMain, nil)
452+
tr := newTracker(mgr)
453+
454+
announcer := newFakePeer("announcer:8333")
455+
other := newFakePeer("other:8333")
456+
457+
inv := wire.NewMsgInv()
458+
hash := mustHash(t, "0000000000000000000000000000000000000000000000000000000000000ccc")
459+
if err := inv.AddInvVect(&wire.InvVect{Type: wire.InvTypeTx, Hash: hash}); err != nil {
460+
t.Fatalf("AddInvVect: %v", err)
461+
}
462+
463+
tr.handleInv(announcer, inv)
464+
465+
if got := len(announcer.sent); got != 1 {
466+
t.Fatalf("announcer should have received 1 getdata, got %d", got)
467+
}
468+
if got := len(other.sent); got != 0 {
469+
t.Fatalf("non-announcing peer must not receive getdata, got %d", got)
470+
}
471+
gd, ok := announcer.sent[0].(*wire.MsgGetData)
472+
if !ok {
473+
t.Fatalf("expected MsgGetData, got %T", announcer.sent[0])
474+
}
475+
if len(gd.InvList) != 1 || gd.InvList[0].Hash != hash {
476+
t.Errorf("unexpected getdata payload: %+v", gd.InvList)
477+
}
478+
// Witness-stripped form is required so we receive the full tx.
479+
if gd.InvList[0].Type != wire.InvTypeWitnessTx {
480+
t.Errorf("expected witness-tx inv type, got %v", gd.InvList[0].Type)
481+
}
482+
}
483+
484+
// TestHandleInv_NoWatchedAddressesSkipsGetData verifies the early-exit in
485+
// handleInv: when zero addresses are watched, no getdata is issued (no
486+
// point fetching txs we can't match).
487+
func TestHandleInv_NoWatchedAddressesSkipsGetData(t *testing.T) {
488+
mgr := newTestRescanMgr(t, "", nil)
489+
tr := newTracker(mgr)
490+
491+
announcer := newFakePeer("announcer:8333")
492+
inv := wire.NewMsgInv()
493+
hash := mustHash(t, "0000000000000000000000000000000000000000000000000000000000000ddd")
494+
if err := inv.AddInvVect(&wire.InvVect{Type: wire.InvTypeTx, Hash: hash}); err != nil {
495+
t.Fatalf("AddInvVect: %v", err)
496+
}
497+
498+
tr.handleInv(announcer, inv)
499+
500+
if got := len(announcer.sent); got != 0 {
501+
t.Fatalf("no getdata expected with zero watched addrs, got %d", got)
502+
}
503+
}
504+
416505
// TestEvictExpired_DropsStaleInflight verifies the TTL sweep also clears
417506
// abandoned getdata requests so a never-delivered tx (e.g. announcing peer
418507
// disconnected before responding) stops blocking re-fetch from other peers.

0 commit comments

Comments
 (0)