From d1b2bff2c8cfd0939775fbd6af324dab4a320da9 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 22 Nov 2024 19:05:18 -0800 Subject: [PATCH] lnwallet: update CoopCloseBalance to allow a paying party This preps us for an upcoming change to the rbf coop state machine where either party can pay for the channel fees. We also add a new test to make sure the new function adheres to some key properties. --- lnwallet/channel.go | 2 + lnwallet/close_test.go | 241 +++++++++++++++++++++++++++++++++++++++++ lnwallet/commitment.go | 31 +++++- 3 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 lnwallet/close_test.go diff --git a/lnwallet/channel.go b/lnwallet/channel.go index a74d6ab3a..61c266285 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -8289,6 +8289,7 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount, lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(), lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(), lc.channelState.LocalCommitment.CommitFee, + fn.None[lntypes.ChannelParty](), ) if err != nil { return nil, nil, 0, err @@ -8402,6 +8403,7 @@ func (lc *LightningChannel) CompleteCooperativeClose( lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(), lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(), lc.channelState.LocalCommitment.CommitFee, + fn.None[lntypes.ChannelParty](), ) if err != nil { return nil, 0, err diff --git a/lnwallet/close_test.go b/lnwallet/close_test.go new file mode 100644 index 000000000..7ece3fc45 --- /dev/null +++ b/lnwallet/close_test.go @@ -0,0 +1,241 @@ +package lnwallet + +import ( + "strconv" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// genValidAmount generates valid bitcoin amounts (non-negative). +func genValidAmount(t *rapid.T, label string) btcutil.Amount { + return btcutil.Amount( + rapid.Int64Range( + 100_000, 21_000_000*100_000_000, + ).Draw(t, label), + ) +} + +// genCoopCloseFee generates a reasonable non-zero cooperative close fee. +func genCoopCloseFee(t *rapid.T) btcutil.Amount { + // Generate a fee between 250-10000 sats which is a reasonable range for + // closing transactions + return btcutil.Amount( + rapid.Int64Range(250, 10_000).Draw(t, "coop_close_fee"), + ) +} + +// genChannelType generates various channel types, ensuring good coverage of +// different channel configurations including anchor outputs and other features. +func genChannelType(t *rapid.T) channeldb.ChannelType { + var chanType channeldb.ChannelType + + // For each bit, decide randomly if it should be set. + bits := []channeldb.ChannelType{ + channeldb.DualFunderBit, + channeldb.SingleFunderTweaklessBit, + channeldb.NoFundingTxBit, + channeldb.AnchorOutputsBit, + channeldb.FrozenBit, + channeldb.ZeroHtlcTxFeeBit, + channeldb.LeaseExpirationBit, + channeldb.ZeroConfBit, + channeldb.ScidAliasChanBit, + channeldb.ScidAliasFeatureBit, + channeldb.SimpleTaprootFeatureBit, + channeldb.TapscriptRootBit, + } + + // Helper to bias towards setting specific bits more frequently. + setBit := func(bit channeldb.ChannelType, probability int) { + bitRange := rapid.IntRange(0, 100) + label := "bit_" + strconv.FormatUint(uint64(bit), 2) + if bitRange.Draw(t, label) < probability { + chanType |= bit + } + } + + // We want to ensure good coverage of anchor outputs since they affect + // the balance calculation directly. We'll set the anchor bit with a 50% + // chance. + setBit(channeldb.AnchorOutputsBit, 50) + + // For other bits, use varying probabilities to ensure good + // distribution. + for _, bit := range bits { + // The anchor bit was already set above so we can skip it here. + if bit == channeldb.AnchorOutputsBit { + continue + } + + // Some bits are related, so we'll make sure we capture that + // dep. + switch bit { + case channeldb.TapscriptRootBit: + // If we have TapscriptRootBit, we must have + // SimpleTaprootFeatureBit. + if chanType&channeldb.SimpleTaprootFeatureBit != 0 { + // 70% chance if taproot is enabled. + setBit(bit, 70) + } + + case channeldb.DualFunderBit: + // 40% chance of dual funding. + setBit(bit, 40) + + default: + // 30% chance for other bits. + setBit(bit, 30) + } + } + + return chanType +} + +// genFeePayer generates optional fee payer. +func genFeePayer(t *rapid.T) fn.Option[lntypes.ChannelParty] { + if !rapid.Bool().Draw(t, "has_fee_payer") { + return fn.None[lntypes.ChannelParty]() + } + + if rapid.Bool().Draw(t, "is_local") { + return fn.Some(lntypes.Local) + } + + return fn.Some(lntypes.Remote) +} + +// genCommitFee generates a reasonable non-zero commitment fee. +func genCommitFee(t *rapid.T) btcutil.Amount { + // Generate a reasonable commit fee between 100-5000 sats + return btcutil.Amount( + rapid.Int64Range(100, 5_000).Draw(t, "commit_fee"), + ) +} + +// TestCoopCloseBalance tests fundamental properties of CoopCloseBalance. This +// ensures that the closing fee is always subtracted from the correct balance, +// amongst other properties. +func TestCoopCloseBalance(tt *testing.T) { + tt.Parallel() + + rapid.Check(tt, func(t *rapid.T) { + require := require.New(t) + + // Generate test inputs + chanType := genChannelType(t) + isInitiator := rapid.Bool().Draw(t, "is_initiator") + + // Generate amounts using specific generators + coopCloseFee := genCoopCloseFee(t) + ourBalance := genValidAmount(t, "local balance") + theirBalance := genValidAmount(t, "remote balance") + feePayer := genFeePayer(t) + commitFee := genCommitFee(t) + + ourFinal, theirFinal, err := CoopCloseBalance( + chanType, isInitiator, coopCloseFee, + ourBalance, theirBalance, commitFee, feePayer, + ) + + // Property 1: If inputs are non-negative, we either get valid + // outputs or an error. + if err != nil { + // On error, final balances should be 0 + require.Zero( + ourFinal, + "expected zero our_balance on error", + ) + require.Zero( + theirFinal, + "expected zero their_balance on error", + ) + + return + } + + // Property 2: Final balances should be non-negative. + require.GreaterOrEqual( + ourFinal, btcutil.Amount(0), + "our final balance should be non-negative", + ) + require.GreaterOrEqual( + theirFinal, btcutil.Amount(0), + "their final balance should be non-negative", + ) + + // Property 3: Total balance should be conserved minus fees. + initialTotal := ourBalance + theirBalance + initialTotal += commitFee + + if chanType.HasAnchors() { + initialTotal += 2 * AnchorSize + } + + finalTotal := ourFinal + theirFinal + coopCloseFee + require.Equal( + initialTotal, finalTotal, + "total balance should be conserved", + ) + + // Property 4: When feePayer is specified, that party's balance + // should be reduced by exactly the coopCloseFee. + if feePayer.IsSome() { + payer := feePayer.UnwrapOrFail(tt) + + if payer == lntypes.Local { + require.LessOrEqual( + ourBalance-(ourFinal+coopCloseFee), + btcutil.Amount(0), + "local balance reduced by more than fee", //nolint:ll + ) + } else { + require.LessOrEqual( + theirBalance-(theirFinal+coopCloseFee), + btcutil.Amount(0), + "remote balance reduced by more than fee", //nolint:ll + ) + } + } + + // Property 5: For anchor channels, verify the correct final + // balance factors in the anchor amount. + if chanType.HasAnchors() { + // The initiator delta is the commit fee plus anchor + // amount. + initiatorDelta := commitFee + 2*AnchorSize + + // Default to initiator paying unless explicitly + // specified. + isLocalPaying := isInitiator + if feePayer.IsSome() { + isLocalPaying = feePayer.UnwrapOrFail(tt) == + lntypes.Local + } + + if isInitiator { + expectedBalance := ourBalance + initiatorDelta + if isLocalPaying { + expectedBalance -= coopCloseFee + } + + require.Equal(expectedBalance, ourFinal, + "initiator (local) balance incorrect") + } else { + // They are the initiator + expectedBalance := theirBalance + initiatorDelta + if !isLocalPaying { + expectedBalance -= coopCloseFee + } + + require.Equal(expectedBalance, theirFinal, + "initiator (remote) balance incorrect") + } + } + }) +} diff --git a/lnwallet/commitment.go b/lnwallet/commitment.go index 787e8a71e..ab20d9afa 100644 --- a/lnwallet/commitment.go +++ b/lnwallet/commitment.go @@ -1033,8 +1033,9 @@ func CreateCommitTx(chanType channeldb.ChannelType, // CoopCloseBalance returns the final balances that should be used to create // the cooperative close tx, given the channel type and transaction fee. func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool, - coopCloseFee, ourBalance, theirBalance, - commitFee btcutil.Amount) (btcutil.Amount, btcutil.Amount, error) { + coopCloseFee, ourBalance, theirBalance, commitFee btcutil.Amount, + feePayer fn.Option[lntypes.ChannelParty], +) (btcutil.Amount, btcutil.Amount, error) { // We'll make sure we account for the complete balance by adding the // current dangling commitment fee to the balance of the initiator. @@ -1046,16 +1047,34 @@ func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool, initiatorDelta += 2 * AnchorSize } - // The initiator will pay the full coop close fee, subtract that value - // from their balance. - initiatorDelta -= coopCloseFee - + // To start with, we'll add the anchor and/or commitment fee to the + // balance of the initiator. if isInitiator { ourBalance += initiatorDelta } else { theirBalance += initiatorDelta } + // With the initiator's balance credited, we'll now subtract the closing + // fee from the closing party. By default, the initiator pays the full + // amount, but this can be overridden by the feePayer option. + defaultPayer := func() lntypes.ChannelParty { + if isInitiator { + return lntypes.Local + } + + return lntypes.Remote + }() + payer := feePayer.UnwrapOr(defaultPayer) + + // Based on the payer computed above, we'll subtract the closing fee. + switch payer { + case lntypes.Local: + ourBalance -= coopCloseFee + case lntypes.Remote: + theirBalance -= coopCloseFee + } + // During fee negotiation it should always be verified that the // initiator can pay the proposed fee, but we do a sanity check just to // be sure here.