multi: skip range check in pathfinder and switch for custom htlc payments
What changed, and why it matters
This commit changes how the Lightning Network Daemon (LND) handles 'custom HTLC' payments. Normally, LND enforces minimum and maximum amount limits on forwarded payments to prevent abuse and routing loops. For custom HTLCs—special payments that carry extra data and may use non-Bitcoin assets—these amount checks are now skipped in both the routing pathfinder and the actual forwarding switch. The change is described by the authors as intentional and needed to avoid routing loops, not as a security fix. It narrows a safety guard for a specific payment type, which could matter if the custom-channel logic has bugs or is misused.
Reviewers should verify that custom HTLCs are indeed only locally sourced and that the AuxTrafficShaper/IsCustomHTLC classification cannot be manipulated by an external peer to route arbitrary amounts. Consider adding tests that confirm non-custom HTLCs still trigger the range check, and that custom HTLCs cannot be crafted by remote forwarding parties. If custom channels are externally reachable, evaluate whether additional amount or rate-limit checks are needed.
Security signals we found
Policy bypass: minimum and maximum HTLC amount checks are skipped for payments classified as custom HTLCs.
Scope is limited to custom HTLCs, which the commit says are locally sourced and use non-routable custom channels.
The change is in two places: routing path selection and actual link forwarding, so both planning and execution of a payment skip the range check.
No explicit bounds checking is added elsewhere to compensate for the skipped range check for custom HTLCs.
Commit message and comments describe the change as avoiding routing loops caused by unpersisted link errors, not as a vulnerability fix.
Evidence from the diff
The patch refactors HTLC amount validation in htlcswitch/link.go into a new validateHtlcAmount helper. That helper skips the MinHTLCOut/MaxHTLC range check when the configured AuxTrafficShaper reports that the HTLC is a ‘custom HTLC’ (via IsCustomHTLC). In routing/unified_edges.go, the pathfinder’s amount-range check is gated by a new bandwidthHints.isCustomHTLCPayment() method instead of the prior firstHopCustomBlob() presence test. routing/bandwidth.go implements isCustomHTLCPayment by parsing the first-hop blob into custom records and asking the traffic shaper whether they constitute a custom HTLC. Tests are updated to match the new interface. The commit message and comments frame this as a functional fix for custom-HTLC routing, not a security patch.
Changed components
htlcswitch/link.gorouting/bandwidth.gorouting/unified_edges.gorouting/integrated_routing_context_test.goInspect captured patch +112 / −48
diff --git a/htlcswitch/link.go b/htlcswitch/link.go
index 1abf496..c5a4452 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 afe085c..df68cea 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 e89df8a..a98fa56 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 f80e1cb..9b8f6c5 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",
Why this scored 35/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.