witness beacon: report node-ID next hop to the on-chain HTLC interceptor
What changed, and why it matters
This change fixes a reporting gap in LND's 'witness beacon,' a component that watches on-chain transactions and tells the HTLC interceptor where a payment should go next. Previously, when the next hop was identified by a node ID rather than a specific channel ID, the on-chain interceptor only reported an 'exit' channel and omitted the actual next node's public key. The patch makes the on-chain path mirror the off-chain path by also reporting the requested node's public key and using a special sentinel value so clients don't mistake the forward for a final payment delivery. It is best understood as a correctness/parity fix that prevents misrouting or misclassification of intercepted forwards.
Review the RPC mapping to NodeIDForwardSCID in related files to ensure the sentinel is consistently handled, and confirm that downstream interceptor clients correctly interpret OutgoingNodeID alongside OutgoingChanID. Consider whether any existing on-chain interceptor integrations were relying on the previous incomplete reporting and need updates.
Security signals we found
Missing security-critical metadata in on-chain interception path
Behavioral parity between on-chain and off-chain HTLC interception
Potential misclassification of node-ID forwards as final receives
Non-strict forwarding semantics preserved (no circuit-map resolution)
Evidence from the diff
In witness_beacon.go, preimageBeacon.SubscribeUpdates now populates InterceptedPacket.OutgoingNodeID from payload.FwdInfo.NextHopNode(). When the next hop is a node-ID hop (no outgoing channel), OutgoingChanID remains hop.Exit (via UnwrapOr), and the node public key is exposed separately. The RPC layer maps hop.Exit to NodeIDForwardSCID so the client recognizes a node-ID forward rather than a final receive. A unit test verifies this behavior. The change deliberately does not resolve the requested next hop against the circuit map, because non-strict forwarding may later select a different channel.
Changed components
witness_beacon.gowitness_beacon_test.gohtlcswitch.InterceptedPacketon-chain HTLC interceptorInspect captured patch +59 / −5
diff --git a/witness_beacon.go b/witness_beacon.go
index 1c2e78c..cefeb2f 100644
--- a/witness_beacon.go
+++ b/witness_beacon.go
@@ -107,14 +107,26 @@ func (p *preimageBeacon) SubscribeUpdates(
},
}
+ // Report the forwarding next hop to the interceptor. A channel-ID next
+ // hop is reported directly; a node-ID next hop has no outgoing channel
+ // of its own, so outgoingChanID is hop.Exit and the requested node ID
+ // is exposed separately, exactly as the off-chain interceptor does.
+ // This is the requested next hop, not the channel that non-strict
+ // forwarding eventually selects, so we deliberately do not resolve it
+ // against the circuit map. The RPC boundary maps a node-ID hop to the
+ // NodeIDForwardSCID sentinel for the client.
+ //
// Notify the htlc interceptor. There may be a client connected
// and willing to supply a preimage.
packet := &htlcswitch.InterceptedPacket{
- Hash: htlc.RHash,
- IncomingExpiry: htlc.RefundTimeout,
- IncomingAmount: htlc.Amt,
- IncomingCircuit: inKey,
- OutgoingChanID: payload.FwdInfo.NextHopChannel().UnwrapOr(hop.Exit),
+ Hash: htlc.RHash,
+ IncomingExpiry: htlc.RefundTimeout,
+ IncomingAmount: htlc.Amt,
+ IncomingCircuit: inKey,
+ OutgoingChanID: payload.FwdInfo.NextHopChannel().UnwrapOr(
+ hop.Exit,
+ ),
+ OutgoingNodeID: payload.FwdInfo.NextHopNode(),
OutgoingExpiry: payload.FwdInfo.OutgoingCLTV,
OutgoingAmount: payload.FwdInfo.AmountToForward,
InOnionCustomRecords: payload.CustomRecords(),
diff --git a/witness_beacon_test.go b/witness_beacon_test.go
index 7ba22db..dc3e0dd 100644
--- a/witness_beacon_test.go
+++ b/witness_beacon_test.go
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/lightningnetwork/lnd/chanstate"
+ "github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/graph/db/models"
"github.com/lightningnetwork/lnd/htlcswitch"
"github.com/lightningnetwork/lnd/htlcswitch/hop"
@@ -97,6 +98,47 @@ func TestWitnessBeaconInterceptErrorCancels(t *testing.T) {
p.RUnlock()
}
+// TestWitnessBeaconInterceptNodeID asserts that for a node-ID next hop the
+// on-chain interceptor reports the exit-hop SCID (hop.Exit) together with the
+// requested next node's public key, matching the off-chain interceptor. The
+// next hop is not resolved against the circuit map; the RPC boundary maps
+// hop.Exit to the sentinel.
+func TestWitnessBeaconInterceptNodeID(t *testing.T) {
+ var interceptedFwd htlcswitch.InterceptedForward
+ interceptor := func(fwd htlcswitch.InterceptedForward) error {
+ interceptedFwd = fwd
+
+ return nil
+ }
+
+ p := newPreimageBeacon(
+ &mockWitnessCache{}, interceptor,
+ func(models.CircuitKey) error {
+ return nil
+ },
+ )
+
+ var nodeID [33]byte
+ nodeID[0] = 0x02
+
+ payload := &hop.Payload{
+ FwdInfo: hop.ForwardingInfo{
+ NextHop: hop.NewNodeNextHop(nodeID),
+ },
+ }
+
+ _, err := p.SubscribeUpdates(
+ lnwire.NewShortChanIDFromInt(1),
+ &chanstate.HTLC{RHash: lntypes.Hash{1}},
+ payload, []byte{2},
+ )
+ require.NoError(t, err)
+
+ packet := interceptedFwd.Packet()
+ require.Equal(t, hop.Exit, packet.OutgoingChanID)
+ require.Equal(t, fn.Some(nodeID), packet.OutgoingNodeID)
+}
+
type mockWitnessCache struct {
witnessCache
}
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.