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 diff --git a/lnwallet/aux_signer.go b/lnwallet/aux_signer.go index 90a4325f60e..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. @@ -116,6 +127,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). 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/channel_test.go b/lnwallet/channel_test.go index 0a0ca261c02..0316dcc54db 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) }) } } @@ -11339,3 +11367,393 @@ 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(), + ) +} + +// 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) + } +} diff --git a/lnwallet/payment_descriptor.go b/lnwallet/payment_descriptor.go index 49b79a139dc..944749bde9f 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 "" } @@ -216,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 @@ -238,7 +255,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 +307,7 @@ func (pd *paymentDescriptor) setCommitHeight( whoseCommitChain lntypes.ChannelParty, nextHeight uint64) { switch pd.EntryType { - case Add: + case Add, NoOpAdd: pd.addCommitHeights.SetForParty( whoseCommitChain, nextHeight, ) @@ -311,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 +}