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.LocalBalance.ToSatoshis(),
lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(), lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(),
lc.channelState.LocalCommitment.CommitFee, lc.channelState.LocalCommitment.CommitFee,
fn.None[lntypes.ChannelParty](),
) )
if err != nil { if err != nil {
return nil, nil, 0, err return nil, nil, 0, err
@ -8402,6 +8403,7 @@ func (lc *LightningChannel) CompleteCooperativeClose(
lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(), lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(),
lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(), lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(),
lc.channelState.LocalCommitment.CommitFee, lc.channelState.LocalCommitment.CommitFee,
fn.None[lntypes.ChannelParty](),
) )
if err != nil { if err != nil {
return nil, 0, err 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 // CoopCloseBalance returns the final balances that should be used to create
// the cooperative close tx, given the channel type and transaction fee. // the cooperative close tx, given the channel type and transaction fee.
func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool, func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool,
coopCloseFee, ourBalance, theirBalance, coopCloseFee, ourBalance, theirBalance, commitFee btcutil.Amount,
commitFee btcutil.Amount) (btcutil.Amount, btcutil.Amount, error) { feePayer fn.Option[lntypes.ChannelParty],
) (btcutil.Amount, btcutil.Amount, error) {
// We'll make sure we account for the complete balance by adding the // We'll make sure we account for the complete balance by adding the
// current dangling commitment fee to the balance of the initiator. // current dangling commitment fee to the balance of the initiator.
@ -1046,16 +1047,34 @@ func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool,
initiatorDelta += 2 * AnchorSize initiatorDelta += 2 * AnchorSize
} }
// The initiator will pay the full coop close fee, subtract that value // To start with, we'll add the anchor and/or commitment fee to the
// from their balance. // balance of the initiator.
initiatorDelta -= coopCloseFee
if isInitiator { if isInitiator {
ourBalance += initiatorDelta ourBalance += initiatorDelta
} else { } else {
theirBalance += initiatorDelta 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 // During fee negotiation it should always be verified that the
// initiator can pay the proposed fee, but we do a sanity check just to // initiator can pay the proposed fee, but we do a sanity check just to
// be sure here. // be sure here.