Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions lnwallet/aux_signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
187 changes: 171 additions & 16 deletions lnwallet/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow this check - since it's only called when we are processing the Settle, and before that there must be an Add. To lock in that Add, we must have already checked that the channel balance wouldn't drop below the reserve, plus a few other checks such as the fee buffer. So this method should always return true? Unless we don't check for reserves when processing tap HTLCs?

Copy link
Collaborator Author

@GeorgeTsagk GeorgeTsagk Jul 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To lock in that Add, we must have already checked that the channel balance wouldn't drop below the reserve,

This would be for the remote side. If Alice is sending to Bob, here we want to check if Bob is below reserve. Currently if Bob is at 0% (no reserve) we can still send him 1sat even if that doesn't take him above the configured reserve. The noop-add will actually nullify the send only if Bob is sitting above reserve.

This requirement is related to how tap assets work, as we need to make sure we have a sufficient amount in order to anchor off-chain metadata that needs to be present on-chain.

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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This usually means a method should exist on the struct itself.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved it here after addressing a recommendation in a previous comment
#9871 (comment)

Happy to change it again


// 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think this needs to be >=

Copy link
Collaborator Author

@GeorgeTsagk GeorgeTsagk Aug 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it doesn't really matter if we're greater vs greater-equal than the value here, worst case we need to send an extra 1msat to pass the check

What really matters here is that both peers perform the same check with the same values, as there absolutely needs to be an agreement here on whether the check is satisfied (otherwise it would lead to balance disagreements -> conflicting commitments -> force closure)

Added some context around this in the code too


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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

continue
}

lc.recordSettlement(party, u.Amount)
}
}
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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,
)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There isn't an error path here yet, but we should return an error if this type is used for any channel type other than the aux channels. This'll serve to contain the logic a bit.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why error out instead of no-op'ing the noop?

i.e

		if lc.ChanType().HasTapscriptRoot() {
			entryType = NoopAdd
		}

if this for any reason is happening over a normal channel, the TLV field will be ignored and won't change any state whatsoever

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if that happens to a normal channel which does not understand the TLV, you propose just treating it as a normal Add ? But I think we should error the channel if a peer tries to trick us into adding a NOOPADD for a normal channel.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if that happens to a normal channel which does not understand the TLV, you propose just treating it as a normal Add

Yes, if for any reason (maybe even by accident) the noop gets signaled then we should simply revert to old behavior.

But I think we should error the channel if a peer tries to trick us into adding a NOOPADD for a normal channel.

Need to account for potential bugs in software. If for any reason the flag "escapes" the scope of being set only over custom channels we wouldn't want to cause force-closes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why error out instead of no-op'ing the noop?

To make the failure explicit, as it should never happen. If we silently downgrade an unexpected noop HTLC (say from a legacy static key channel), then it may be harder to figure out what happened after the fact.

We can put this check in validateCommitmentSanity, as it's already possible that we'll error out if a peer or the switch sends us a bad/invalid HTLC.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to account for potential bugs in software. If for any reason the flag "escapes" the scope of being set only over custom channels we wouldn't want to cause force-closes.

If that's the case we should let the FC happen so we know something is wrong, otherwise the bug will be sitting there silently forever.

return &paymentDescriptor{
ChanID: htlc.ChanID,
EntryType: Add,
EntryType: entryType,
RHash: PaymentHash(htlc.PaymentHash),
Timeout: htlc.Expiry,
Amount: htlc.Amount,
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -6126,17 +6256,22 @@ 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,
LogIndex: lc.updateLogs.Remote.logIndex,
HtlcIndex: lc.updateLogs.Remote.htlcCounter,
OnionBlob: htlc.OnionBlob,
BlindingPoint: htlc.BlindingPoint,
CustomRecords: htlc.CustomRecords.Copy(),
CustomRecords: customRecords,
}

localACKedIndex := lc.commitChains.Remote.tail().messageIndices.Local
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 " +
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make this an error since it's dangerous for the receiver? IIUC in tap chan we can also send normal HTLCs, I'm wondering if the receiver can refuse receiving noop Adds, say via a config. Or is there a way to check that if it's sending normal htlcs we disable it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One important thing to keep in mind here is that all of the code introduced in this PR is not used unless external software (i.e tapd in our case) explicitly tells LND to treat this as a noop HTLC (via the noop TLV). The control guards around whether the TLV is set lies externally.

Specifically we only set it if the intent of the tap payment is clearly to send assets and not sats. If a normal (sats) payment is initiated we don't set the flag.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if the receiver can refuse receiving noop Adds, say via a config

If we sign as if we're doing it, but they don't, then that leads to a force close just like if any of the other channel rendering attributes are ignored.

Or is there a way to check that if it's sending normal htlcs we disable it?

When this rolls out, there'll be a new feature bit used to signal if both sides understand the feature.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should return an error here instead.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't really see why this is needed:

  • Assuming that both parties run LND and the same checks then both will result in treating this as a normal HTLC. This code line here is purely informational, it's not that we simply log if the remote party tried "cheating". The noop flag only signals the intention for an HTLC to be treated as noop, but the checks may render it a normal HTLC -- and that is the above case, an HTLC that set the noop flag ended up being reverted to a normal HTLC and we just let the user know. If on the other hand the two parties run different software and disagree on whether an HTLC should be a noop, then they'd force close, and that's also something we'll try to avoid.

  • Returning an error here means we allow for a couple of call sites to fail for a not really crucial reason. It is possible that the intention to create a noop is signaled, but the criteria are just not met. I'm not confident that we should error out all these call sites for something that is predicted behavior: diskHtlcToPayDesc, logUpdateToPayDesc, remoteLogUpdateToPayDesc, htlcAddDescriptor, ReceiveHTLC

"doesn't have a tapscript root")
}

return Add
}
13 changes: 13 additions & 0 deletions lnwallet/payment_descriptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}