Skip to content

Commit d1b2bff

Browse files
committed
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.
1 parent b8cf5ae commit d1b2bff

File tree

3 files changed

+268
-6
lines changed

3 files changed

+268
-6
lines changed

lnwallet/channel.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8289,6 +8289,7 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount,
82898289
lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(),
82908290
lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(),
82918291
lc.channelState.LocalCommitment.CommitFee,
8292+
fn.None[lntypes.ChannelParty](),
82928293
)
82938294
if err != nil {
82948295
return nil, nil, 0, err
@@ -8402,6 +8403,7 @@ func (lc *LightningChannel) CompleteCooperativeClose(
84028403
lc.channelState.LocalCommitment.LocalBalance.ToSatoshis(),
84038404
lc.channelState.LocalCommitment.RemoteBalance.ToSatoshis(),
84048405
lc.channelState.LocalCommitment.CommitFee,
8406+
fn.None[lntypes.ChannelParty](),
84058407
)
84068408
if err != nil {
84078409
return nil, 0, err

lnwallet/close_test.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package lnwallet
2+
3+
import (
4+
"strconv"
5+
"testing"
6+
7+
"github.com/btcsuite/btcd/btcutil"
8+
"github.com/lightningnetwork/lnd/channeldb"
9+
"github.com/lightningnetwork/lnd/fn/v2"
10+
"github.com/lightningnetwork/lnd/lntypes"
11+
"github.com/stretchr/testify/require"
12+
"pgregory.net/rapid"
13+
)
14+
15+
// genValidAmount generates valid bitcoin amounts (non-negative).
16+
func genValidAmount(t *rapid.T, label string) btcutil.Amount {
17+
return btcutil.Amount(
18+
rapid.Int64Range(
19+
100_000, 21_000_000*100_000_000,
20+
).Draw(t, label),
21+
)
22+
}
23+
24+
// genCoopCloseFee generates a reasonable non-zero cooperative close fee.
25+
func genCoopCloseFee(t *rapid.T) btcutil.Amount {
26+
// Generate a fee between 250-10000 sats which is a reasonable range for
27+
// closing transactions
28+
return btcutil.Amount(
29+
rapid.Int64Range(250, 10_000).Draw(t, "coop_close_fee"),
30+
)
31+
}
32+
33+
// genChannelType generates various channel types, ensuring good coverage of
34+
// different channel configurations including anchor outputs and other features.
35+
func genChannelType(t *rapid.T) channeldb.ChannelType {
36+
var chanType channeldb.ChannelType
37+
38+
// For each bit, decide randomly if it should be set.
39+
bits := []channeldb.ChannelType{
40+
channeldb.DualFunderBit,
41+
channeldb.SingleFunderTweaklessBit,
42+
channeldb.NoFundingTxBit,
43+
channeldb.AnchorOutputsBit,
44+
channeldb.FrozenBit,
45+
channeldb.ZeroHtlcTxFeeBit,
46+
channeldb.LeaseExpirationBit,
47+
channeldb.ZeroConfBit,
48+
channeldb.ScidAliasChanBit,
49+
channeldb.ScidAliasFeatureBit,
50+
channeldb.SimpleTaprootFeatureBit,
51+
channeldb.TapscriptRootBit,
52+
}
53+
54+
// Helper to bias towards setting specific bits more frequently.
55+
setBit := func(bit channeldb.ChannelType, probability int) {
56+
bitRange := rapid.IntRange(0, 100)
57+
label := "bit_" + strconv.FormatUint(uint64(bit), 2)
58+
if bitRange.Draw(t, label) < probability {
59+
chanType |= bit
60+
}
61+
}
62+
63+
// We want to ensure good coverage of anchor outputs since they affect
64+
// the balance calculation directly. We'll set the anchor bit with a 50%
65+
// chance.
66+
setBit(channeldb.AnchorOutputsBit, 50)
67+
68+
// For other bits, use varying probabilities to ensure good
69+
// distribution.
70+
for _, bit := range bits {
71+
// The anchor bit was already set above so we can skip it here.
72+
if bit == channeldb.AnchorOutputsBit {
73+
continue
74+
}
75+
76+
// Some bits are related, so we'll make sure we capture that
77+
// dep.
78+
switch bit {
79+
case channeldb.TapscriptRootBit:
80+
// If we have TapscriptRootBit, we must have
81+
// SimpleTaprootFeatureBit.
82+
if chanType&channeldb.SimpleTaprootFeatureBit != 0 {
83+
// 70% chance if taproot is enabled.
84+
setBit(bit, 70)
85+
}
86+
87+
case channeldb.DualFunderBit:
88+
// 40% chance of dual funding.
89+
setBit(bit, 40)
90+
91+
default:
92+
// 30% chance for other bits.
93+
setBit(bit, 30)
94+
}
95+
}
96+
97+
return chanType
98+
}
99+
100+
// genFeePayer generates optional fee payer.
101+
func genFeePayer(t *rapid.T) fn.Option[lntypes.ChannelParty] {
102+
if !rapid.Bool().Draw(t, "has_fee_payer") {
103+
return fn.None[lntypes.ChannelParty]()
104+
}
105+
106+
if rapid.Bool().Draw(t, "is_local") {
107+
return fn.Some(lntypes.Local)
108+
}
109+
110+
return fn.Some(lntypes.Remote)
111+
}
112+
113+
// genCommitFee generates a reasonable non-zero commitment fee.
114+
func genCommitFee(t *rapid.T) btcutil.Amount {
115+
// Generate a reasonable commit fee between 100-5000 sats
116+
return btcutil.Amount(
117+
rapid.Int64Range(100, 5_000).Draw(t, "commit_fee"),
118+
)
119+
}
120+
121+
// TestCoopCloseBalance tests fundamental properties of CoopCloseBalance. This
122+
// ensures that the closing fee is always subtracted from the correct balance,
123+
// amongst other properties.
124+
func TestCoopCloseBalance(tt *testing.T) {
125+
tt.Parallel()
126+
127+
rapid.Check(tt, func(t *rapid.T) {
128+
require := require.New(t)
129+
130+
// Generate test inputs
131+
chanType := genChannelType(t)
132+
isInitiator := rapid.Bool().Draw(t, "is_initiator")
133+
134+
// Generate amounts using specific generators
135+
coopCloseFee := genCoopCloseFee(t)
136+
ourBalance := genValidAmount(t, "local balance")
137+
theirBalance := genValidAmount(t, "remote balance")
138+
feePayer := genFeePayer(t)
139+
commitFee := genCommitFee(t)
140+
141+
ourFinal, theirFinal, err := CoopCloseBalance(
142+
chanType, isInitiator, coopCloseFee,
143+
ourBalance, theirBalance, commitFee, feePayer,
144+
)
145+
146+
// Property 1: If inputs are non-negative, we either get valid
147+
// outputs or an error.
148+
if err != nil {
149+
// On error, final balances should be 0
150+
require.Zero(
151+
ourFinal,
152+
"expected zero our_balance on error",
153+
)
154+
require.Zero(
155+
theirFinal,
156+
"expected zero their_balance on error",
157+
)
158+
159+
return
160+
}
161+
162+
// Property 2: Final balances should be non-negative.
163+
require.GreaterOrEqual(
164+
ourFinal, btcutil.Amount(0),
165+
"our final balance should be non-negative",
166+
)
167+
require.GreaterOrEqual(
168+
theirFinal, btcutil.Amount(0),
169+
"their final balance should be non-negative",
170+
)
171+
172+
// Property 3: Total balance should be conserved minus fees.
173+
initialTotal := ourBalance + theirBalance
174+
initialTotal += commitFee
175+
176+
if chanType.HasAnchors() {
177+
initialTotal += 2 * AnchorSize
178+
}
179+
180+
finalTotal := ourFinal + theirFinal + coopCloseFee
181+
require.Equal(
182+
initialTotal, finalTotal,
183+
"total balance should be conserved",
184+
)
185+
186+
// Property 4: When feePayer is specified, that party's balance
187+
// should be reduced by exactly the coopCloseFee.
188+
if feePayer.IsSome() {
189+
payer := feePayer.UnwrapOrFail(tt)
190+
191+
if payer == lntypes.Local {
192+
require.LessOrEqual(
193+
ourBalance-(ourFinal+coopCloseFee),
194+
btcutil.Amount(0),
195+
"local balance reduced by more than fee", //nolint:ll
196+
)
197+
} else {
198+
require.LessOrEqual(
199+
theirBalance-(theirFinal+coopCloseFee),
200+
btcutil.Amount(0),
201+
"remote balance reduced by more than fee", //nolint:ll
202+
)
203+
}
204+
}
205+
206+
// Property 5: For anchor channels, verify the correct final
207+
// balance factors in the anchor amount.
208+
if chanType.HasAnchors() {
209+
// The initiator delta is the commit fee plus anchor
210+
// amount.
211+
initiatorDelta := commitFee + 2*AnchorSize
212+
213+
// Default to initiator paying unless explicitly
214+
// specified.
215+
isLocalPaying := isInitiator
216+
if feePayer.IsSome() {
217+
isLocalPaying = feePayer.UnwrapOrFail(tt) ==
218+
lntypes.Local
219+
}
220+
221+
if isInitiator {
222+
expectedBalance := ourBalance + initiatorDelta
223+
if isLocalPaying {
224+
expectedBalance -= coopCloseFee
225+
}
226+
227+
require.Equal(expectedBalance, ourFinal,
228+
"initiator (local) balance incorrect")
229+
} else {
230+
// They are the initiator
231+
expectedBalance := theirBalance + initiatorDelta
232+
if !isLocalPaying {
233+
expectedBalance -= coopCloseFee
234+
}
235+
236+
require.Equal(expectedBalance, theirFinal,
237+
"initiator (remote) balance incorrect")
238+
}
239+
}
240+
})
241+
}

lnwallet/commitment.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,8 +1033,9 @@ func CreateCommitTx(chanType channeldb.ChannelType,
10331033
// CoopCloseBalance returns the final balances that should be used to create
10341034
// the cooperative close tx, given the channel type and transaction fee.
10351035
func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool,
1036-
coopCloseFee, ourBalance, theirBalance,
1037-
commitFee btcutil.Amount) (btcutil.Amount, btcutil.Amount, error) {
1036+
coopCloseFee, ourBalance, theirBalance, commitFee btcutil.Amount,
1037+
feePayer fn.Option[lntypes.ChannelParty],
1038+
) (btcutil.Amount, btcutil.Amount, error) {
10381039

10391040
// We'll make sure we account for the complete balance by adding the
10401041
// current dangling commitment fee to the balance of the initiator.
@@ -1046,16 +1047,34 @@ func CoopCloseBalance(chanType channeldb.ChannelType, isInitiator bool,
10461047
initiatorDelta += 2 * AnchorSize
10471048
}
10481049

1049-
// The initiator will pay the full coop close fee, subtract that value
1050-
// from their balance.
1051-
initiatorDelta -= coopCloseFee
1052-
1050+
// To start with, we'll add the anchor and/or commitment fee to the
1051+
// balance of the initiator.
10531052
if isInitiator {
10541053
ourBalance += initiatorDelta
10551054
} else {
10561055
theirBalance += initiatorDelta
10571056
}
10581057

1058+
// With the initiator's balance credited, we'll now subtract the closing
1059+
// fee from the closing party. By default, the initiator pays the full
1060+
// amount, but this can be overridden by the feePayer option.
1061+
defaultPayer := func() lntypes.ChannelParty {
1062+
if isInitiator {
1063+
return lntypes.Local
1064+
}
1065+
1066+
return lntypes.Remote
1067+
}()
1068+
payer := feePayer.UnwrapOr(defaultPayer)
1069+
1070+
// Based on the payer computed above, we'll subtract the closing fee.
1071+
switch payer {
1072+
case lntypes.Local:
1073+
ourBalance -= coopCloseFee
1074+
case lntypes.Remote:
1075+
theirBalance -= coopCloseFee
1076+
}
1077+
10591078
// During fee negotiation it should always be verified that the
10601079
// initiator can pay the proposed fee, but we do a sanity check just to
10611080
// be sure here.

0 commit comments

Comments
 (0)