From 138b7165190890e7cb6c7218c01287092cbc5ed7 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Wed, 11 Jun 2025 14:53:48 +0200 Subject: [PATCH 1/7] lnwallet: add noop updateType to paymendDescriptor We add a new update type to the payment descriptor to describe this new type of htlc. This type of HTLC will only end up being set if explicitly signalled by external software. --- lnwallet/payment_descriptor.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lnwallet/payment_descriptor.go b/lnwallet/payment_descriptor.go index 49b79a139dc..3f4b9dd5be3 100644 --- a/lnwallet/payment_descriptor.go +++ b/lnwallet/payment_descriptor.go @@ -42,6 +42,13 @@ const ( // FeeUpdate is an update type sent by the channel initiator that // updates the fee rate used when signing the commitment transaction. FeeUpdate + + // NoOpAdd is an update type that adds a new HTLC entry into the log. + // This differs from the normal Add type, in that when settled the + // balance may go back to the sender, rather than be credited for the + // receiver. The criteria about whether the balance will go back to the + // sender is whether the receiver is sitting above the channel reserve. + NoOpAdd ) // String returns a human readable string that uniquely identifies the target @@ -58,6 +65,8 @@ func (u updateType) String() string { return "Settle" case FeeUpdate: return "FeeUpdate" + case NoOpAdd: + return "NoOpAdd" default: return "" } @@ -238,7 +247,7 @@ type paymentDescriptor struct { func (pd *paymentDescriptor) toLogUpdate() channeldb.LogUpdate { var msg lnwire.Message switch pd.EntryType { - case Add: + case Add, NoOpAdd: msg = &lnwire.UpdateAddHTLC{ ChanID: pd.ChanID, ID: pd.HtlcIndex, @@ -290,7 +299,7 @@ func (pd *paymentDescriptor) setCommitHeight( whoseCommitChain lntypes.ChannelParty, nextHeight uint64) { switch pd.EntryType { - case Add: + case Add, NoOpAdd: pd.addCommitHeights.SetForParty( whoseCommitChain, nextHeight, ) From aacefb9055f24620eaaa63486ad1bb9a727224d3 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 27 May 2025 13:22:52 +0200 Subject: [PATCH 2/7] lnwallet: add IsAdd helper to AuxHtlcDescriptor We also add the IsAdd helper to the AuxHtlcDescriptor, as external software using the aux framework might want to know which type of HTLC this is. --- lnwallet/aux_signer.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lnwallet/aux_signer.go b/lnwallet/aux_signer.go index 90a4325f60e..042d3223e16 100644 --- a/lnwallet/aux_signer.go +++ b/lnwallet/aux_signer.go @@ -116,6 +116,18 @@ func (a *AuxHtlcDescriptor) AddHeight( return a.addCommitHeightLocal } +// IsAdd checks if the entry type of the Aux HTLC Descriptor is an add type. +func (a *AuxHtlcDescriptor) IsAdd() bool { + switch a.EntryType { + case Add: + fallthrough + case NoOpAdd: + return true + default: + return false + } +} + // RemoveHeight returns the height at which the HTLC was removed from the // commitment chain. The height is returned based on the chain the HTLC is being // removed from (local or remote chain). From a5a15f64015a6661da33a12d850bf465177035cc Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 27 May 2025 13:34:10 +0200 Subject: [PATCH 3/7] lnwallet: detect and handle noop HTLCs We update the lightning channel state machine in some key areas. If the noop TLV is set in the update_add_htlc custom records then we change the entry type to noop. When settling the HTLC if the type is noop we credit the satoshi amount back to the sender. --- lnwallet/aux_signer.go | 17 ++- lnwallet/channel.go | 187 ++++++++++++++++++++++++++++++--- lnwallet/payment_descriptor.go | 13 +++ 3 files changed, 198 insertions(+), 19 deletions(-) diff --git a/lnwallet/aux_signer.go b/lnwallet/aux_signer.go index 042d3223e16..79a7ca1dc09 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 ee45bf943d7..29de01618ee 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 3f4b9dd5be3..944749bde9f 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 +} From e0486697df4ba4a74568cc80d399dae0e63e8061 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 27 May 2025 13:36:35 +0200 Subject: [PATCH 4/7] lnwallet: add noop HTLC tests Adds some simple tests to check the noop HTLC logic of the lightning channel. --- lnwallet/channel_test.go | 140 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go index 0a0ca261c02..c37d0596142 100644 --- a/lnwallet/channel_test.go +++ b/lnwallet/channel_test.go @@ -11339,3 +11339,143 @@ func TestCreateCooperativeCloseTx(t *testing.T) { }) } } + +// TestNoopAddSettle tests that adding and settling an HTLC with no-op, no +// balances are actually affected. +func TestNoopAddSettle(t *testing.T) { + t.Parallel() + + // Create a test channel which will be used for the duration of this + // unittest. The channel will be funded evenly with Alice having 5 BTC, + // and Bob having 5 BTC. + chanType := channeldb.SimpleTaprootFeatureBit | + channeldb.AnchorOutputsBit | channeldb.ZeroHtlcTxFeeBit | + channeldb.SingleFunderTweaklessBit | channeldb.TapscriptRootBit + aliceChannel, bobChannel, err := CreateTestChannels( + t, chanType, + ) + require.NoError(t, err, "unable to create test channels") + + const htlcAmt = 10_000 + htlc, preimage := createHTLC(0, htlcAmt) + noopRecord := tlv.NewPrimitiveRecord[tlv.TlvType65544, bool](true) + + records, err := tlv.RecordsToMap([]tlv.Record{noopRecord.Record()}) + require.NoError(t, err) + htlc.CustomRecords = records + + aliceBalance := aliceChannel.channelState.LocalCommitment.LocalBalance + bobBalance := bobChannel.channelState.LocalCommitment.LocalBalance + + // Have Alice add the HTLC, then lock it in with a new state transition. + aliceHtlcIndex, err := aliceChannel.AddHTLC(htlc, nil) + require.NoError(t, err, "alice unable to add htlc") + bobHtlcIndex, err := bobChannel.ReceiveHTLC(htlc) + require.NoError(t, err, "bob unable to receive htlc") + + err = ForceStateTransition(aliceChannel, bobChannel) + require.NoError(t, err) + + // We'll have Bob settle the HTLC, then force another state transition. + err = bobChannel.SettleHTLC(preimage, bobHtlcIndex, nil, nil, nil) + require.NoError(t, err, "bob unable to settle inbound htlc") + err = aliceChannel.ReceiveHTLCSettle(preimage, aliceHtlcIndex) + require.NoError(t, err) + + err = ForceStateTransition(aliceChannel, bobChannel) + require.NoError(t, err) + + aliceBalanceFinal := aliceChannel.channelState.LocalCommitment.LocalBalance //nolint:ll + bobBalanceFinal := bobChannel.channelState.LocalCommitment.LocalBalance + + // The balances of Alice and Bob should be the exact same and shouldn't + // have changed. + require.Equal(t, aliceBalance, aliceBalanceFinal) + require.Equal(t, bobBalance, bobBalanceFinal) +} + +// TestNoopAddBelowReserve tests that the noop HTLCs behave as expected when +// added over a channel where a party is below their reserve. +func TestNoopAddBelowReserve(t *testing.T) { + t.Parallel() + + // Create a test channel which will be used for the duration of this + // unittest. The channel will be funded evenly with Alice having 5 BTC, + // and Bob having 5 BTC. + chanType := channeldb.SimpleTaprootFeatureBit | + channeldb.AnchorOutputsBit | channeldb.ZeroHtlcTxFeeBit | + channeldb.SingleFunderTweaklessBit | channeldb.TapscriptRootBit + aliceChan, bobChan, err := CreateTestChannels(t, chanType) + require.NoError(t, err, "unable to create test channels") + + aliceBalance := aliceChan.channelState.LocalCommitment.LocalBalance + bobBalance := bobChan.channelState.LocalCommitment.LocalBalance + + const ( + // htlcAmt is the default HTLC amount to be used, epxressed in + // milli-satoshis. + htlcAmt = lnwire.MilliSatoshi(500_000) + + // numHtlc is the total number of HTLCs to be added/settled over + // the channel. + numHtlc = 20 + ) + + // Let's create the noop add TLV record to be used in all added HTLCs + // over the channel. + noopRecord := tlv.NewPrimitiveRecord[NoOpHtlcTLVType, bool](true) + records, err := tlv.RecordsToMap([]tlv.Record{noopRecord.Record()}) + require.NoError(t, err) + + // Let's set Bob's reserve to whatever his local balance is, plus half + // of the total amount to be added by the total HTLCs. This way we can + // also verify that the noop-adds will start the nullification only once + // Bob is above reserve. + reserveTarget := (numHtlc / 2) * htlcAmt + bobReserve := bobBalance + reserveTarget + + bobChan.channelState.LocalChanCfg.ChanReserve = + bobReserve.ToSatoshis() + + aliceChan.channelState.RemoteChanCfg.ChanReserve = + bobReserve.ToSatoshis() + + // Add and settle all the HTLCs over the channel. + for i := range numHtlc { + htlc, preimage := createHTLC(i, htlcAmt) + htlc.CustomRecords = records + + aliceHtlcIndex, err := aliceChan.AddHTLC(htlc, nil) + require.NoError(t, err, "alice unable to add htlc") + bobHtlcIndex, err := bobChan.ReceiveHTLC(htlc) + require.NoError(t, err, "bob unable to receive htlc") + + require.NoError(t, ForceStateTransition(aliceChan, bobChan)) + + // We'll have Bob settle the HTLC, then force another state + // transition. + err = bobChan.SettleHTLC(preimage, bobHtlcIndex, nil, nil, nil) + require.NoError(t, err, "bob unable to settle inbound htlc") + err = aliceChan.ReceiveHTLCSettle(preimage, aliceHtlcIndex) + require.NoError(t, err) + require.NoError(t, ForceStateTransition(aliceChan, bobChan)) + } + + // We need to kick the state transition one last time for the balances + // to be updated on both commitments. + require.NoError(t, ForceStateTransition(aliceChan, bobChan)) + + aliceBalanceFinal := aliceChan.channelState.LocalCommitment.LocalBalance + bobBalanceFinal := bobChan.channelState.LocalCommitment.LocalBalance + + // The balances of Alice and Bob must have changed exactly by half the + // total number of HTLCs we added over the channel, plus one to get Bob + // above the reserve. Bob's final balance should be as much as his + // reserve plus one extra default HTLC amount. + require.Equal(t, aliceBalance-htlcAmt*(numHtlc/2+1), aliceBalanceFinal) + require.Equal(t, bobBalance+htlcAmt*(numHtlc/2+1), bobBalanceFinal) + require.Equal( + t, bobBalanceFinal.ToSatoshis(), + bobChan.LocalChanReserve()+htlcAmt.ToSatoshis(), + ) +} From 4a34f78fbad4250b6e3eb9986880e732f52d3c87 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Wed, 25 Jun 2025 14:05:52 +0200 Subject: [PATCH 5/7] lnwallet: add noop case to retransmit test To make sure we don't cause force-closures because of commit sig mismatches, we add a test case to verify that the retransmitted HTLC matches the original HTLC. --- lnwallet/channel_test.go | 82 +++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go index c37d0596142..d2800dd01b3 100644 --- a/lnwallet/channel_test.go +++ b/lnwallet/channel_test.go @@ -3232,7 +3232,9 @@ func restartChannel(channelOld *LightningChannel) (*LightningChannel, error) { // he receives Alice's CommitSig message, then Alice concludes that she needs // to re-send the CommitDiff. After the diff has been sent, both nodes should // resynchronize and be able to complete the dangling commit. -func testChanSyncOweCommitment(t *testing.T, chanType channeldb.ChannelType) { +func testChanSyncOweCommitment(t *testing.T, + chanType channeldb.ChannelType, noop bool) { + // Create a test channel which will be used for the duration of this // unittest. The channel will be funded evenly with Alice having 5 BTC, // and Bob having 5 BTC. @@ -3242,6 +3244,17 @@ func testChanSyncOweCommitment(t *testing.T, chanType channeldb.ChannelType) { var fakeOnionBlob [lnwire.OnionPacketSize]byte copy(fakeOnionBlob[:], bytes.Repeat([]byte{0x05}, lnwire.OnionPacketSize)) + // Let's create the noop add TLV record. This will only be + // effective for channels that have a tapscript root. + noopRecord := tlv.NewPrimitiveRecord[NoOpHtlcTLVType, bool](true) + records, err := tlv.RecordsToMap([]tlv.Record{noopRecord.Record()}) + require.NoError(t, err) + + // If the noop flag is not set for this test, nullify the records. + if !noop { + records = nil + } + // We'll start off the scenario with Bob sending 3 HTLC's to Alice in a // single state update. htlcAmt := lnwire.NewMSatFromSatoshis(20000) @@ -3251,10 +3264,11 @@ func testChanSyncOweCommitment(t *testing.T, chanType channeldb.ChannelType) { for i := 0; i < 3; i++ { rHash := sha256.Sum256(bobPreimage[:]) h := &lnwire.UpdateAddHTLC{ - PaymentHash: rHash, - Amount: htlcAmt, - Expiry: uint32(10), - OnionBlob: fakeOnionBlob, + PaymentHash: rHash, + Amount: htlcAmt, + Expiry: uint32(10), + OnionBlob: fakeOnionBlob, + CustomRecords: records, } htlcIndex, err := bobChannel.AddHTLC(h, nil) @@ -3290,15 +3304,17 @@ func testChanSyncOweCommitment(t *testing.T, chanType channeldb.ChannelType) { t.Fatalf("unable to settle htlc: %v", err) } } + var alicePreimage [32]byte copy(alicePreimage[:], bytes.Repeat([]byte{0xaa}, 32)) rHash := sha256.Sum256(alicePreimage[:]) aliceHtlc := &lnwire.UpdateAddHTLC{ - ChanID: chanID, - PaymentHash: rHash, - Amount: htlcAmt, - Expiry: uint32(10), - OnionBlob: fakeOnionBlob, + ChanID: chanID, + PaymentHash: rHash, + Amount: htlcAmt, + Expiry: uint32(10), + OnionBlob: fakeOnionBlob, + CustomRecords: records, } aliceHtlcIndex, err := aliceChannel.AddHTLC(aliceHtlc, nil) require.NoError(t, err, "unable to add alice's htlc") @@ -3519,22 +3535,25 @@ func testChanSyncOweCommitment(t *testing.T, chanType channeldb.ChannelType) { // At this point, the final balances of both parties should properly // reflect the amount of HTLC's sent. - bobMsatSent := numBobHtlcs * htlcAmt - if aliceChannel.channelState.TotalMSatSent != htlcAmt { - t.Fatalf("wrong value for msat sent: expected %v, got %v", - htlcAmt, aliceChannel.channelState.TotalMSatSent) - } - if aliceChannel.channelState.TotalMSatReceived != bobMsatSent { - t.Fatalf("wrong value for msat recv: expected %v, got %v", - bobMsatSent, aliceChannel.channelState.TotalMSatReceived) - } - if bobChannel.channelState.TotalMSatSent != bobMsatSent { - t.Fatalf("wrong value for msat sent: expected %v, got %v", - bobMsatSent, bobChannel.channelState.TotalMSatSent) - } - if bobChannel.channelState.TotalMSatReceived != htlcAmt { - t.Fatalf("wrong value for msat recv: expected %v, got %v", - htlcAmt, bobChannel.channelState.TotalMSatReceived) + if noop { + // If this test-case includes noop HTLCs, then we don't expect + // any balance changes. + require.Zero(t, aliceChannel.channelState.TotalMSatSent) + require.Zero(t, aliceChannel.channelState.TotalMSatReceived) + require.Zero(t, bobChannel.channelState.TotalMSatSent) + require.Zero(t, bobChannel.channelState.TotalMSatReceived) + } else { + // Otherwise, calculate the expected changes and assert them. + bobMsatSent := numBobHtlcs * htlcAmt + + aliceChan := aliceChannel.channelState + bobChan := bobChannel.channelState + + require.Equal(t, aliceChan.TotalMSatSent, htlcAmt) + require.Equal(t, aliceChan.TotalMSatReceived, bobMsatSent) + + require.Equal(t, bobChan.TotalMSatSent, bobMsatSent) + require.Equal(t, bobChan.TotalMSatReceived, htlcAmt) } } @@ -3548,6 +3567,7 @@ func TestChanSyncOweCommitment(t *testing.T) { testCases := []struct { name string chanType channeldb.ChannelType + noop bool }{ { name: "tweakless", @@ -3571,10 +3591,18 @@ func TestChanSyncOweCommitment(t *testing.T) { channeldb.SimpleTaprootFeatureBit | channeldb.TapscriptRootBit, }, + { + name: "tapscript root with noop", + chanType: channeldb.SingleFunderTweaklessBit | + channeldb.AnchorOutputsBit | + channeldb.SimpleTaprootFeatureBit | + channeldb.TapscriptRootBit, + noop: true, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - testChanSyncOweCommitment(t, tc.chanType) + testChanSyncOweCommitment(t, tc.chanType, tc.noop) }) } } From 47d1365fdeb60e502419721d21e300c10d119e6f Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 30 Jun 2025 15:30:22 +0200 Subject: [PATCH 6/7] lnwallet: add table-driven test for evaluateNoOpHtlc helper --- lnwallet/channel_test.go | 250 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go index d2800dd01b3..0316dcc54db 100644 --- a/lnwallet/channel_test.go +++ b/lnwallet/channel_test.go @@ -11507,3 +11507,253 @@ func TestNoopAddBelowReserve(t *testing.T) { bobChan.LocalChanReserve()+htlcAmt.ToSatoshis(), ) } + +// TestEvaluateNoOpHtlc tests that the noop htlc evaluator helper function +// produces the expected balance deltas from various starting states. +func TestEvaluateNoOpHtlc(t *testing.T) { + testCases := []struct { + name string + localBalance, remoteBalance btcutil.Amount + localReserve, remoteReserve btcutil.Amount + entry *paymentDescriptor + receiver lntypes.ChannelParty + balanceDeltas *lntypes.Dual[int64] + expectedDeltas *lntypes.Dual[int64] + }{ + { + name: "local above reserve", + entry: &paymentDescriptor{ + Amount: lnwire.MilliSatoshi(2500), + }, + receiver: lntypes.Local, + balanceDeltas: &lntypes.Dual[int64]{ + Local: 0, + Remote: 0, + }, + expectedDeltas: &lntypes.Dual[int64]{ + Local: 0, + Remote: 2_500, + }, + }, + { + name: "remote above reserve", + entry: &paymentDescriptor{ + Amount: lnwire.MilliSatoshi(2500), + }, + receiver: lntypes.Remote, + balanceDeltas: &lntypes.Dual[int64]{ + Local: 0, + Remote: 0, + }, + expectedDeltas: &lntypes.Dual[int64]{ + Local: 2_500, + Remote: 0, + }, + }, + { + name: "local below reserve", + entry: &paymentDescriptor{ + Amount: lnwire.MilliSatoshi(2500), + }, + receiver: lntypes.Local, + localBalance: 25_000, + localReserve: 50_000, + balanceDeltas: &lntypes.Dual[int64]{ + Local: 0, + Remote: 0, + }, + expectedDeltas: &lntypes.Dual[int64]{ + Local: 2_500, + Remote: 0, + }, + }, + { + name: "remote below reserve", + entry: &paymentDescriptor{ + Amount: lnwire.MilliSatoshi(2500), + }, + receiver: lntypes.Remote, + remoteBalance: 25_000, + remoteReserve: 50_000, + balanceDeltas: &lntypes.Dual[int64]{ + Local: 0, + Remote: 0, + }, + expectedDeltas: &lntypes.Dual[int64]{ + Local: 0, + Remote: 2_500, + }, + }, + + { + name: "local above reserve with delta", + entry: &paymentDescriptor{ + Amount: lnwire.MilliSatoshi(2500), + }, + receiver: lntypes.Local, + localBalance: 25_000, + localReserve: 50_000, + balanceDeltas: &lntypes.Dual[int64]{ + Local: 25_001_000, + Remote: 0, + }, + expectedDeltas: &lntypes.Dual[int64]{ + Local: 25_001_000, + Remote: 2_500, + }, + }, + { + name: "remote above reserve with delta", + entry: &paymentDescriptor{ + Amount: lnwire.MilliSatoshi(2500), + }, + receiver: lntypes.Remote, + remoteBalance: 25_000, + remoteReserve: 50_000, + balanceDeltas: &lntypes.Dual[int64]{ + Local: 0, + Remote: 25_001_000, + }, + expectedDeltas: &lntypes.Dual[int64]{ + Local: 2_500, + Remote: 25_001_000, + }, + }, + { + name: "local below reserve with delta", + entry: &paymentDescriptor{ + Amount: lnwire.MilliSatoshi(2500), + }, + receiver: lntypes.Local, + localBalance: 25_000, + localReserve: 50_000, + balanceDeltas: &lntypes.Dual[int64]{ + Local: 24_999_000, + Remote: 0, + }, + expectedDeltas: &lntypes.Dual[int64]{ + Local: 25_001_500, + Remote: 0, + }, + }, + { + name: "remote below reserve with delta", + entry: &paymentDescriptor{ + Amount: lnwire.MilliSatoshi(2500), + }, + receiver: lntypes.Remote, + remoteBalance: 25_000, + remoteReserve: 50_000, + balanceDeltas: &lntypes.Dual[int64]{ + Local: 0, + Remote: 24_998_000, + }, + expectedDeltas: &lntypes.Dual[int64]{ + Local: 0, + Remote: 25_000_500, + }, + }, + { + name: "local above reserve with negative delta", + entry: &paymentDescriptor{ + Amount: lnwire.MilliSatoshi(2500), + }, + receiver: lntypes.Remote, + localBalance: 55_000, + localReserve: 50_000, + balanceDeltas: &lntypes.Dual[int64]{ + Local: -4_999_000, + Remote: 0, + }, + expectedDeltas: &lntypes.Dual[int64]{ + Local: -4_999_000, + Remote: 2_500, + }, + }, + { + name: "remote above reserve with negative delta", + entry: &paymentDescriptor{ + Amount: lnwire.MilliSatoshi(2500), + }, + receiver: lntypes.Remote, + remoteBalance: 55_000, + remoteReserve: 50_000, + balanceDeltas: &lntypes.Dual[int64]{ + Local: 0, + Remote: -4_999_000, + }, + expectedDeltas: &lntypes.Dual[int64]{ + Local: 2_500, + Remote: -4_999_000, + }, + }, + { + name: "local below reserve with negative delta", + entry: &paymentDescriptor{ + Amount: lnwire.MilliSatoshi(2500), + }, + receiver: lntypes.Local, + localBalance: 55_000, + localReserve: 50_000, + balanceDeltas: &lntypes.Dual[int64]{ + Local: -5_001_000, + Remote: 0, + }, + expectedDeltas: &lntypes.Dual[int64]{ + Local: -4_998_500, + Remote: 0, + }, + }, + { + name: "remote below reserve with negative delta", + entry: &paymentDescriptor{ + Amount: lnwire.MilliSatoshi(2500), + }, + receiver: lntypes.Remote, + remoteBalance: 55_000, + remoteReserve: 50_000, + balanceDeltas: &lntypes.Dual[int64]{ + Local: 0, + Remote: -5_001_000, + }, + expectedDeltas: &lntypes.Dual[int64]{ + Local: 0, + Remote: -4_998_500, + }, + }, + } + + chanType := channeldb.SimpleTaprootFeatureBit | + channeldb.AnchorOutputsBit | channeldb.ZeroHtlcTxFeeBit | + channeldb.SingleFunderTweaklessBit | channeldb.TapscriptRootBit + aliceChan, _, err := CreateTestChannels(t, chanType) + require.NoError(t, err, "unable to create test channels") + + for _, testCase := range testCases { + tc := testCase + + t.Logf("Running test case: %s", testCase.name) + + if tc.localBalance != 0 && tc.localReserve != 0 { + aliceChan.channelState.LocalChanCfg.ChanReserve = + tc.localReserve + + aliceChan.channelState.LocalCommitment.LocalBalance = + lnwire.NewMSatFromSatoshis(tc.localBalance) + } + + if tc.remoteBalance != 0 && tc.remoteReserve != 0 { + aliceChan.channelState.RemoteChanCfg.ChanReserve = + tc.remoteReserve + + aliceChan.channelState.RemoteCommitment.RemoteBalance = + lnwire.NewMSatFromSatoshis(tc.remoteBalance) + } + + aliceChan.evaluateNoOpHtlc( + tc.entry, tc.receiver, tc.balanceDeltas, + ) + + require.Equal(t, tc.expectedDeltas, tc.balanceDeltas) + } +} From 97bbbf14f986b9dfe7a449fbfcf5fbb19b397c52 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Wed, 23 Jul 2025 15:32:44 +0200 Subject: [PATCH 7/7] docs: update release notes for NoOp HTLCs --- docs/release-notes/release-notes-0.20.0.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.0.md b/docs/release-notes/release-notes-0.20.0.md index 0c3df271aab..579c5f3feef 100644 --- a/docs/release-notes/release-notes-0.20.0.md +++ b/docs/release-notes/release-notes-0.20.0.md @@ -40,6 +40,13 @@ # New Features +- Added [NoOp HTLCs](https://github.com/lightningnetwork/lnd/pull/9871). This +allows sending HTLCs to the remote party without shifting the balances of the +channel. This is currently only possible to use with custom channels, and only +when the appropriate TLV flag is set. This allows for HTLCs carrying metadata to +reflect their state on the channel commitment without having to send or receive +a certain amount of msats. + ## Functional Enhancements * RPCs `walletrpc.EstimateFee` and `walletrpc.FundPsbt` now