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 b166780015)
This commit is contained in:
bitromortac 2026-07-23 13:52:45 +00:00 committed by github-actions[bot]
parent c863397a4f
commit 86eeacc3ae
2 changed files with 133 additions and 1 deletions

View file

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

View file

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