github.com/decred/dcrlnd@v0.7.6/lnwallet/chanfunding/coin_select_test.go (about) 1 package chanfunding 2 3 import ( 4 "encoding/hex" 5 "regexp" 6 "testing" 7 8 "github.com/decred/dcrd/dcrutil/v4" 9 "github.com/decred/dcrd/wire" 10 "github.com/decred/dcrlnd/input" 11 "github.com/decred/dcrlnd/lnwallet/chainfee" 12 "github.com/stretchr/testify/require" 13 ) 14 15 var ( 16 p2pkhScript, _ = hex.DecodeString( 17 "76a914000000000000000000000000000000000000000088ac", 18 ) 19 20 unknownScript, _ = hex.DecodeString( 21 "a91411034bdcb6ccb7744fdfdeea958a6fb0b415a03288ac", 22 ) 23 ) 24 25 // fundingFee is a helper method that returns the fee estimate used for a tx 26 // with the given number of inputs and the optional change output. This matches 27 // the estimate done by the wallet. 28 func fundingFee(feeRate chainfee.AtomPerKByte, numInput int, 29 change bool) dcrutil.Amount { 30 31 var sizeEstimate input.TxSizeEstimator 32 33 // All inputs. 34 for i := 0; i < numInput; i++ { 35 sizeEstimate.AddP2PKHInput() 36 } 37 38 // The multisig funding output. 39 sizeEstimate.AddP2SHOutput() 40 41 // Optionally count a change output. 42 if change { 43 sizeEstimate.AddP2PKHOutput() 44 } 45 46 totalSize := sizeEstimate.Size() 47 return feeRate.FeeForSize(totalSize) 48 } 49 50 // TestCalculateFees tests that the helper function to calculate the fees 51 // both with and without applying a change output is done correctly for 52 // (N)P2WKH inputs, and should raise an error otherwise. 53 func TestCalculateFees(t *testing.T) { 54 t.Parallel() 55 56 const feeRate = chainfee.AtomPerKByte(1000) 57 58 type testCase struct { 59 name string 60 utxos []Coin 61 62 expectedFeeNoChange dcrutil.Amount 63 expectedFeeWithChange dcrutil.Amount 64 expectedErr error 65 } 66 67 testCases := []testCase{ 68 { 69 name: "one P2PKH input", 70 utxos: []Coin{ 71 { 72 TxOut: wire.TxOut{ 73 PkScript: p2pkhScript, 74 Value: 1, 75 }, 76 }, 77 }, 78 79 expectedFeeNoChange: 215, 80 expectedFeeWithChange: 251, 81 expectedErr: nil, 82 }, 83 84 { 85 name: "not supported P2KH input", 86 utxos: []Coin{ 87 { 88 TxOut: wire.TxOut{ 89 PkScript: unknownScript, 90 Value: 1, 91 }, 92 }, 93 }, 94 95 expectedErr: &errUnsupportedInput{unknownScript}, 96 }, 97 } 98 99 for _, test := range testCases { 100 test := test 101 t.Run(test.name, func(t *testing.T) { 102 feeNoChange, feeWithChange, err := calculateFees( 103 test.utxos, feeRate, 104 ) 105 require.Equal(t, test.expectedErr, err) 106 107 // Note: The error-case will have zero values returned 108 // for fees and therefore anyway pass the following 109 // requirements. 110 require.Equal(t, test.expectedFeeNoChange, feeNoChange) 111 require.Equal(t, test.expectedFeeWithChange, feeWithChange) 112 }) 113 } 114 } 115 116 // TestCoinSelect tests that we pick coins adding up to the expected amount 117 // when creating a funding transaction, and that the calculated change is the 118 // expected amount. 119 // 120 // NOTE: coinSelect will always attempt to add a change output, so we must 121 // account for this in the tests. 122 func TestCoinSelect(t *testing.T) { 123 t.Parallel() 124 125 const feeRate = chainfee.AtomPerKByte(100) 126 const dustLimit = dcrutil.Amount(1000) 127 128 type testCase struct { 129 name string 130 outputValue dcrutil.Amount 131 coins []Coin 132 133 expectedInput []dcrutil.Amount 134 expectedChange dcrutil.Amount 135 expectErr bool 136 } 137 138 testCases := []testCase{ 139 { 140 // We have 1.0 BTC available, and wants to send 0.5. 141 // This will obviously lead to a change output of 142 // almost 0.5 BTC. 143 name: "big change", 144 coins: []Coin{ 145 { 146 TxOut: wire.TxOut{ 147 PkScript: p2pkhScript, 148 Value: 1 * dcrutil.AtomsPerCoin, 149 }, 150 }, 151 }, 152 outputValue: 0.5 * dcrutil.AtomsPerCoin, 153 154 // The one and only input will be selected. 155 expectedInput: []dcrutil.Amount{ 156 1 * dcrutil.AtomsPerCoin, 157 }, 158 // Change will be what's left minus the fee. 159 expectedChange: 0.5*dcrutil.AtomsPerCoin - fundingFee(feeRate, 1, true), 160 }, 161 { 162 // We have 1 BTC available, and we want to send 1 BTC. 163 // This should lead to an error, as we don't have 164 // enough funds to pay the fee. 165 name: "nothing left for fees", 166 coins: []Coin{ 167 { 168 TxOut: wire.TxOut{ 169 PkScript: p2pkhScript, 170 Value: 1 * dcrutil.AtomsPerCoin, 171 }, 172 }, 173 }, 174 outputValue: 1 * dcrutil.AtomsPerCoin, 175 expectErr: true, 176 }, 177 { 178 // We have a 1 BTC input, and want to create an output 179 // as big as possible, such that the remaining change 180 // would be dust but instead goes to fees. 181 name: "dust change", 182 coins: []Coin{ 183 { 184 TxOut: wire.TxOut{ 185 PkScript: p2pkhScript, 186 Value: 1 * dcrutil.AtomsPerCoin, 187 }, 188 }, 189 }, 190 // We tune the output value by subtracting the expected 191 // fee and the dustlimit. 192 outputValue: 1*dcrutil.AtomsPerCoin - fundingFee(feeRate, 1, false) - dustLimit, 193 194 expectedInput: []dcrutil.Amount{ 195 1 * dcrutil.AtomsPerCoin, 196 }, 197 198 // Change must be zero. 199 expectedChange: 0, 200 }, 201 { 202 // We got just enough funds to create a change output above the 203 // dust limit. 204 name: "change right above dustlimit", 205 coins: []Coin{ 206 { 207 TxOut: wire.TxOut{ 208 PkScript: p2pkhScript, 209 Value: int64(fundingFee(feeRate, 1, true) + 2*(dustLimit+1)), 210 }, 211 }, 212 }, 213 // We tune the output value to be just above the dust limit. 214 outputValue: dustLimit + 1, 215 216 expectedInput: []dcrutil.Amount{ 217 fundingFee(feeRate, 1, true) + 2*(dustLimit+1), 218 }, 219 220 // After paying for the fee the change output should be just above 221 // the dust limit. 222 expectedChange: dustLimit + 1, 223 }, 224 { 225 // If more than 20% of funds goes to fees, it should fail. 226 name: "high fee", 227 coins: []Coin{ 228 { 229 TxOut: wire.TxOut{ 230 PkScript: p2pkhScript, 231 Value: int64(5 * fundingFee(feeRate, 1, false)), 232 }, 233 }, 234 }, 235 outputValue: 4 * fundingFee(feeRate, 1, false), 236 237 expectErr: true, 238 }, 239 } 240 241 for _, test := range testCases { 242 test := test 243 t.Run(test.name, func(t *testing.T) { 244 t.Parallel() 245 246 selected, changeAmt, err := CoinSelect( 247 feeRate, test.outputValue, dustLimit, test.coins, 248 ) 249 if !test.expectErr && err != nil { 250 t.Fatalf(err.Error()) 251 } 252 253 if test.expectErr && err == nil { 254 t.Fatalf("expected error") 255 } 256 257 // If we got an expected error, there is nothing more to test. 258 if test.expectErr { 259 return 260 } 261 262 // Check that the selected inputs match what we expect. 263 if len(selected) != len(test.expectedInput) { 264 t.Fatalf("expected %v inputs, got %v", 265 len(test.expectedInput), len(selected)) 266 } 267 268 for i, coin := range selected { 269 if coin.Value != int64(test.expectedInput[i]) { 270 t.Fatalf("expected input %v to have value %v, "+ 271 "had %v", i, test.expectedInput[i], 272 coin.Value) 273 } 274 } 275 276 // Assert we got the expected change amount. 277 if changeAmt != test.expectedChange { 278 t.Fatalf("expected %v change amt, got %v", 279 test.expectedChange, changeAmt) 280 } 281 }) 282 } 283 } 284 285 // TestCoinSelectSubtractFees tests that we pick coins adding up to the 286 // expected amount when creating a funding transaction, and that a change 287 // output is created only when necessary. 288 func TestCoinSelectSubtractFees(t *testing.T) { 289 t.Parallel() 290 291 const feeRate = chainfee.AtomPerKByte(100) 292 const highFeeRate = chainfee.AtomPerKByte(10000) 293 const dustLimit = dcrutil.Amount(1000) 294 const dust = dcrutil.Amount(100) 295 296 // removeAmounts replaces any amounts in string with "<amt>". 297 removeAmounts := func(s string) string { 298 re := regexp.MustCompile(`[[:digit:]]+\.?[[:digit:]]*`) 299 return re.ReplaceAllString(s, "<amt>") 300 } 301 302 type testCase struct { 303 name string 304 highFee bool 305 spendValue dcrutil.Amount 306 coins []Coin 307 308 expectedInput []dcrutil.Amount 309 expectedFundingAmt dcrutil.Amount 310 expectedChange dcrutil.Amount 311 expectErr string 312 } 313 314 testCases := []testCase{ 315 { 316 // We have 1.0 BTC available, spend them all. This 317 // should lead to a funding TX with one output, the 318 // rest goes to fees. 319 name: "spend all", 320 coins: []Coin{ 321 { 322 TxOut: wire.TxOut{ 323 PkScript: p2pkhScript, 324 Value: 1 * dcrutil.AtomsPerCoin, 325 }, 326 }, 327 }, 328 spendValue: 1 * dcrutil.AtomsPerCoin, 329 330 // The one and only input will be selected. 331 expectedInput: []dcrutil.Amount{ 332 1 * dcrutil.AtomsPerCoin, 333 }, 334 expectedFundingAmt: 1*dcrutil.AtomsPerCoin - fundingFee(feeRate, 1, false), 335 expectedChange: 0, 336 }, 337 { 338 // We have 1.0 DCR available and spend half of it. This 339 // should lead to a funding TX with a change output. 340 name: "spend with change", 341 coins: []Coin{ 342 { 343 TxOut: wire.TxOut{ 344 PkScript: p2pkhScript, 345 Value: 1 * dcrutil.AtomsPerCoin, 346 }, 347 }, 348 }, 349 spendValue: 0.5 * dcrutil.AtomsPerCoin, 350 351 // The one and only input will be selected. 352 expectedInput: []dcrutil.Amount{ 353 1 * dcrutil.AtomsPerCoin, 354 }, 355 expectedFundingAmt: 0.5*dcrutil.AtomsPerCoin - fundingFee(feeRate, 1, true), 356 expectedChange: 0.5 * dcrutil.AtomsPerCoin, 357 }, 358 { 359 // The total funds available is below the dust limit 360 // after paying fees. 361 name: "dust output", 362 coins: []Coin{ 363 { 364 TxOut: wire.TxOut{ 365 PkScript: p2pkhScript, 366 Value: int64(fundingFee(feeRate, 1, false) + dustLimit - 1), 367 }, 368 }, 369 }, 370 spendValue: fundingFee(feeRate, 1, false) + dust, 371 372 expectErr: "output amount(<amt> DCR) after subtracting " + 373 "fees(<amt> DCR) below dust limit(<amt> DCR)", 374 }, 375 { 376 // After subtracting fees, the resulting change output 377 // is below the dust limit. The remainder should go 378 // towards the funding output. 379 name: "dust change", 380 coins: []Coin{ 381 { 382 TxOut: wire.TxOut{ 383 PkScript: p2pkhScript, 384 Value: 1 * dcrutil.AtomsPerCoin, 385 }, 386 }, 387 }, 388 spendValue: 1*dcrutil.AtomsPerCoin - dust, 389 390 expectedInput: []dcrutil.Amount{ 391 1 * dcrutil.AtomsPerCoin, 392 }, 393 expectedFundingAmt: 1*dcrutil.AtomsPerCoin - fundingFee(feeRate, 1, false), 394 expectedChange: 0, 395 }, 396 { 397 // We got just enough funds to create an output above the dust limit. 398 name: "output right above dustlimit", 399 coins: []Coin{ 400 { 401 TxOut: wire.TxOut{ 402 PkScript: p2pkhScript, 403 Value: int64(fundingFee(feeRate, 1, false) + dustLimit + 1), 404 }, 405 }, 406 }, 407 spendValue: fundingFee(feeRate, 1, false) + dustLimit + 1, 408 409 expectedInput: []dcrutil.Amount{ 410 fundingFee(feeRate, 1, false) + dustLimit + 1, 411 }, 412 expectedFundingAmt: dustLimit + 1, 413 expectedChange: 0, 414 }, 415 { 416 // Amount left is below dust limit after paying fee for 417 // a change output, resulting in a no-change tx. 418 name: "no amount to pay fee for change", 419 coins: []Coin{ 420 { 421 TxOut: wire.TxOut{ 422 PkScript: p2pkhScript, 423 Value: int64(fundingFee(feeRate, 1, false) + 2*(dustLimit+1)), 424 }, 425 }, 426 }, 427 spendValue: fundingFee(feeRate, 1, false) + dustLimit + 1, 428 429 expectedInput: []dcrutil.Amount{ 430 fundingFee(feeRate, 1, false) + 2*(dustLimit+1), 431 }, 432 expectedFundingAmt: 2 * (dustLimit + 1), 433 expectedChange: 0, 434 }, 435 { 436 // If more than 20% of funds goes to fees, it should fail. 437 name: "high fee", 438 highFee: true, 439 coins: []Coin{ 440 { 441 TxOut: wire.TxOut{ 442 PkScript: p2pkhScript, 443 Value: int64(6 * fundingFee(highFeeRate, 1, false)), 444 }, 445 }, 446 }, 447 spendValue: 5 * fundingFee(highFeeRate, 1, false), 448 449 expectErr: "fee <amt> DCR on total output value <amt> DCR", 450 }, 451 } 452 453 for _, test := range testCases { 454 test := test 455 456 t.Run(test.name, func(t *testing.T) { 457 feeRate := feeRate 458 if test.highFee { 459 feeRate = highFeeRate 460 } 461 462 selected, localFundingAmt, changeAmt, err := CoinSelectSubtractFees( 463 feeRate, test.spendValue, dustLimit, test.coins, 464 ) 465 if err != nil { 466 switch { 467 case test.expectErr == "": 468 t.Fatalf(err.Error()) 469 470 case test.expectErr != removeAmounts(err.Error()): 471 t.Fatalf("expected error '%v', got '%v'", 472 test.expectErr, 473 err.Error()) 474 475 // If we got an expected error, there is 476 // nothing more to test. 477 default: 478 return 479 } 480 } 481 482 // Check that there was no expected error we missed. 483 if test.expectErr != "" { 484 t.Fatalf("expected error") 485 } 486 487 // Check that the selected inputs match what we expect. 488 if len(selected) != len(test.expectedInput) { 489 t.Fatalf("expected %v inputs, got %v", 490 len(test.expectedInput), len(selected)) 491 } 492 493 for i, coin := range selected { 494 if coin.Value != int64(test.expectedInput[i]) { 495 t.Fatalf("expected input %v to have value %v, "+ 496 "had %v", i, test.expectedInput[i], 497 coin.Value) 498 } 499 } 500 501 // Assert we got the expected change amount. 502 if localFundingAmt != test.expectedFundingAmt { 503 t.Fatalf("expected %v local funding amt, got %v", 504 test.expectedFundingAmt, localFundingAmt) 505 } 506 if changeAmt != test.expectedChange { 507 t.Fatalf("expected %v change amt, got %v", 508 test.expectedChange, changeAmt) 509 } 510 }) 511 } 512 }