witness beacon: report node-ID next hop to the on-chain HTLC interceptor

Extend the on-chain interceptor path in the witness beacon to expose a
node-ID next hop, mirroring the off-chain path. A node-ID next hop has no
outgoing channel of its own, so the beacon reports hop.Exit as the outgoing
channel (via ForwardingInfo.NextHopChannel().UnwrapOr) and the requested
next node's public key. The RPC boundary maps that to the NodeIDForwardSCID
sentinel so the forward is not misread as a final receive.

This is the requested next hop, not the channel eventually selected by
non-strict forwarding, so the beacon deliberately does not resolve it
against the circuit map.

(cherry picked from commit 9c4b8bfec2)
This commit is contained in:
bitromortac 2026-07-23 12:25:29 +00:00 committed by ziggie
parent fa55f44367
commit 4b9f2f0f6a
No known key found for this signature in database
GPG key ID: 1AFF9C4DCED6D666
2 changed files with 59 additions and 5 deletions

View file

@ -106,14 +106,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(),

View file

@ -5,6 +5,7 @@ import (
"testing"
"github.com/lightningnetwork/lnd/channeldb"
"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),
&channeldb.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
}