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.
This commit is contained in:
Olaoluwa Osuntokun 2024-11-22 19:05:18 -08:00
parent b8cf5ae98f
commit d1b2bff2c8
No known key found for this signature in database
GPG key ID: 90525F7DEEE0AD86
3 changed files with 268 additions and 6 deletions

View file

@ -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

241
lnwallet/close_test.go Normal file
View file

@ -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")
}
}
})
}

View file

@ -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.