From e2a9b17254b092018c209e613b160bb898897921 Mon Sep 17 00:00:00 2001 From: ziggie Date: Sat, 2 Aug 2025 17:34:23 +0200 Subject: [PATCH] multi: skip range check in pathfinder and switch for custom htlc payments --- htlcswitch/link.go | 115 +++++++++++++++------ routing/bandwidth.go | 29 ++++-- routing/integrated_routing_context_test.go | 5 +- routing/unified_edges.go | 11 +- 4 files changed, 112 insertions(+), 48 deletions(-) diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 1abf496a1..c5a445243 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -2547,36 +2547,12 @@ func (l *channelLink) canSendHtlc(policy models.ForwardingPolicy, heightNow uint32, originalScid lnwire.ShortChannelID, customRecords lnwire.CustomRecords) *LinkError { - // As our first sanity check, we'll ensure that the passed HTLC isn't - // too small for the next hop. If so, then we'll cancel the HTLC - // directly. - if amt < policy.MinHTLCOut { - l.log.Warnf("outgoing htlc(%x) is too small: min_htlc=%v, "+ - "htlc_value=%v", payHash[:], policy.MinHTLCOut, - amt) - - // As part of the returned error, we'll send our latest routing - // policy so the sending node obtains the most up to date data. - cb := func(upd *lnwire.ChannelUpdate1) lnwire.FailureMessage { - return lnwire.NewAmountBelowMinimum(amt, *upd) - } - failure := l.createFailureWithUpdate(false, originalScid, cb) - return NewLinkError(failure) - } - - // Next, ensure that the passed HTLC isn't too large. If so, we'll - // cancel the HTLC directly. - if policy.MaxHTLC != 0 && amt > policy.MaxHTLC { - l.log.Warnf("outgoing htlc(%x) is too large: max_htlc=%v, "+ - "htlc_value=%v", payHash[:], policy.MaxHTLC, amt) - - // As part of the returned error, we'll send our latest routing - // policy so the sending node obtains the most up-to-date data. - cb := func(upd *lnwire.ChannelUpdate1) lnwire.FailureMessage { - return lnwire.NewTemporaryChannelFailure(upd) - } - failure := l.createFailureWithUpdate(false, originalScid, cb) - return NewDetailedLinkError(failure, OutgoingFailureHTLCExceedsMax) + // Validate HTLC amount against policy limits. + linkErr := l.validateHtlcAmount( + policy, payHash, amt, originalScid, customRecords, + ) + if linkErr != nil { + return linkErr } // We want to avoid offering an HTLC which will expire in the near @@ -2591,6 +2567,7 @@ func (l *channelLink) canSendHtlc(policy models.ForwardingPolicy, return lnwire.NewExpiryTooSoon(*upd) } failure := l.createFailureWithUpdate(false, originalScid, cb) + return NewLinkError(failure) } @@ -2606,7 +2583,8 @@ func (l *channelLink) canSendHtlc(policy models.ForwardingPolicy, // We now check the available bandwidth to see if this HTLC can be // forwarded. availableBandwidth := l.Bandwidth() - auxBandwidth, err := fn.MapOptionZ( + + auxBandwidth, externalErr := fn.MapOptionZ( l.cfg.AuxTrafficShaper, func(ts AuxTrafficShaper) fn.Result[OptionalBandwidth] { var htlcBlob fn.Option[tlv.Blob] @@ -2624,8 +2602,10 @@ func (l *channelLink) canSendHtlc(policy models.ForwardingPolicy, return l.AuxBandwidth(amt, originalScid, htlcBlob, ts) }, ).Unpack() - if err != nil { - l.log.Errorf("Unable to determine aux bandwidth: %v", err) + if externalErr != nil { + l.log.Errorf("Unable to determine aux bandwidth: %v", + externalErr) + return NewLinkError(&lnwire.FailTemporaryNodeFailure{}) } @@ -2645,6 +2625,7 @@ func (l *channelLink) canSendHtlc(policy models.ForwardingPolicy, return lnwire.NewTemporaryChannelFailure(upd) } failure := l.createFailureWithUpdate(false, originalScid, cb) + return NewDetailedLinkError( failure, OutgoingFailureInsufficientBalance, ) @@ -4716,3 +4697,71 @@ func (l *channelLink) processLocalUpdateFailHTLC(ctx context.Context, // Immediately update the commitment tx to minimize latency. l.updateCommitTxOrFail(ctx) } + +// validateHtlcAmount checks if the HTLC amount is within the policy's +// minimum and maximum limits. Returns a LinkError if validation fails. +func (l *channelLink) validateHtlcAmount(policy models.ForwardingPolicy, + payHash [32]byte, amt lnwire.MilliSatoshi, + originalScid lnwire.ShortChannelID, + customRecords lnwire.CustomRecords) *LinkError { + + // In case we are dealing with a custom HTLC, we don't need to validate + // the HTLC constraints. + // + // NOTE: Custom HTLCs are only locally sourced and will use custom + // channels which are not routable channels and should have their policy + // not restricted in the first place. However to be sure we skip this + // check otherwise we might end up in a loop of sending to the same + // route again and again because link errors are not persisted in + // mission control. + if fn.MapOptionZ( + l.cfg.AuxTrafficShaper, + func(ts AuxTrafficShaper) bool { + return ts.IsCustomHTLC(customRecords) + }, + ) { + + l.log.Debugf("Skipping htlc amount policy validation for " + + "custom htlc") + + return nil + } + + // As our first sanity check, we'll ensure that the passed HTLC isn't + // too small for the next hop. If so, then we'll cancel the HTLC + // directly. + if amt < policy.MinHTLCOut { + l.log.Warnf("outgoing htlc(%x) is too small: min_htlc=%v, "+ + "htlc_value=%v", payHash[:], policy.MinHTLCOut, + amt) + + // As part of the returned error, we'll send our latest routing + // policy so the sending node obtains the most up to date data. + cb := func(upd *lnwire.ChannelUpdate1) lnwire.FailureMessage { + return lnwire.NewAmountBelowMinimum(amt, *upd) + } + failure := l.createFailureWithUpdate(false, originalScid, cb) + + return NewLinkError(failure) + } + + // Next, ensure that the passed HTLC isn't too large. If so, we'll + // cancel the HTLC directly. + if policy.MaxHTLC != 0 && amt > policy.MaxHTLC { + l.log.Warnf("outgoing htlc(%x) is too large: max_htlc=%v, "+ + "htlc_value=%v", payHash[:], policy.MaxHTLC, amt) + + // As part of the returned error, we'll send our latest routing + // policy so the sending node obtains the most up-to-date data. + cb := func(upd *lnwire.ChannelUpdate1) lnwire.FailureMessage { + return lnwire.NewTemporaryChannelFailure(upd) + } + failure := l.createFailureWithUpdate(false, originalScid, cb) + + return NewDetailedLinkError( + failure, OutgoingFailureHTLCExceedsMax, + ) + } + + return nil +} diff --git a/routing/bandwidth.go b/routing/bandwidth.go index afe085c2e..df68cea4f 100644 --- a/routing/bandwidth.go +++ b/routing/bandwidth.go @@ -24,9 +24,9 @@ type bandwidthHints interface { availableChanBandwidth(channelID uint64, amount lnwire.MilliSatoshi) (lnwire.MilliSatoshi, bool) - // firstHopCustomBlob returns the custom blob for the first hop of the - // payment, if available. - firstHopCustomBlob() fn.Option[tlv.Blob] + // isCustomHTLCPayment returns true if this payment is a custom payment. + // For custom payments policy checks might not be needed. + isCustomHTLCPayment() bool } // getLinkQuery is the function signature used to lookup a link. @@ -207,8 +207,23 @@ func (b *bandwidthManager) availableChanBandwidth(channelID uint64, return bandwidth, true } -// firstHopCustomBlob returns the custom blob for the first hop of the payment, -// if available. -func (b *bandwidthManager) firstHopCustomBlob() fn.Option[tlv.Blob] { - return b.firstHopBlob +// isCustomHTLCPayment returns true if this payment is a custom payment. +// For custom payments policy checks might not be needed. +func (b *bandwidthManager) isCustomHTLCPayment() bool { + return fn.MapOptionZ(b.firstHopBlob, func(blob tlv.Blob) bool { + customRecords, err := lnwire.ParseCustomRecords(blob) + if err != nil { + log.Warnf("failed to parse custom records when "+ + "checking if payment is custom: %v", err) + + return false + } + + return fn.MapOptionZ( + b.trafficShaper, + func(s htlcswitch.AuxTrafficShaper) bool { + return s.IsCustomHTLC(customRecords) + }, + ) + }) } diff --git a/routing/integrated_routing_context_test.go b/routing/integrated_routing_context_test.go index e89df8aee..a98fa5602 100644 --- a/routing/integrated_routing_context_test.go +++ b/routing/integrated_routing_context_test.go @@ -11,7 +11,6 @@ import ( "github.com/lightningnetwork/lnd/kvdb" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" - "github.com/lightningnetwork/lnd/tlv" "github.com/lightningnetwork/lnd/zpay32" "github.com/stretchr/testify/require" ) @@ -36,8 +35,8 @@ func (m *mockBandwidthHints) availableChanBandwidth(channelID uint64, return balance, ok } -func (m *mockBandwidthHints) firstHopCustomBlob() fn.Option[tlv.Blob] { - return fn.None[tlv.Blob]() +func (m *mockBandwidthHints) isCustomHTLCPayment() bool { + return false } // integratedRoutingContext defines the context in which integrated routing diff --git a/routing/unified_edges.go b/routing/unified_edges.go index f80e1cb1a..9b8f6c5c0 100644 --- a/routing/unified_edges.go +++ b/routing/unified_edges.go @@ -265,12 +265,13 @@ func (u *edgeUnifier) getEdgeLocal(netAmtReceived lnwire.MilliSatoshi, // Add inbound fee to get to the amount that is sent over the // local channel. amt := netAmtReceived + lnwire.MilliSatoshi(inboundFee) - // Check valid amount range for the channel. We skip this test - // for payments with custom HTLC data, as the amount sent on - // the BTC layer may differ from the amount that is actually - // forwarded in custom channels. - if bandwidthHints.firstHopCustomBlob().IsNone() && + + // for payments with custom htlc data we skip the amount range + // check because the amt of the payment does not relate to the + // actual amount carried by the HTLC but instead is encoded in + // the blob data. + if !bandwidthHints.isCustomHTLCPayment() && !edge.amtInRange(amt) { log.Debugf("Amount %v not in range for edge %v",