From 9697b1848af5478d595c924b0803486e6989b5c6 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Wed, 11 Jun 2025 14:53:48 +0200 Subject: [PATCH 1/6] 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 | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lnwallet/payment_descriptor.go b/lnwallet/payment_descriptor.go index 49b79a139dc..eca8a25ac93 100644 --- a/lnwallet/payment_descriptor.go +++ b/lnwallet/payment_descriptor.go @@ -42,6 +42,12 @@ 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 will go back to the sender, rather than be credited for the + // receiver. + NoOpAdd ) // String returns a human readable string that uniquely identifies the target @@ -58,6 +64,8 @@ func (u updateType) String() string { return "Settle" case FeeUpdate: return "FeeUpdate" + case NoOpAdd: + return "NoopAdd" default: return "" } @@ -238,7 +246,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 +298,7 @@ func (pd *paymentDescriptor) setCommitHeight( whoseCommitChain lntypes.ChannelParty, nextHeight uint64) { switch pd.EntryType { - case Add: + case Add, NoOpAdd: pd.addCommitHeights.SetForParty( whoseCommitChain, nextHeight, ) From 03cb3421756b3cf815d0bcd93f58c7f555b51e5f Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 27 May 2025 13:22:52 +0200 Subject: [PATCH 2/6] 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 445ab1bb3e704be2baafc2d8017fb8e7d8969b49 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 27 May 2025 13:34:10 +0200 Subject: [PATCH 3/6] 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 | 143 +++++++++++++++++++++++++++++---- lnwallet/payment_descriptor.go | 5 ++ 3 files changed, 146 insertions(+), 19 deletions(-) diff --git a/lnwallet/aux_signer.go b/lnwallet/aux_signer.go index 042d3223e16..bfba4c12b74 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 + + // NoOpHtlcType 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. + NoOpHtlcType tlv.TlvType65544 +) + +// NoopAddHtlcType is the (golang) type of the TLV record that's used to signal +// that an HTLC should be a noop HTLC. +type NoopAddHtlcType = 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..d4ac957b9fc 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 := 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 = 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 = 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,19 @@ func (lc *LightningChannel) evaluateHTLCView(view *HtlcView, ) if rmvHeight == 0 { switch { + // If this a noop add, then when we settle the + // HTLC, we actually 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. + 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 +3057,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 +3096,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 +3172,70 @@ 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 { + + channel.RLock() + defer channel.RUnlock() + + c := channel + + localReserve := lnwire.NewMSatFromSatoshis(c.LocalChanCfg.ChanReserve) + remoteReserve := lnwire.NewMSatFromSatoshis(c.RemoteChanCfg.ChanReserve) + + switch { + case party.IsLocal(): + totalLocal := c.LocalCommitment.LocalBalance + if delta >= 0 { + totalLocal += lnwire.MilliSatoshi(delta) + } else { + totalLocal -= lnwire.MilliSatoshi(-1 * delta) + } + + return totalLocal > localReserve + + case party.IsRemote(): + 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() + } + + 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 +3924,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 @@ -5712,7 +5803,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 +5822,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 +6061,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 +6080,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 +6153,12 @@ func (lc *LightningChannel) MayAddOutgoingHtlc(amt lnwire.MilliSatoshi) error { func (lc *LightningChannel) htlcAddDescriptor(htlc *lnwire.UpdateAddHTLC, openKey *models.CircuitKey) *paymentDescriptor { + customRecords := htlc.CustomRecords.Copy() + entryType := 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 +6167,7 @@ func (lc *LightningChannel) htlcAddDescriptor(htlc *lnwire.UpdateAddHTLC, OnionBlob: htlc.OnionBlob, OpenCircuitKey: openKey, BlindingPoint: htlc.BlindingPoint, - CustomRecords: htlc.CustomRecords.Copy(), + CustomRecords: customRecords, } } @@ -6126,9 +6220,12 @@ func (lc *LightningChannel) ReceiveHTLC(htlc *lnwire.UpdateAddHTLC) (uint64, lc.updateLogs.Remote.htlcCounter) } + customRecords := htlc.CustomRecords.Copy() + entryType := 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 +6233,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 +9922,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 +10096,17 @@ 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 entryTypeForHtlc(records lnwire.CustomRecords, + chanType channeldb.ChannelType) updateType { + + noopTLV := uint64(NoOpHtlcType.TypeVal()) + if _, ok := records[noopTLV]; ok && chanType.HasTapscriptRoot() { + return NoOpAdd + } + + return Add +} diff --git a/lnwallet/payment_descriptor.go b/lnwallet/payment_descriptor.go index eca8a25ac93..b5b319bc24e 100644 --- a/lnwallet/payment_descriptor.go +++ b/lnwallet/payment_descriptor.go @@ -319,3 +319,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 cc51a691b16dbf42920d5a4078937b5f0510c936 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 27 May 2025 13:36:35 +0200 Subject: [PATCH 4/6] 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..d16d39a4562 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[NoopAddHtlcType, 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, minus 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 2b2ffb60e6cde096585e0800ba3fa140e0398ad9 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Wed, 25 Jun 2025 14:05:52 +0200 Subject: [PATCH 5/6] 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 | 47 ++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go index d16d39a4562..212a9fddd35 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[NoopAddHtlcType, 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") @@ -3548,6 +3564,7 @@ func TestChanSyncOweCommitment(t *testing.T) { testCases := []struct { name string chanType channeldb.ChannelType + noop bool }{ { name: "tweakless", @@ -3571,10 +3588,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 9fedbd6de335036bc63118cf578ab87b4cf66b14 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Mon, 30 Jun 2025 15:30:22 +0200 Subject: [PATCH 6/6] 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 212a9fddd35..69bb11d46a8 100644 --- a/lnwallet/channel_test.go +++ b/lnwallet/channel_test.go @@ -11504,3 +11504,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) + } +}