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  }