htlcswitch: classify a node-ID forward as a forward event
What changed, and why it matters
This commit fixes a bookkeeping bug in LND's HTLC event stream. When a payment is forwarded to a 'blinded' next hop identified only by a node public key (not a channel ID), and it fails before the switch picks an outgoing channel, the event was wrongly labeled as a 'receive' (final delivery) instead of a 'forward'. The fix carries the original next-hop information through failure and resolution paths so the event is correctly classified as a forward. It is a reporting/telemetry issue, not a code-execution vulnerability.
No immediate operational action beyond normal patching. Operators relying on SubscribeHtlcEvents for monitoring or accounting should update to include this fix so forward failures are not misreported as receive failures.
Security signals we found
Event misclassification in HTLC event stream (forward reported as receive)
Blinded/node-ID forwarding path introduced prior to this fix
Failure and resolution packets dropped next-hop metadata before fix
Fix adds explicit node-ID-forward classification and propagation of outgoingHop
Evidence from the diff
The change updates getEventType in htlcswitch/htlcnotifier.go to classify an htlcPacket with an outgoingHop that is a node ID (Right variant of the hop.Either type) as HtlcEventTypeForward before checking the outgoingChanID == hop.Exit sentinel. It also copies outgoingHop into reconstructed packets built by Switch.failAddPacket and interceptedForward.resolve so the classification survives early failures. A new test file covers getEventType and both reconstruction paths.
Changed components
htlcswitch/htlcnotifier.gohtlcswitch/switch.gohtlcswitch/interceptable_switch.goSubscribeHtlcEvents RPC/event streamInspect captured patch +149 / −0
diff --git a/htlcswitch/htlcnotifier.go b/htlcswitch/htlcnotifier.go
index 4d4d333..ac9bb3b 100644
--- a/htlcswitch/htlcnotifier.go
+++ b/htlcswitch/htlcnotifier.go
@@ -466,6 +466,14 @@ func getEventType(pkt *htlcPacket) HtlcEventType {
case pkt.incomingChanID == hop.Source:
return HtlcEventTypeSend
+ // A node-ID (pubkey) next hop has no outgoing SCID until the switch
+ // selects one, so outgoingChanID may still be hop.Exit on an early
+ // failure. Such a hop is always a forward, never the exit, so classify
+ // it before the hop.Exit check to avoid reporting a forward as a
+ // receive.
+ case pkt.outgoingHop.IsRight():
+ return HtlcEventTypeForward
+
case pkt.outgoingChanID == hop.Exit:
return HtlcEventTypeReceive
diff --git a/htlcswitch/htlcnotifier_test.go b/htlcswitch/htlcnotifier_test.go
new file mode 100644
index 0000000..f1f0722
--- /dev/null
+++ b/htlcswitch/htlcnotifier_test.go
@@ -0,0 +1,139 @@
+package htlcswitch
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lightningnetwork/lnd/htlcswitch/hop"
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/stretchr/testify/require"
+)
+
+// TestGetEventType asserts how getEventType classifies an htlcPacket as a send,
+// receive or forward event.
+func TestGetEventType(t *testing.T) {
+ t.Parallel()
+
+ var nodeID [33]byte
+ nodeID[0] = 0x02
+
+ tests := []struct {
+ name string
+ pkt *htlcPacket
+ want HtlcEventType
+ }{
+ {
+ name: "send",
+ pkt: &htlcPacket{incomingChanID: hop.Source},
+ want: HtlcEventTypeSend,
+ },
+ {
+ name: "receive at exit hop",
+ pkt: &htlcPacket{
+ incomingChanID: lnwire.NewShortChanIDFromInt(1),
+ outgoingChanID: hop.Exit,
+ },
+ want: HtlcEventTypeReceive,
+ },
+ {
+ name: "forward by channel ID",
+ pkt: &htlcPacket{
+ incomingChanID: lnwire.NewShortChanIDFromInt(1),
+ outgoingChanID: lnwire.NewShortChanIDFromInt(2),
+ },
+ want: HtlcEventTypeForward,
+ },
+ {
+ // A node-ID forward that failed before channel
+ // selection has outgoingChanID == hop.Exit but a Right
+ // (pubkey) next hop, so it must classify as a forward.
+ name: "forward by node ID before selection",
+ pkt: &htlcPacket{
+ incomingChanID: lnwire.NewShortChanIDFromInt(1),
+ outgoingChanID: hop.Exit,
+ outgoingHop: hop.NewNodeNextHop(nodeID),
+ },
+ want: HtlcEventTypeForward,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ require.Equal(t, tc.want, getEventType(tc.pkt))
+ })
+ }
+}
+
+// TestGetEventTypeNodeIDReconstructedPackets asserts that node-ID forward
+// packets reconstructed via failAddPacket and interceptedForward.resolve
+// preserve outgoingHop and are correctly classified as HtlcEventTypeForward by
+// getEventType.
+func TestGetEventTypeNodeIDReconstructedPackets(t *testing.T) {
+ t.Parallel()
+
+ var nodeID [33]byte
+ nodeID[0] = 0x02
+
+ inChanID := lnwire.NewShortChanIDFromInt(1)
+ chanID := lnwire.ChannelID{1}
+
+ // Create a switch with a mailOrchestrator and mailbox.
+ s := &Switch{
+ mailOrchestrator: newMailOrchestrator(&mailOrchConfig{}),
+ }
+ mailbox := s.mailOrchestrator.GetOrCreateMailBox(chanID, inChanID)
+ s.mailOrchestrator.BindLiveShortChanID(mailbox, chanID, inChanID)
+
+ // 1. Verify failAddPacket reconstruction.
+ origPkt := &htlcPacket{
+ incomingChanID: inChanID,
+ incomingHTLCID: 42,
+ outgoingChanID: hop.Exit,
+ outgoingHop: hop.NewNodeNextHop(nodeID),
+ obfuscator: NewMockObfuscator(),
+ }
+ linkErr := NewLinkError(&lnwire.FailUnknownNextPeer{})
+
+ err := s.failAddPacket(origPkt, linkErr)
+ require.Equal(t, linkErr, err)
+
+ select {
+ case failPkt := <-mailbox.PacketOutBox():
+ require.True(t, failPkt.outgoingHop.IsRight())
+ require.Equal(
+ t, HtlcEventTypeForward, getEventType(failPkt),
+ "failAddPacket must classify as forward",
+ )
+ case <-time.After(time.Second):
+ t.Fatal("failAddPacket did not deliver packet to mailbox")
+ }
+
+ // 2. Verify interceptedForward.resolve reconstruction.
+ resolvePkt := &htlcPacket{
+ incomingChanID: inChanID,
+ incomingHTLCID: 43,
+ outgoingChanID: hop.Exit,
+ outgoingHop: hop.NewNodeNextHop(nodeID),
+ obfuscator: NewMockObfuscator(),
+ }
+ fwd := &interceptedForward{
+ htlcSwitch: s,
+ packet: resolvePkt,
+ }
+
+ err = fwd.resolve(&lnwire.UpdateFailHTLC{})
+ require.NoError(t, err)
+
+ select {
+ case resPkt := <-mailbox.PacketOutBox():
+ require.True(t, resPkt.outgoingHop.IsRight())
+ require.Equal(
+ t, HtlcEventTypeForward, getEventType(resPkt),
+ "interceptedForward.resolve must classify as forward",
+ )
+ case <-time.After(time.Second):
+ t.Fatal("resolve did not deliver packet to mailbox")
+ }
+}
diff --git a/htlcswitch/interceptable_switch.go b/htlcswitch/interceptable_switch.go
index ac2d24c..5e379d0 100644
--- a/htlcswitch/interceptable_switch.go
+++ b/htlcswitch/interceptable_switch.go
@@ -891,6 +891,7 @@ func (f *interceptedForward) resolve(message lnwire.Message) error {
incomingChanID: f.packet.incomingChanID,
incomingHTLCID: f.packet.incomingHTLCID,
outgoingChanID: f.packet.outgoingChanID,
+ outgoingHop: f.packet.outgoingHop,
outgoingHTLCID: f.packet.outgoingHTLCID,
isResolution: true,
circuit: f.packet.circuit,
diff --git a/htlcswitch/switch.go b/htlcswitch/switch.go
index 7461d2e..3b6e3f9 100644
--- a/htlcswitch/switch.go
+++ b/htlcswitch/switch.go
@@ -1251,6 +1251,7 @@ func (s *Switch) failAddPacket(packet *htlcPacket, failure *LinkError) error {
incomingChanID: packet.incomingChanID,
incomingHTLCID: packet.incomingHTLCID,
outgoingChanID: packet.outgoingChanID,
+ outgoingHop: packet.outgoingHop,
outgoingHTLCID: packet.outgoingHTLCID,
incomingAmount: packet.incomingAmount,
amount: packet.amount,
Why this scored 25/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.