htlcswitch+lnrpc: report node-ID next hop to the off-chain HTLC interceptor

When the switch forwards a blinded hop identified by node ID, it has not
yet resolved a concrete outgoing channel at interception time. Expose the
next hop to the interceptor: InterceptedForward.Packet() reports the
packet's outgoing channel as-is (hop.Exit, since none is selected yet) and
carries the requested pubkey in OutgoingNodeID.

At the RPC boundary, forwardInterceptor.onIntercept maps a node-ID hop to
the reserved NodeIDForwardSCID sentinel in outgoing_requested_chan_id and
the pubkey in outgoing_requested_node_id, so a client switching on a zero
channel ID to detect the exit hop does not misread the forward as a final
receive. The sentinel is a wire-only concern, applied where the request is
built rather than in the switch's internal InterceptedPacket, which stays
truthful (OutgoingNodeID.IsSome() is the node-ID discriminator).

(cherry picked from commit 32373b76c7)
This commit is contained in:
bitromortac 2026-07-24 11:38:20 +00:00 committed by ziggie
parent 47a5449258
commit 02f0f61cab
No known key found for this signature in database
GPG key ID: 1AFF9C4DCED6D666
3 changed files with 29 additions and 1 deletions

View file

@ -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,

View file

@ -381,6 +381,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 {
@ -388,9 +396,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

View file

@ -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)
}