lnwallet: add and use AuxHtlcValidator to lightning channel
What changed, and why it matters
This commit changes how Lightning Network channels check whether a special 'custom' payment (HTLC) can be added. Previously, bandwidth checks for these custom payments happened earlier, while routes were still being planned, and multiple payments could look at the same balance without knowing about each other. The new code moves the final check to the moment just before the payment is committed to the channel, using the most up-to-date balance and a corrected view of pending payments. It also fixes a bug where a height counter used in these checks was being left at zero. The change is defensive and aimed at preventing inconsistent or over-committed custom channel states rather than a classic remote exploit.
Review the corresponding htlcswitch changes that remove canSendHtlc aux checks to ensure no bypass remains, and verify that all custom channel implementations (e.g., tapd-assets traffic shaper) register an AuxHtlcValidator. Consider regression tests for concurrent HTLC additions and NextHeight correctness.
Security signals we found
Moved aux bandwidth validation to a single, synchronous final check before commitment
Fixed NextHeight being silently left at zero in FetchLatestAuxHTLCView
Switched HTLC view to use remote ACKed index from signed local commitment tail for consistency
Removed earlier aux bandwidth checks from htlcswitch forwarding/payment paths
Validator runs only after standard Lightning commitment sanity checks
Evidence from the diff
The patch introduces an AuxHtlcValidator interface and wires it into LightningChannel.addHTLC as a final validation hook. It removes earlier aux bandwidth checks from canSendHtlc/CheckHTLCForward/CheckHTLCTransit in the htlcswitch, so forwards now fail at the link level and payments may fail at pathfinding or link level. FetchLatestAuxHTLCView now sets NextHeight to commitChains.Local.tip().height+1 and uses the remote ACKed index from the last signed local commitment tail for a stable HTLC view. The validator receives current link bandwidth (via availableBalance(NoBuffer)) and an AuxHtlcView built from fetchHTLCView. This is a correctness/reliability fix for custom channels (e.g., Taproot Assets traffic shaper integration) rather than a patch for a known remote code-execution vulnerability.
Changed components
lnwallet/channel.goLightningChannel.addHTLCLightningChannel.FetchLatestAuxHTLCViewChannelOpt / channelOptsAuxHtlcValidator interfaceCustom channel / traffic shaper integrationInspect captured patch +89 / −3
diff --git a/lnwallet/channel.go b/lnwallet/channel.go
index fb1e3ac..02f5f9c 100644
--- a/lnwallet/channel.go
+++ b/lnwallet/channel.go
@@ -833,6 +833,20 @@ type LightningChannel struct {
// is created.
type ChannelOpt func(*channelOpts)
+// AuxHtlcValidator is an interface for validating 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 interface {
+ // ValidateHtlc checks whether the given HTLC can be added to the
+ // channel given the current link bandwidth, custom records, and HTLC
+ // view.
+ ValidateHtlc(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 +856,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 +912,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{}
@@ -2738,9 +2765,21 @@ func (lc *LightningChannel) FetchLatestAuxHTLCView() AuxHtlcView {
lc.RLock()
defer lc.RUnlock()
- return newAuxHtlcView(lc.fetchHTLCView(
- lc.updateLogs.Remote.logIndex, lc.updateLogs.Local.logIndex,
- ))
+ nextHeight := lc.commitChains.Local.tip().height + 1
+
+ // We use the remote ACKed index from the last signed local commitment
+ // (tail) rather than the remote's latest log index. This ensures we
+ // only include remote HTLCs that have been locked into a signed
+ // commitment, giving the aux validator a stable, consistent view that
+ // matches the actual commitment state used for balance calculations.
+ remoteACKedIndex := lc.commitChains.Local.tail().messageIndices.Remote
+ view := lc.fetchHTLCView(
+ remoteACKedIndex, lc.updateLogs.Local.logIndex,
+ )
+
+ view.NextHeight = nextHeight
+
+ return newAuxHtlcView(view)
}
// fetchHTLCView returns all the candidate HTLC updates which should be
@@ -6065,6 +6104,52 @@ func (lc *LightningChannel) addHTLC(htlc *lnwire.UpdateAddHTLC,
return 0, 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.
+ commitChain := lc.commitChains.Local
+ remoteIndex := commitChain.tail().messageIndices.Remote
+ view := lc.fetchHTLCView(
+ remoteIndex, lc.updateLogs.Local.logIndex,
+ )
+
+ nextHeight := lc.commitChains.Local.tip().height + 1
+ view.NextHeight = nextHeight
+
+ lc.log.Debugf("Setting view nextheight=%v", nextHeight)
+
+ auxView := newAuxHtlcView(view)
+
+ // Get the current available balance for the link
+ // bandwidth check. This is needed for the balance
+ // validation in the traffic shaper. We use NoBuffer
+ // since the buffer check was already performed earlier,
+ // and assets don't pay on-chain fees.
+ linkBandwidth, _ := lc.availableBalance(NoBuffer)
+
+ return validator.ValidateHtlc(
+ pd.Amount, linkBandwidth, pd.CustomRecords,
+ auxView,
+ )
+ },
+ )
+ if err != nil {
+ return 0, fmt.Errorf("aux HTLC validation failed: %w", err)
+ }
+
lc.updateLogs.Local.appendHtlc(pd)
return pd.HtlcIndex, nil
@@ -6216,6 +6301,7 @@ func (lc *LightningChannel) htlcAddDescriptor(htlc *lnwire.UpdateAddHTLC,
// remote commitments.
func (lc *LightningChannel) validateAddHtlc(pd *paymentDescriptor,
buffer BufferType) 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
Why this scored 42/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.