|
| 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 | +} |
0 commit comments