From 86eeacc3aebd15600db00457d36ee5bebf198630 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 23 Jul 2026 13:52:45 +0000 Subject: [PATCH] htlcswitch: key the aux traffic shaper on the evaluated channel During non-strict forwarding, handlePacketAdd evaluates every candidate channel to the next peer and calls CheckHtlcForward with the sender-requested outgoing SCID (originalOutgoingChanID) for each candidate. That SCID flowed through canSendHtlc into AuxTrafficShaper.ShouldHandleTraffic, so a channel-keyed shaper was asked about the requested channel rather than the candidate actually being evaluated. With parallel channels to a peer this inspects the wrong channel. Key the shaper on l.ShortChanID() (the channel under evaluation) instead. originalScid is retained solely for createFailureWithUpdate / FailAliasUpdate, so the alias-aware channel_update returned to the sender is unchanged and the real SCID handed to the shaper never leaks onto the wire. (cherry picked from commit b166780015ec425b74166cd2d11105a9312c24b8) --- htlcswitch/link.go | 5 +- htlcswitch/link_test.go | 129 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 056403cb3..f966cd3e2 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -2635,7 +2635,10 @@ func (l *channelLink) canSendHtlc(policy models.ForwardingPolicy, htlcBlob = fn.Some(blob) } - return l.AuxBandwidth(amt, originalScid, htlcBlob, ts) + // Check if this link can handle the traffic. + return l.AuxBandwidth( + amt, l.ShortChanID(), htlcBlob, ts, + ) }, ).Unpack() if externalErr != nil { diff --git a/htlcswitch/link_test.go b/htlcswitch/link_test.go index a64942d5c..991819b77 100644 --- a/htlcswitch/link_test.go +++ b/htlcswitch/link_test.go @@ -40,6 +40,7 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/ticker" + "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) @@ -6394,6 +6395,134 @@ func TestCheckHtlcForward(t *testing.T) { }) } +// recordingAuxShaper is a minimal AuxTrafficShaper that records the channel id +// it is asked about and declines to handle the traffic, so the normal +// forwarding path proceeds. Only the methods reached by CheckHtlcForward are +// implemented; the rest are inherited from the embedded (nil) interface and +// must never be called. +type recordingAuxShaper struct { + AuxTrafficShaper + + gotCID lnwire.ShortChannelID +} + +// ShouldHandleTraffic records the short channel ID passed to the shaper. +func (a *recordingAuxShaper) ShouldHandleTraffic(cid lnwire.ShortChannelID, + _, _ fn.Option[tlv.Blob]) (bool, error) { + + a.gotCID = cid + + return false, nil +} + +// IsCustomHTLC returns false as recordingAuxShaper handles standard HTLCs. +func (a *recordingAuxShaper) IsCustomHTLC(_ lnwire.CustomRecords) bool { + return false +} + +// TestCheckHtlcForwardAuxShaperChannel asserts that during non-strict +// forwarding the aux traffic shaper is keyed on the channel actually being +// evaluated (the link's own SCID), not the sender-requested SCID, which fixes +// both the node-ID/blinded path (where no SCID is requested) and pre-existing +// parallel-channel forwarding. It also asserts the real SCID handed to the +// shaper never leaks into the sender-facing channel_update, which continues to +// reference the requested (alias) SCID. +func TestCheckHtlcForwardAuxShaperChannel(t *testing.T) { + t.Parallel() + + const ( + chanScid = 42 + requestedScid = 99 + ) + + fetchLastChannelUpdate := func(lnwire.ShortChannelID) ( + *lnwire.ChannelUpdate1, error) { + + return &lnwire.ChannelUpdate1{}, nil + } + + // Record the SCID used to build the returned channel_update on failure. + var updateScid lnwire.ShortChannelID + failAliasUpdate := func(sid lnwire.ShortChannelID, + incoming bool) *lnwire.ChannelUpdate1 { + + updateScid = sid + + return &lnwire.ChannelUpdate1{ + ShortChannelID: sid, + } + } + + testChannel, _, err := createTestChannel( + t, alicePrivKey, bobPrivKey, 100000, 100000, 1000, 1000, + lnwire.NewShortChanIDFromInt(chanScid), + ) + require.NoError(t, err) + + shaper := &recordingAuxShaper{} + link := channelLink{ + cfg: ChannelLinkConfig{ + FwrdingPolicy: models.ForwardingPolicy{ + TimeLockDelta: 20, + MinHTLCOut: 500, + MaxHTLC: 1000, + BaseFee: 10, + }, + FetchLastChannelUpdate: fetchLastChannelUpdate, + MaxOutgoingCltvExpiry: DefaultMaxOutgoingCltvExpiry, + HtlcNotifier: &mockHTLCNotifier{}, + }, + log: log, + channel: testChannel.channel, + } + link.cfg.AuxTrafficShaper = fn.Some[AuxTrafficShaper](shaper) + link.attachFailAliasUpdate(failAliasUpdate) + + require.Equal( + t, lnwire.NewShortChanIDFromInt(chanScid), link.ShortChanID(), + ) + + var hash [32]byte + requested := lnwire.NewShortChanIDFromInt(requestedScid) + + // A satisfiable forward: the shaper must be queried about the channel + // being evaluated (the link's own SCID), not the requested SCID. + result := link.CheckHtlcForward( + hash, 1500, 1000, 200, 150, models.InboundFee{}, 0, requested, + nil, + ) + require.Nil(t, result, "expected policy to be satisfied") + require.Equal( + t, link.ShortChanID(), shaper.gotCID, + "aux shaper must be keyed on the evaluated channel", + ) + require.NotEqual( + t, requested, shaper.gotCID, + "aux shaper must not be keyed on the requested SCID", + ) + + // A failing forward: the returned channel_update must reference the + // requested (alias) SCID, never the real channel SCID handed to the + // shaper. + result = link.CheckHtlcForward( + hash, 100, 50, 200, 150, models.InboundFee{}, 0, requested, nil, + ) + require.NotNil(t, result) + require.Equal( + t, requested, updateScid, + "channel_update must reference the requested SCID, not the "+ + "real channel SCID", + ) + + wireErr := result.WireMessage() + failAmt, ok := wireErr.(*lnwire.FailAmountBelowMinimum) + require.True(t, ok, "expected FailAmountBelowMinimum failure") + require.Equal( + t, requested, failAmt.Update.ShortChannelID, + "failure update must carry the requested SCID", + ) +} + // TestChannelLinkCanceledInvoice in this test checks the interaction // between Alice and Bob for a canceled invoice. func TestChannelLinkCanceledInvoice(t *testing.T) {