What changed, and why it matters
This commit adds a new 'no-op HTLC' feature to LND's lightning channel state machine. A no-op HTLC is a special payment-like message that, when settled, may credit the amount back to the sender rather than transferring it to the receiver. It is only intended for channels using a custom tapscript root and is triggered by external software via custom TLV records. The change touches balance calculations, settlement logic, and how HTLCs are restored from disk. Because it alters fundamental accounting rules, any bug in the logic could affect funds, but the commit itself is a feature addition with guardrails (reserve checks, tapscript-root gating) rather than a clear-cut fix for a known vulnerability.
Treat this as a high-risk feature change requiring careful review of the reserve-check arithmetic and the no-op settle flag propagation. Test edge cases around force closures, revocation, and state restoration where NoOpAdd HTLCs are reloaded from disk. If running LND with custom asset channels, ensure this commit is included and monitor for follow-up fixes.
Security signals we found
Alters core channel balance accounting and settlement flow
Introduces new HTLC entry type that can reverse amount direction on settle
Gated by channel reserve check and tapscript-root channel type
Custom TLV presence alone does not trigger no-op unless channel has tapscript root
Changes multiple EntryType == Add comparisons to isAdd() helper, expanding code paths that treat NoOpAdd as an add
No explicit security advisory, CVE, or incident disclosure supplied
Evidence from the diff
The patch introduces a new updateType ‘NoOpAdd’ and a custom TLV type (65544) to mark HTLCs as no-op. When an update_add_htlc carries this TLV and the channel has a tapscript root, the paymentDescriptor’s EntryType becomes NoOpAdd. During HTLC view evaluation, if a NoOpAdd is settled and the receiver is above the channel reserve, the amount is credited back to the sender (entry.noOpSettle is set). The logic is gated by balanceAboveReserve, which uses local/remote commitment balances and the running balance delta. Several existing checks that tested EntryType == Add now use a new isAdd() helper that includes NoOpAdd. The feature is intended for use with AuxComponents/external asset protocols.
Changed components
lnwallet/channel.golnwallet/payment_descriptor.golnwallet/aux_signer.goLightningChannel state machineHTLC view evaluation / settlement logicAuxComponents / custom tapscript root channelsInspect captured patch +198 / −19
diff --git a/lnwallet/aux_signer.go b/lnwallet/aux_signer.go
index 042d322..79a7ca1 100644
--- a/lnwallet/aux_signer.go
+++ b/lnwallet/aux_signer.go
@@ -10,9 +10,20 @@ import (
"github.com/lightningnetwork/lnd/tlv"
)
-// htlcCustomSigType is the TLV type that is used to encode the custom HTLC
-// signatures within the custom data for an existing HTLC.
-var htlcCustomSigType tlv.TlvType65543
+var (
+ // htlcCustomSigType is the TLV type that is used to encode the custom
+ // HTLC signatures within the custom data for an existing HTLC.
+ htlcCustomSigType tlv.TlvType65543
+
+ // NoOpHtlcTLVEntry is the TLV that that's used in the update_add_htlc
+ // message to indicate the presence of a noop HTLC. This has no encoded
+ // value, its presence is used to indicate that the HTLC is a noop.
+ NoOpHtlcTLVEntry tlv.TlvType65544
+)
+
+// NoOpHtlcTLVType is the (golang) type of the TLV record that's used to signal
+// that an HTLC should be a noop HTLC.
+type NoOpHtlcTLVType = tlv.TlvType65544
// AuxHtlcView is a struct that contains a safe copy of an HTLC view that can
// be used by aux components.
diff --git a/lnwallet/channel.go b/lnwallet/channel.go
index ee45bf9..29de016 100644
--- a/lnwallet/channel.go
+++ b/lnwallet/channel.go
@@ -551,6 +551,12 @@ func (lc *LightningChannel) diskHtlcToPayDesc(feeRate chainfee.SatPerKWeight,
remoteOutputIndex = htlc.OutputIndex
}
+ customRecords := htlc.CustomRecords.Copy()
+
+ entryType := lc.entryTypeForHtlc(
+ customRecords, lc.channelState.ChanType,
+ )
+
// With the scripts reconstructed (depending on if this is our commit
// vs theirs or a pending commit for the remote party), we can now
// re-create the original payment descriptor.
@@ -559,7 +565,7 @@ func (lc *LightningChannel) diskHtlcToPayDesc(feeRate chainfee.SatPerKWeight,
RHash: htlc.RHash,
Timeout: htlc.RefundTimeout,
Amount: htlc.Amt,
- EntryType: Add,
+ EntryType: entryType,
HtlcIndex: htlc.HtlcIndex,
LogIndex: htlc.LogIndex,
OnionBlob: htlc.OnionBlob,
@@ -570,7 +576,7 @@ func (lc *LightningChannel) diskHtlcToPayDesc(feeRate chainfee.SatPerKWeight,
theirPkScript: theirP2WSH,
theirWitnessScript: theirWitnessScript,
BlindingPoint: htlc.BlindingPoint,
- CustomRecords: htlc.CustomRecords.Copy(),
+ CustomRecords: customRecords,
}, nil
}
@@ -1100,6 +1106,10 @@ func (lc *LightningChannel) logUpdateToPayDesc(logUpdate *channeldb.LogUpdate,
},
}
+ pd.EntryType = lc.entryTypeForHtlc(
+ pd.CustomRecords, lc.channelState.ChanType,
+ )
+
isDustRemote := HtlcIsDust(
lc.channelState.ChanType, false, lntypes.Remote,
feeRate, wireMsg.Amount.ToSatoshis(), remoteDustLimit,
@@ -1336,6 +1346,10 @@ func (lc *LightningChannel) remoteLogUpdateToPayDesc(logUpdate *channeldb.LogUpd
},
}
+ pd.EntryType = lc.entryTypeForHtlc(
+ pd.CustomRecords, lc.channelState.ChanType,
+ )
+
// We don't need to generate an htlc script yet. This will be
// done once we sign our remote commitment.
@@ -1736,7 +1750,7 @@ func (lc *LightningChannel) restorePendingRemoteUpdates(
// but this Add restoration was a no-op as every single one of
// these Adds was already restored since they're all incoming
// htlcs on the local commitment.
- if payDesc.EntryType == Add {
+ if payDesc.isAdd() {
continue
}
@@ -1881,7 +1895,7 @@ func (lc *LightningChannel) restorePendingLocalUpdates(
}
switch payDesc.EntryType {
- case Add:
+ case Add, NoOpAdd:
// The HtlcIndex of the added HTLC _must_ be equal to
// the log's htlcCounter at this point. If it is not we
// panic to catch this.
@@ -2993,6 +3007,22 @@ func (lc *LightningChannel) evaluateHTLCView(view *HtlcView,
)
if rmvHeight == 0 {
switch {
+ // If this a noop add, then when we settle the
+ // HTLC, we may credit the sender with the
+ // amount again, thus making it a noop. Noop
+ // HTLCs are only triggered by external software
+ // using the AuxComponents and only for channels
+ // that use the custom tapscript root. The
+ // criteria about whether the noop will be
+ // effective is whether the receiver is already
+ // sitting above reserve.
+ case entry.EntryType == Settle &&
+ addEntry.EntryType == NoOpAdd:
+
+ lc.evaluateNoOpHtlc(
+ entry, party, &balanceDeltas,
+ )
+
// If an incoming HTLC is being settled, then
// this means that the preimage has been
// received by the settling party Therefore, we
@@ -3030,7 +3060,7 @@ func (lc *LightningChannel) evaluateHTLCView(view *HtlcView,
liveAdds := fn.Filter(
view.Updates.GetForParty(party),
func(pd *paymentDescriptor) bool {
- isAdd := pd.EntryType == Add
+ isAdd := pd.isAdd()
shouldSkip := skip.GetForParty(party).
Contains(pd.HtlcIndex)
@@ -3069,7 +3099,7 @@ func (lc *LightningChannel) evaluateHTLCView(view *HtlcView,
// corresponding to whoseCommitmentChain.
isUncommitted := func(update *paymentDescriptor) bool {
switch update.EntryType {
- case Add:
+ case Add, NoOpAdd:
return update.addCommitHeights.GetForParty(
whoseCommitChain,
) == 0
@@ -3145,6 +3175,92 @@ func (lc *LightningChannel) fetchParent(entry *paymentDescriptor,
return addEntry, nil
}
+// balanceAboveReserve checks if the balance for the provided party is above the
+// configured reserve. It also uses the balance delta for the party, to account
+// for entry amounts that have been processed already.
+func balanceAboveReserve(party lntypes.ChannelParty, delta int64,
+ channel *channeldb.OpenChannel) bool {
+
+ // We're going to access the channel state, so let's make sure we're
+ // holding the lock.
+ channel.RLock()
+ defer channel.RUnlock()
+
+ // For calculating whether a party is above reserve we are going to
+ // use the channel state local/remote balance of the corresponding
+ // commitment. This balance corresponds to the balance of each party
+ // after the most recent revocation. That's the balance on top of which
+ // we may apply the balance delta of the currently processed HTLCs. It
+ // is important for the calculated balance to match between us and our
+ // peer, as any disagreement over the balances here can lead to a force
+ // closure.
+ c := channel
+
+ localReserve := lnwire.NewMSatFromSatoshis(c.LocalChanCfg.ChanReserve)
+ remoteReserve := lnwire.NewMSatFromSatoshis(c.RemoteChanCfg.ChanReserve)
+
+ switch {
+ case party.IsLocal():
+ // For the local party we'll consult the local balance of the
+ // local commitment. Then we'll correctly add the delta based on
+ // whether it's negative or not.
+ totalLocal := c.LocalCommitment.LocalBalance
+ if delta >= 0 {
+ totalLocal += lnwire.MilliSatoshi(delta)
+ } else {
+ totalLocal -= lnwire.MilliSatoshi(-1 * delta)
+ }
+
+ return totalLocal > localReserve
+
+ case party.IsRemote():
+ // For the remote party we'll consult the remote balance of the
+ // remote commitment. Then we'll correctly add the delta based
+ // on whether it's negative or not.
+ totalRemote := c.RemoteCommitment.RemoteBalance
+ if delta >= 0 {
+ totalRemote += lnwire.MilliSatoshi(delta)
+ } else {
+ totalRemote -= lnwire.MilliSatoshi(-1 * delta)
+ }
+
+ return totalRemote > remoteReserve
+ }
+
+ return false
+}
+
+// evaluateNoOpHtlc applies the balance delta based on whether the NoOp HTLC is
+// considered effective. This depends on whether the receiver is already above
+// the channel reserve.
+func (lc *LightningChannel) evaluateNoOpHtlc(entry *paymentDescriptor,
+ party lntypes.ChannelParty, balanceDeltas *lntypes.Dual[int64]) {
+
+ channel := lc.channelState
+ delta := balanceDeltas.GetForParty(party)
+
+ // If the receiver has existing balance above reserve then we go ahead
+ // with crediting the amount back to the sender. Otherwise we give the
+ // amount to the receiver. We do this because the receiver needs some
+ // above reserve balance to anchor the AuxBlob. We also pass in the so
+ // far calculated delta for the party, as that's effectively part of
+ // their balance within this view computation.
+ if balanceAboveReserve(party, delta, channel) {
+ party = party.CounterParty()
+
+ // The noop is effective, meaning that the settlement will
+ // credit the amount back to the sender. Let's mark this as it
+ // may be needed later when processing the settle entry, where
+ // we won't be able to perform the above check again.
+ entry.noOpSettle = true
+ }
+
+ d := int64(entry.Amount)
+ balanceDeltas.ModifyForParty(party, func(acc int64) int64 {
+ return acc + d
+ })
+}
+
// generateRemoteHtlcSigJobs generates a series of HTLC signature jobs for the
// sig pool, along with a channel that if closed, will cancel any jobs after
// they have been submitted to the sigPool. This method is to be used when
@@ -3833,7 +3949,7 @@ func (lc *LightningChannel) validateCommitmentSanity(theirLogCounter,
// Go through all updates, checking that they don't violate the
// channel constraints.
for _, entry := range updates {
- if entry.EntryType == Add {
+ if entry.isAdd() {
// An HTLC is being added, this will add to the
// number and amount in flight.
amtInFlight += entry.Amount
@@ -4668,6 +4784,15 @@ func (lc *LightningChannel) computeView(view *HtlcView,
if whoseCommitChain == lntypes.Local &&
u.EntryType == Settle {
+ // If this settle was a result of an
+ // effective noop add entry, then we
+ // don't need to record the amount as it
+ // was never sent over to the other
+ // side.
+ if u.noOpSettle {
+ continue
+ }
+
lc.recordSettlement(party, u.Amount)
}
}
@@ -5712,7 +5837,7 @@ func (lc *LightningChannel) ReceiveRevocation(revMsg *lnwire.RevokeAndAck) (
// don't re-forward any already processed HTLC's after a
// restart.
switch {
- case pd.EntryType == Add && committedAdd && shouldFwdAdd:
+ case pd.isAdd() && committedAdd && shouldFwdAdd:
// Construct a reference specifying the location that
// this forwarded Add will be written in the forwarding
// package constructed at this remote height.
@@ -5731,7 +5856,7 @@ func (lc *LightningChannel) ReceiveRevocation(revMsg *lnwire.RevokeAndAck) (
addUpdatesToForward, pd.toLogUpdate(),
)
- case pd.EntryType != Add && committedRmv && shouldFwdRmv:
+ case !pd.isAdd() && committedRmv && shouldFwdRmv:
// Construct a reference specifying the location that
// this forwarded Settle/Fail will be written in the
// forwarding package constructed at this remote height.
@@ -5970,7 +6095,7 @@ func (lc *LightningChannel) GetDustSum(whoseCommit lntypes.ChannelParty,
// Grab all of our HTLCs and evaluate against the dust limit.
for e := lc.updateLogs.Local.Front(); e != nil; e = e.Next() {
pd := e.Value
- if pd.EntryType != Add {
+ if !pd.isAdd() {
continue
}
@@ -5989,7 +6114,7 @@ func (lc *LightningChannel) GetDustSum(whoseCommit lntypes.ChannelParty,
// Grab all of their HTLCs and evaluate against the dust limit.
for e := lc.updateLogs.Remote.Front(); e != nil; e = e.Next() {
pd := e.Value
- if pd.EntryType != Add {
+ if !pd.isAdd() {
continue
}
@@ -6062,9 +6187,14 @@ func (lc *LightningChannel) MayAddOutgoingHtlc(amt lnwire.MilliSatoshi) error {
func (lc *LightningChannel) htlcAddDescriptor(htlc *lnwire.UpdateAddHTLC,
openKey *models.CircuitKey) *paymentDescriptor {
+ customRecords := htlc.CustomRecords.Copy()
+ entryType := lc.entryTypeForHtlc(
+ customRecords, lc.channelState.ChanType,
+ )
+
return &paymentDescriptor{
ChanID: htlc.ChanID,
- EntryType: Add,
+ EntryType: entryType,
RHash: PaymentHash(htlc.PaymentHash),
Timeout: htlc.Expiry,
Amount: htlc.Amount,
@@ -6073,7 +6203,7 @@ func (lc *LightningChannel) htlcAddDescriptor(htlc *lnwire.UpdateAddHTLC,
OnionBlob: htlc.OnionBlob,
OpenCircuitKey: openKey,
BlindingPoint: htlc.BlindingPoint,
- CustomRecords: htlc.CustomRecords.Copy(),
+ CustomRecords: customRecords,
}
}
@@ -6126,9 +6256,14 @@ func (lc *LightningChannel) ReceiveHTLC(htlc *lnwire.UpdateAddHTLC) (uint64,
lc.updateLogs.Remote.htlcCounter)
}
+ customRecords := htlc.CustomRecords.Copy()
+ entryType := lc.entryTypeForHtlc(
+ customRecords, lc.channelState.ChanType,
+ )
+
pd := &paymentDescriptor{
ChanID: htlc.ChanID,
- EntryType: Add,
+ EntryType: entryType,
RHash: PaymentHash(htlc.PaymentHash),
Timeout: htlc.Expiry,
Amount: htlc.Amount,
@@ -6136,7 +6271,7 @@ func (lc *LightningChannel) ReceiveHTLC(htlc *lnwire.UpdateAddHTLC) (uint64,
HtlcIndex: lc.updateLogs.Remote.htlcCounter,
OnionBlob: htlc.OnionBlob,
BlindingPoint: htlc.BlindingPoint,
- CustomRecords: htlc.CustomRecords.Copy(),
+ CustomRecords: customRecords,
}
localACKedIndex := lc.commitChains.Remote.tail().messageIndices.Local
@@ -9825,7 +9960,7 @@ func (lc *LightningChannel) unsignedLocalUpdates(remoteMessageIndex,
// We don't save add updates as they are restored from the
// remote commitment in restoreStateLogs.
- if pd.EntryType == Add {
+ if pd.isAdd() {
continue
}
@@ -9999,3 +10134,23 @@ func (lc *LightningChannel) ZeroConfRealScid() fn.Option[lnwire.ShortChannelID]
return fn.None[lnwire.ShortChannelID]()
}
+
+// entryTypeForHtlc returns the add type that should be used for adding this
+// HTLC to the channel. If the channel has a tapscript root and the HTLC carries
+// the NoOp bit in the custom records then we'll convert this to a NoOp add.
+func (lc *LightningChannel) entryTypeForHtlc(records lnwire.CustomRecords,
+ chanType channeldb.ChannelType) updateType {
+
+ noopTLV := uint64(NoOpHtlcTLVEntry.TypeVal())
+ _, noopFlag := records[noopTLV]
+ if noopFlag && chanType.HasTapscriptRoot() {
+ return NoOpAdd
+ }
+
+ if noopFlag && !chanType.HasTapscriptRoot() {
+ lc.log.Warnf("Received flag for noop-add over a channel that " +
+ "doesn't have a tapscript root")
+ }
+
+ return Add
+}
diff --git a/lnwallet/payment_descriptor.go b/lnwallet/payment_descriptor.go
index 3f4b9dd..944749b 100644
--- a/lnwallet/payment_descriptor.go
+++ b/lnwallet/payment_descriptor.go
@@ -225,6 +225,14 @@ type paymentDescriptor struct {
// into the log to the HTLC being modified.
EntryType updateType
+ // noOpSettle is a flag indicating whether a chain of entries resulted
+ // in an effective no-op settle. That means that the amount was credited
+ // back to the sender. This is useful as we need a way to mark whether
+ // the noop add was effective, which can be useful at later stages,
+ // where we might not be able to re-run the criteria for the
+ // effectiveness of the noop-add.
+ noOpSettle bool
+
// isForwarded denotes if an incoming HTLC has been forwarded to any
// possible upstream peers in the route.
isForwarded bool
@@ -320,3 +328,8 @@ func (pd *paymentDescriptor) setCommitHeight(
)
}
}
+
+// isAdd returns true if the paymentDescriptor is of type Add.
+func (pd *paymentDescriptor) isAdd() bool {
+ return pd.EntryType == Add || pd.EntryType == NoOpAdd
+}
Why this scored 43/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.