htlcswitch+lnrpc: report node-ID next hop to the off-chain HTLC interceptor
What changed, and why it matters
This change improves how the Lightning Network Daemon (LND) reports forwarding details to external plugins that inspect HTLCs (payment packets). For a new type of blinded route where the next hop is identified by node ID rather than a specific channel, the code now tells the plugin both the requested node public key and a special reserved channel ID marker. This prevents an external plugin from mistakenly thinking the payment has reached its final destination when it has not. It is a correctness and information-disclosure improvement, not a fix for an active exploit.
No urgent action. Operators using the HTLC interceptor API should ensure their client can handle NodeIDForwardSCID and the new OutgoingRequestedNodeId field when processing blinded-route forwards. Review related interceptor logic for consistent handling of the sentinel.
Security signals we found
Information-correctness fix for HTLC interceptor API
Prevents misclassification of node-ID forwards as exit hops
Adds explicit sentinel to disambiguate zero SCID semantics
No memory-safety, cryptographic, or authorization changes observed
Evidence from the diff
The commit extends InterceptedPacket with OutgoingNodeID (an Option<[33]byte>) and adds a wire-only sentinel NodeIDForwardSCID (^uint64(0)). When the switch intercepts a forward to a blinded hop specified by node ID (BOLT 4 next_node_id) and no concrete outgoing channel is selected yet, it now populates OutgoingNodeID and keeps OutgoingChanID as hop.Exit (zero SCID). At the RPC boundary, forwardInterceptor.onIntercept maps that case to NodeIDForwardSCID in OutgoingRequestedChanId and sets OutgoingRequestedNodeId. This avoids un-updated interceptor clients interpreting a zero SCID as a final receive/exit hop.
Changed components
htlcswitch/interceptable_switch.gohtlcswitch/interfaces.golnrpc/routerrpc/forward_interceptor.goHTLC interceptor RPC (routerrpc)Inspect captured patch +29 / −1
diff --git a/htlcswitch/interceptable_switch.go b/htlcswitch/interceptable_switch.go
index 5e379d0..9ef686a 100644
--- a/htlcswitch/interceptable_switch.go
+++ b/htlcswitch/interceptable_switch.go
@@ -705,6 +705,7 @@ func (f *interceptedForward) Packet() InterceptedPacket {
HtlcID: f.packet.incomingHTLCID,
},
OutgoingChanID: f.packet.outgoingChanID,
+ OutgoingNodeID: f.packet.outgoingHop.RightToSome(),
Hash: f.htlc.PaymentHash,
OutgoingExpiry: f.htlc.Expiry,
OutgoingAmount: f.htlc.Amount,
diff --git a/htlcswitch/interfaces.go b/htlcswitch/interfaces.go
index ef62eb7..2a2b834 100644
--- a/htlcswitch/interfaces.go
+++ b/htlcswitch/interfaces.go
@@ -382,6 +382,14 @@ type InterceptableHtlcForwarder interface {
// and resolve it later or let the switch execute its default behavior.
type ForwardInterceptor func(InterceptedPacket) error
+// NodeIDForwardSCID is the sentinel outgoing SCID reported to HTLC interceptor
+// clients (at the RPC boundary) for a next hop identified by node ID (BOLT 4
+// next_node_id) rather than by channel. All bits are set, an out-of-range value
+// that can never match a real or alias channel, so a client switching on a zero
+// SCID to detect the exit hop does not read the forward as a final receive. The
+// pubkey is in InterceptedPacket.OutgoingNodeID.
+const NodeIDForwardSCID uint64 = ^uint64(0)
+
// InterceptedPacket contains the relevant information for the interceptor about
// an HTLC.
type InterceptedPacket struct {
@@ -389,9 +397,17 @@ type InterceptedPacket struct {
// packet.
IncomingCircuit models.CircuitKey
- // OutgoingChanID is the destination channel for this packet.
+ // OutgoingChanID is the destination channel for this packet. For a
+ // node-ID next hop with no concrete channel known yet it is hop.Exit
+ // and OutgoingNodeID holds the pubkey; the RPC layer maps that to the
+ // NodeIDForwardSCID sentinel before reporting it to a client.
OutgoingChanID lnwire.ShortChannelID
+ // OutgoingNodeID is the next hop's compressed pubkey for a blinded
+ // route that identifies it by node ID (next_node_id). None in the
+ // common channel-ID case.
+ OutgoingNodeID fn.Option[[33]byte]
+
// Hash is the payment hash of the htlc.
Hash lntypes.Hash
diff --git a/lnrpc/routerrpc/forward_interceptor.go b/lnrpc/routerrpc/forward_interceptor.go
index 61adf8f..a1a065f 100644
--- a/lnrpc/routerrpc/forward_interceptor.go
+++ b/lnrpc/routerrpc/forward_interceptor.go
@@ -100,6 +100,17 @@ func (r *forwardInterceptor) onIntercept(
InWireCustomRecords: htlc.InWireCustomRecords,
}
+ // A node-ID forward has no requested outgoing channel. Expose the
+ // requested pubkey and report the reserved NodeIDForwardSCID sentinel
+ // rather than a zero SCID. Older un-upgraded protobuf clients do not
+ // know about outgoing_requested_node_id and would otherwise interpret
+ // a zero SCID as an exit hop.
+ htlc.OutgoingNodeID.WhenSome(func(nodeID [33]byte) {
+ interceptionRequest.OutgoingRequestedNodeId = nodeID[:]
+ interceptionRequest.OutgoingRequestedChanId =
+ htlcswitch.NodeIDForwardSCID
+ })
+
return r.stream.Send(interceptionRequest)
}
Why this scored 19/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.