From e4f4547198130af99ac56c80d52c6424387a9918 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 8 Dec 2025 12:29:24 +0100 Subject: [PATCH 1/4] lnwallet: add and use AuxHtlcValidator to lightning channel Previously we'd perform aux bandwidth checks during path finding. This could lead to issues where multiple HTLCs where querying the same bandwidth but were not accounting for each other before being added to the commitment log. We now add a new validator function that will serve as the last point of checks before adding the HTLC to the commitment. During path finding HTLCs could query channel bandwidth asynchronously. At this new call site all HTLCs that are about to be added to the channel have been organised in sequence, so it's safe to query bandwdith again at this point as we're getting the actual up-to-date values. --- lnwallet/channel.go | 59 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 484a019da5b..1876ab57909 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -833,6 +833,14 @@ type LightningChannel struct { // is created. type ChannelOpt func(*channelOpts) +// AuxHtlcValidator is a function that validates whether an HTLC can be added +// to a custom channel. It is called during HTLC validation with the current +// channel state and HTLC details. This allows external components (like the +// traffic shaper) to perform final validation checks against the most +// up-to-date channel state before the HTLC is committed. +type AuxHtlcValidator func(amount, linkBandwidth lnwire.MilliSatoshi, + customRecords lnwire.CustomRecords, view AuxHtlcView) error + // channelOpts is the set of options used to create a new channel. type channelOpts struct { localNonce *musig2.Nonces @@ -842,6 +850,10 @@ type channelOpts struct { auxSigner fn.Option[AuxSigner] auxResolver fn.Option[AuxContractResolver] + // auxHtlcValidator is an optional validator that performs custom + // validation on HTLCs before they are added to the channel state. + auxHtlcValidator fn.Option[AuxHtlcValidator] + skipNonceInit bool } @@ -894,6 +906,15 @@ func WithAuxResolver(resolver AuxContractResolver) ChannelOpt { } } +// WithAuxHtlcValidator is used to specify a custom HTLC validator for the +// channel. This validator will be called during HTLC addition to perform +// final validation checks against the most up-to-date channel state. +func WithAuxHtlcValidator(validator AuxHtlcValidator) ChannelOpt { + return func(o *channelOpts) { + o.auxHtlcValidator = fn.Some(validator) + } +} + // defaultChannelOpts returns the set of default options for a new channel. func defaultChannelOpts() *channelOpts { return &channelOpts{} @@ -6243,6 +6264,44 @@ func (lc *LightningChannel) validateAddHtlc(pd *paymentDescriptor, return err } + // If an auxiliary HTLC validator is configured, call it now to perform + // custom validation checks against the current channel state. This is + // the final validation point before the HTLC is added to the update + // log, ensuring that the validator sees the most up-to-date state + // including all previously validated HTLCs in this batch. + // + // NOTE: This is called after the standard commitment sanity checks to + // ensure we only perform (potentially) expensive custom validation on + // HTLCs that have already passed the basic Lightning protocol + // constraints. + err = fn.MapOptionZ( + lc.opts.auxHtlcValidator, + func(validator AuxHtlcValidator) error { + // Fetch the current HTLC view which includes all + // pending HTLCs that haven't been committed yet. This + // provides the validator with the most accurate state. + view := lc.fetchHTLCView( + lc.updateLogs.Remote.logIndex, + lc.updateLogs.Local.logIndex, + ) + auxView := newAuxHtlcView(view) + + // Get the current available balance for the link + // bandwidth check. This is needed for the reserve + // validation in the traffic shaper. We use NoBuffer + // since this is the final check before adding the HTLC. + linkBandwidth, _ := lc.availableBalance(NoBuffer) + + return validator( + pd.Amount, linkBandwidth, pd.CustomRecords, + auxView, + ) + }, + ) + if err != nil { + return fmt.Errorf("aux HTLC validation failed: %w", err) + } + return nil } From 5c7f101e68ef450e09178a975eed64de2248b6b7 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 8 Dec 2025 12:38:18 +0100 Subject: [PATCH 2/4] peer: set and use aux htlc validator When instantiating the lightning channel we now pass in the created HTLC validator. This validator simply performs a bandwidth check and errors out if that is insufficient. --- peer/brontide.go | 86 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/peer/brontide.go b/peer/brontide.go index 8d02ca6e539..79f153ed2e3 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -51,6 +51,7 @@ import ( "github.com/lightningnetwork/lnd/pool" "github.com/lightningnetwork/lnd/protofsm" "github.com/lightningnetwork/lnd/queue" + "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/subscribe" "github.com/lightningnetwork/lnd/ticker" "github.com/lightningnetwork/lnd/tlv" @@ -1139,6 +1140,16 @@ func (p *Brontide) loadActiveChannels(chans []*channeldb.OpenChannel) ( }, ) + p.cfg.AuxTrafficShaper.WhenSome( + func(ts htlcswitch.AuxTrafficShaper) { + val := p.createHtlcValidator(dbChan, ts) + chanOpts = append( + chanOpts, + lnwallet.WithAuxHtlcValidator(val), + ) + }, + ) + lnChan, err := lnwallet.NewLightningChannel( p.cfg.Signer, dbChan, p.cfg.SigPool, chanOpts..., ) @@ -5228,6 +5239,15 @@ func (p *Brontide) addActiveChannel(c *lnpeer.NewChannel) error { chanOpts = append(chanOpts, lnwallet.WithAuxResolver(s)) }) + p.cfg.AuxTrafficShaper.WhenSome( + func(ts htlcswitch.AuxTrafficShaper) { + val := p.createHtlcValidator(c.OpenChannel, ts) + chanOpts = append( + chanOpts, lnwallet.WithAuxHtlcValidator(val), + ) + }, + ) + // If not already active, we'll add this channel to the set of active // channels, so we can look it up later easily according to its channel // ID. @@ -5434,6 +5454,72 @@ func (p *Brontide) scaleTimeout(timeout time.Duration) time.Duration { return timeout } +// createHtlcValidator creates an HTLC validator function that performs final +// aux balance validation before HTLCs are added to the channel state. This +// validator calls into the traffic shaper's PaymentBandwidth method to check +// external balance against the most up-to-date channel state, preventing race +// conditions where multiple HTLCs could be approved based on stale bandwidth. +func (p *Brontide) createHtlcValidator(dbChan *channeldb.OpenChannel, + ts htlcswitch.AuxTrafficShaper) lnwallet.AuxHtlcValidator { + + return func(amount, linkBandwidth lnwire.MilliSatoshi, + customRecords lnwire.CustomRecords, + view lnwallet.AuxHtlcView) error { + + // Get the short channel ID for logging. + scid := dbChan.ShortChannelID + + // Extract the HTLC custom records to pass to the traffic + // shaper. + var htlcBlob fn.Option[tlv.Blob] + if len(customRecords) > 0 { + blob, err := customRecords.Serialize() + if err != nil { + return fmt.Errorf("unable to serialize "+ + "custom records: %w", err) + } + htlcBlob = fn.Some(blob) + } + + // Get the funding and commitment blobs for this channel. + fundingBlob := dbChan.CustomBlob + commitmentBlob := dbChan.LocalCommitment.CustomBlob + + // Fetch the peer's public key. + peerBytes := p.IdentityKey().SerializeCompressed() + peer, err := route.NewVertexFromBytes(peerBytes) + if err != nil { + return fmt.Errorf("failed to create vertex from peer "+ + "pub key: %w", err) + } + + // Call the traffic shaper's PaymentBandwidth method with the + // current state. This performs the same bandwidth checks as + // during pathfinding/forwarding, but against the absolute + // latest channel state. + // + // The linkBandwidth is provided by the channel and represents + // the current available balance, which is used by the traffic + // shaper to ensure we don't dip below channel reserves. + bandwidth, err := ts.PaymentBandwidth( + fundingBlob, htlcBlob, commitmentBlob, + linkBandwidth, amount, view, peer, + ) + if err != nil { + return fmt.Errorf("traffic shaper bandwidth check "+ + "failed: %w", err) + } + + if amount > bandwidth { + return fmt.Errorf("insufficient aux bandwidth: "+ + "need %v, have %v (scid=%v)", amount, + bandwidth, scid) + } + + return nil + } +} + // CoopCloseUpdates is a struct used to communicate updates for an active close // to the caller. type CoopCloseUpdates struct { From dbd5b4cf66b775f4fd20530406a4329cf8b3de5e Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 15 Dec 2025 14:28:08 +0100 Subject: [PATCH 3/4] lnwallet: validate HTLC against aux bandwidth only before adding The validateAddHtlc helper is also called from the public function MayAddOutgoingHTLC which is used from places like the pathfinding logic. In those call sites we already perform bandwidth related checks for the purpose of pathfinding, so it is redundant to do it again within this helper. We use the aux bandwidth check only when truly adding the HTLC to the channel state. --- lnwallet/channel.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 1876ab57909..2b0ede4ceb4 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -6082,7 +6082,7 @@ func (lc *LightningChannel) addHTLC(htlc *lnwire.UpdateAddHTLC, defer lc.Unlock() pd := lc.htlcAddDescriptor(htlc, openKey) - if err := lc.validateAddHtlc(pd, buffer); err != nil { + if err := lc.validateAddHtlc(pd, buffer, true); err != nil { return 0, err } @@ -6200,7 +6200,7 @@ func (lc *LightningChannel) MayAddOutgoingHtlc(amt lnwire.MilliSatoshi) error { // Enforce the FeeBuffer because we are evaluating whether we can add // another htlc to the channel state. - if err := lc.validateAddHtlc(pd, FeeBuffer); err != nil { + if err := lc.validateAddHtlc(pd, FeeBuffer, false); err != nil { lc.log.Debugf("May add outgoing htlc rejected: %v", err) return err } @@ -6236,7 +6236,8 @@ func (lc *LightningChannel) htlcAddDescriptor(htlc *lnwire.UpdateAddHTLC, // validateAddHtlc validates the addition of an outgoing htlc to our local and // remote commitments. func (lc *LightningChannel) validateAddHtlc(pd *paymentDescriptor, - buffer BufferType) error { + buffer BufferType, finalCheck bool) error { + // Make sure adding this HTLC won't violate any of the constraints we // must keep on the commitment transactions. remoteACKedIndex := lc.commitChains.Local.tail().messageIndices.Remote @@ -6264,6 +6265,13 @@ func (lc *LightningChannel) validateAddHtlc(pd *paymentDescriptor, return err } + // In order to avoid unnecessary validations of the aux bandwidth that + // may be costly to perform, let's skip unless this is the final check + // before adding the HTLC to the channel. + if !finalCheck { + return nil + } + // If an auxiliary HTLC validator is configured, call it now to perform // custom validation checks against the current channel state. This is // the final validation point before the HTLC is added to the update From aea7673e6de999157a7c3e0782d8b818ce21c3b3 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 15 Dec 2025 14:28:43 +0100 Subject: [PATCH 4/4] htlcswitch: remove aux bandwidth check from canSendHtlc We remove the aux bandwidth check from the helper canSendHtlc, which was called from CheckHTLCTransit and CheckHTLCForward (both are methods of the htlcswitch). For forwards we now fail at the link level, following the introduction of the AuxHtlcValidator. For payments, we now may fail either at the pathfinding level, or at the link level. The htlcswitch may no longer fail for aux bandwidth checks. --- htlcswitch/link.go | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 4c81964cc24..f6b29f96afd 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -2605,40 +2605,6 @@ func (l *channelLink) canSendHtlc(policy models.ForwardingPolicy, // forwarded. availableBandwidth := l.Bandwidth() - auxBandwidth, externalErr := fn.MapOptionZ( - l.cfg.AuxTrafficShaper, - func(ts AuxTrafficShaper) fn.Result[OptionalBandwidth] { - var htlcBlob fn.Option[tlv.Blob] - blob, err := customRecords.Serialize() - if err != nil { - return fn.Err[OptionalBandwidth]( - fmt.Errorf("unable to serialize "+ - "custom records: %w", err)) - } - - if len(blob) > 0 { - htlcBlob = fn.Some(blob) - } - - return l.AuxBandwidth(amt, originalScid, htlcBlob, ts) - }, - ).Unpack() - if externalErr != nil { - l.log.Errorf("Unable to determine aux bandwidth: %v", - externalErr) - - return NewLinkError(&lnwire.FailTemporaryNodeFailure{}) - } - - if auxBandwidth.IsHandled && auxBandwidth.Bandwidth.IsSome() { - auxBandwidth.Bandwidth.WhenSome( - func(bandwidth lnwire.MilliSatoshi) { - availableBandwidth = bandwidth - }, - ) - } - - // Check to see if there is enough balance in this channel. if amt > availableBandwidth { l.log.Warnf("insufficient bandwidth to route htlc: %v is "+ "larger than %v", amt, availableBandwidth)