diff --git a/lnwallet/chanfunding/coin_select.go b/lnwallet/chanfunding/coin_select.go new file mode 100644 index 00000000..f1ce008d --- /dev/null +++ b/lnwallet/chanfunding/coin_select.go @@ -0,0 +1,216 @@ +package chanfunding + +import ( + "fmt" + + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// ErrInsufficientFunds is a type matching the error interface which is +// returned when coin selection for a new funding transaction fails to due +// having an insufficient amount of confirmed funds. +type ErrInsufficientFunds struct { + amountAvailable btcutil.Amount + amountSelected btcutil.Amount +} + +// Error returns a human readable string describing the error. +func (e *ErrInsufficientFunds) Error() string { + return fmt.Sprintf("not enough witness outputs to create funding "+ + "transaction, need %v only have %v available", + e.amountAvailable, e.amountSelected) +} + +// Coin represents a spendable UTXO which is available for channel funding. +// This UTXO need not reside in our internal wallet as an example, and instead +// may be derived from an existing watch-only wallet. It wraps both the output +// present within the UTXO set, and also the outpoint that generates this coin. +type Coin struct { + wire.TxOut + + wire.OutPoint +} + +// selectInputs selects a slice of inputs necessary to meet the specified +// selection amount. If input selection is unable to succeed due to insufficient +// funds, a non-nil error is returned. Additionally, the total amount of the +// selected coins are returned in order for the caller to properly handle +// change+fees. +func selectInputs(amt btcutil.Amount, coins []Coin) (btcutil.Amount, []Coin, error) { + satSelected := btcutil.Amount(0) + for i, coin := range coins { + satSelected += btcutil.Amount(coin.Value) + if satSelected >= amt { + return satSelected, coins[:i+1], nil + } + } + + return 0, nil, &ErrInsufficientFunds{amt, satSelected} +} + +// CoinSelect attempts to select a sufficient amount of coins, including a +// change output to fund amt satoshis, adhering to the specified fee rate. The +// specified fee rate should be expressed in sat/kw for coin selection to +// function properly. +func CoinSelect(feeRate chainfee.SatPerKWeight, amt btcutil.Amount, + coins []Coin) ([]Coin, btcutil.Amount, error) { + + amtNeeded := amt + for { + // First perform an initial round of coin selection to estimate + // the required fee. + totalSat, selectedUtxos, err := selectInputs(amtNeeded, coins) + if err != nil { + return nil, 0, err + } + + var weightEstimate input.TxWeightEstimator + + for _, utxo := range selectedUtxos { + switch { + + case txscript.IsPayToWitnessPubKeyHash(utxo.PkScript): + weightEstimate.AddP2WKHInput() + + case txscript.IsPayToScriptHash(utxo.PkScript): + weightEstimate.AddNestedP2WKHInput() + + default: + return nil, 0, fmt.Errorf("unsupported address type: %x", + utxo.PkScript) + } + } + + // Channel funding multisig output is P2WSH. + weightEstimate.AddP2WSHOutput() + + // Assume that change output is a P2WKH output. + // + // TODO: Handle wallets that generate non-witness change + // addresses. + // TODO(halseth): make coinSelect not estimate change output + // for dust change. + weightEstimate.AddP2WKHOutput() + + // The difference between the selected amount and the amount + // requested will be used to pay fees, and generate a change + // output with the remaining. + overShootAmt := totalSat - amt + + // Based on the estimated size and fee rate, if the excess + // amount isn't enough to pay fees, then increase the requested + // coin amount by the estimate required fee, performing another + // round of coin selection. + totalWeight := int64(weightEstimate.Weight()) + requiredFee := feeRate.FeeForWeight(totalWeight) + if overShootAmt < requiredFee { + amtNeeded = amt + requiredFee + continue + } + + // If the fee is sufficient, then calculate the size of the + // change output. + changeAmt := overShootAmt - requiredFee + + return selectedUtxos, changeAmt, nil + } +} + +// CoinSelectSubtractFees attempts to select coins such that we'll spend up to +// amt in total after fees, adhering to the specified fee rate. The selected +// coins, the final output and change values are returned. +func CoinSelectSubtractFees(feeRate chainfee.SatPerKWeight, amt, + dustLimit btcutil.Amount, coins []Coin) ([]Coin, btcutil.Amount, + btcutil.Amount, error) { + + // First perform an initial round of coin selection to estimate + // the required fee. + totalSat, selectedUtxos, err := selectInputs(amt, coins) + if err != nil { + return nil, 0, 0, err + } + + var weightEstimate input.TxWeightEstimator + for _, utxo := range selectedUtxos { + switch { + + case txscript.IsPayToWitnessPubKeyHash(utxo.PkScript): + weightEstimate.AddP2WKHInput() + + case txscript.IsPayToScriptHash(utxo.PkScript): + weightEstimate.AddNestedP2WKHInput() + + default: + return nil, 0, 0, fmt.Errorf("unsupported address "+ + "type: %x", utxo.PkScript) + } + } + + // Channel funding multisig output is P2WSH. + weightEstimate.AddP2WSHOutput() + + // At this point we've got two possibilities, either create a + // change output, or not. We'll first try without creating a + // change output. + // + // Estimate the fee required for a transaction without a change + // output. + totalWeight := int64(weightEstimate.Weight()) + requiredFee := feeRate.FeeForWeight(totalWeight) + + // For a transaction without a change output, we'll let everything go + // to our multi-sig output after subtracting fees. + outputAmt := totalSat - requiredFee + changeAmt := btcutil.Amount(0) + + // If the the output is too small after subtracting the fee, the coin + // selection cannot be performed with an amount this small. + if outputAmt <= dustLimit { + return nil, 0, 0, fmt.Errorf("output amount(%v) after "+ + "subtracting fees(%v) below dust limit(%v)", outputAmt, + requiredFee, dustLimit) + } + + // We were able to create a transaction with no change from the + // selected inputs. We'll remember the resulting values for + // now, while we try to add a change output. Assume that change output + // is a P2WKH output. + weightEstimate.AddP2WKHOutput() + + // Now that we have added the change output, redo the fee + // estimate. + totalWeight = int64(weightEstimate.Weight()) + requiredFee = feeRate.FeeForWeight(totalWeight) + + // For a transaction with a change output, everything we don't spend + // will go to change. + newChange := totalSat - amt + newOutput := amt - requiredFee + + // If adding a change output leads to both outputs being above + // the dust limit, we'll add the change output. Otherwise we'll + // go with the no change tx we originally found. + if newChange > dustLimit && newOutput > dustLimit { + outputAmt = newOutput + changeAmt = newChange + } + + // Sanity check the resulting output values to make sure we + // don't burn a great part to fees. + totalOut := outputAmt + changeAmt + fee := totalSat - totalOut + + // Fail if more than 20% goes to fees. + // TODO(halseth): smarter fee limit. Make configurable or dynamic wrt + // total funding size? + if fee > totalOut/5 { + return nil, 0, 0, fmt.Errorf("fee %v on total output"+ + "value %v", fee, totalOut) + } + + return selectedUtxos, outputAmt, changeAmt, nil +} diff --git a/lnwallet/wallet_test.go b/lnwallet/chanfunding/coin_select_test.go similarity index 82% rename from lnwallet/wallet_test.go rename to lnwallet/chanfunding/coin_select_test.go index 903f5e4d..67b24976 100644 --- a/lnwallet/wallet_test.go +++ b/lnwallet/chanfunding/coin_select_test.go @@ -1,13 +1,21 @@ -package lnwallet +package chanfunding import ( + "encoding/hex" "testing" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcutil" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) +var ( + p2wkhScript, _ = hex.DecodeString( + "001411034bdcb6ccb7744fdfdeea958a6fb0b415a032", + ) +) + // fundingFee is a helper method that returns the fee estimate used for a tx // with the given number of inputs and the optional change output. This matches // the estimate done by the wallet. @@ -48,7 +56,7 @@ func TestCoinSelect(t *testing.T) { type testCase struct { name string outputValue btcutil.Amount - coins []*Utxo + coins []Coin expectedInput []btcutil.Amount expectedChange btcutil.Amount @@ -61,10 +69,12 @@ func TestCoinSelect(t *testing.T) { // This will obviously lead to a change output of // almost 0.5 BTC. name: "big change", - coins: []*Utxo{ + coins: []Coin{ { - AddressType: WitnessPubKey, - Value: 1 * btcutil.SatoshiPerBitcoin, + TxOut: wire.TxOut{ + PkScript: p2wkhScript, + Value: 1 * btcutil.SatoshiPerBitcoin, + }, }, }, outputValue: 0.5 * btcutil.SatoshiPerBitcoin, @@ -81,10 +91,12 @@ func TestCoinSelect(t *testing.T) { // This should lead to an error, as we don't have // enough funds to pay the fee. name: "nothing left for fees", - coins: []*Utxo{ + coins: []Coin{ { - AddressType: WitnessPubKey, - Value: 1 * btcutil.SatoshiPerBitcoin, + TxOut: wire.TxOut{ + PkScript: p2wkhScript, + Value: 1 * btcutil.SatoshiPerBitcoin, + }, }, }, outputValue: 1 * btcutil.SatoshiPerBitcoin, @@ -95,10 +107,12 @@ func TestCoinSelect(t *testing.T) { // as big as possible, such that the remaining change // will be dust. name: "dust change", - coins: []*Utxo{ + coins: []Coin{ { - AddressType: WitnessPubKey, - Value: 1 * btcutil.SatoshiPerBitcoin, + TxOut: wire.TxOut{ + PkScript: p2wkhScript, + Value: 1 * btcutil.SatoshiPerBitcoin, + }, }, }, // We tune the output value by subtracting the expected @@ -117,10 +131,12 @@ func TestCoinSelect(t *testing.T) { // as big as possible, such that there is nothing left // for change. name: "no change", - coins: []*Utxo{ + coins: []Coin{ { - AddressType: WitnessPubKey, - Value: 1 * btcutil.SatoshiPerBitcoin, + TxOut: wire.TxOut{ + PkScript: p2wkhScript, + Value: 1 * btcutil.SatoshiPerBitcoin, + }, }, }, // We tune the output value to be the maximum amount @@ -143,7 +159,7 @@ func TestCoinSelect(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - selected, changeAmt, err := coinSelect( + selected, changeAmt, err := CoinSelect( feeRate, test.outputValue, test.coins, ) if !test.expectErr && err != nil { @@ -166,7 +182,7 @@ func TestCoinSelect(t *testing.T) { } for i, coin := range selected { - if coin.Value != test.expectedInput[i] { + if coin.Value != int64(test.expectedInput[i]) { t.Fatalf("expected input %v to have value %v, "+ "had %v", i, test.expectedInput[i], coin.Value) @@ -195,7 +211,7 @@ func TestCoinSelectSubtractFees(t *testing.T) { type testCase struct { name string spendValue btcutil.Amount - coins []*Utxo + coins []Coin expectedInput []btcutil.Amount expectedFundingAmt btcutil.Amount @@ -209,10 +225,12 @@ func TestCoinSelectSubtractFees(t *testing.T) { // should lead to a funding TX with one output, the // rest goes to fees. name: "spend all", - coins: []*Utxo{ + coins: []Coin{ { - AddressType: WitnessPubKey, - Value: 1 * btcutil.SatoshiPerBitcoin, + TxOut: wire.TxOut{ + PkScript: p2wkhScript, + Value: 1 * btcutil.SatoshiPerBitcoin, + }, }, }, spendValue: 1 * btcutil.SatoshiPerBitcoin, @@ -228,10 +246,12 @@ func TestCoinSelectSubtractFees(t *testing.T) { // The total funds available is below the dust limit // after paying fees. name: "dust output", - coins: []*Utxo{ + coins: []Coin{ { - AddressType: WitnessPubKey, - Value: fundingFee(feeRate, 1, false) + dust, + TxOut: wire.TxOut{ + PkScript: p2wkhScript, + Value: int64(fundingFee(feeRate, 1, false) + dust), + }, }, }, spendValue: fundingFee(feeRate, 1, false) + dust, @@ -243,10 +263,12 @@ func TestCoinSelectSubtractFees(t *testing.T) { // is below the dust limit. The remainder should go // towards the funding output. name: "dust change", - coins: []*Utxo{ + coins: []Coin{ { - AddressType: WitnessPubKey, - Value: 1 * btcutil.SatoshiPerBitcoin, + TxOut: wire.TxOut{ + PkScript: p2wkhScript, + Value: 1 * btcutil.SatoshiPerBitcoin, + }, }, }, spendValue: 1*btcutil.SatoshiPerBitcoin - dust, @@ -260,10 +282,12 @@ func TestCoinSelectSubtractFees(t *testing.T) { { // We got just enough funds to create an output above the dust limit. name: "output right above dustlimit", - coins: []*Utxo{ + coins: []Coin{ { - AddressType: WitnessPubKey, - Value: fundingFee(feeRate, 1, false) + dustLimit + 1, + TxOut: wire.TxOut{ + PkScript: p2wkhScript, + Value: int64(fundingFee(feeRate, 1, false) + dustLimit + 1), + }, }, }, spendValue: fundingFee(feeRate, 1, false) + dustLimit + 1, @@ -278,10 +302,12 @@ func TestCoinSelectSubtractFees(t *testing.T) { // Amount left is below dust limit after paying fee for // a change output, resulting in a no-change tx. name: "no amount to pay fee for change", - coins: []*Utxo{ + coins: []Coin{ { - AddressType: WitnessPubKey, - Value: fundingFee(feeRate, 1, false) + 2*(dustLimit+1), + TxOut: wire.TxOut{ + PkScript: p2wkhScript, + Value: int64(fundingFee(feeRate, 1, false) + 2*(dustLimit+1)), + }, }, }, spendValue: fundingFee(feeRate, 1, false) + dustLimit + 1, @@ -295,10 +321,12 @@ func TestCoinSelectSubtractFees(t *testing.T) { { // If more than 20% of funds goes to fees, it should fail. name: "high fee", - coins: []*Utxo{ + coins: []Coin{ { - AddressType: WitnessPubKey, - Value: 5 * fundingFee(feeRate, 1, false), + TxOut: wire.TxOut{ + PkScript: p2wkhScript, + Value: int64(5 * fundingFee(feeRate, 1, false)), + }, }, }, spendValue: 5 * fundingFee(feeRate, 1, false), @@ -308,8 +336,10 @@ func TestCoinSelectSubtractFees(t *testing.T) { } for _, test := range testCases { + test := test + t.Run(test.name, func(t *testing.T) { - selected, localFundingAmt, changeAmt, err := coinSelectSubtractFees( + selected, localFundingAmt, changeAmt, err := CoinSelectSubtractFees( feeRate, test.spendValue, dustLimit, test.coins, ) if !test.expectErr && err != nil { @@ -332,7 +362,7 @@ func TestCoinSelectSubtractFees(t *testing.T) { } for i, coin := range selected { - if coin.Value != test.expectedInput[i] { + if coin.Value != int64(test.expectedInput[i]) { t.Fatalf("expected input %v to have value %v, "+ "had %v", i, test.expectedInput[i], coin.Value) diff --git a/lnwallet/wallet.go b/lnwallet/wallet.go index 342579e9..b6b552a5 100644 --- a/lnwallet/wallet.go +++ b/lnwallet/wallet.go @@ -22,6 +22,7 @@ import ( "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwallet/chanfunding" "github.com/lightningnetwork/lnd/lnwallet/chanvalidate" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/shachain" @@ -33,20 +34,6 @@ const ( msgBufferSize = 100 ) -// ErrInsufficientFunds is a type matching the error interface which is -// returned when coin selection for a new funding transaction fails to due -// having an insufficient amount of confirmed funds. -type ErrInsufficientFunds struct { - amountAvailable btcutil.Amount - amountSelected btcutil.Amount -} - -func (e *ErrInsufficientFunds) Error() string { - return fmt.Sprintf("not enough witness outputs to create funding transaction,"+ - " need %v only have %v available", e.amountAvailable, - e.amountSelected) -} - // InitFundingReserveMsg is the first message sent to initiate the workflow // required to open a payment channel with a remote peer. The initial required // parameters are configurable across channels. These parameters are to be @@ -1341,13 +1328,24 @@ func (l *LightningWallet) selectCoinsAndChange(feeRate chainfee.SatPerKWeight, // Find all unlocked unspent witness outputs that satisfy the minimum // number of confirmations required. - coins, err := l.ListUnspentWitness(minConfs, math.MaxInt32) + utxos, err := l.ListUnspentWitness(minConfs, math.MaxInt32) if err != nil { return nil, err } + coins := make([]chanfunding.Coin, len(utxos), 0) + for _, utxo := range utxos { + coins = append(coins, chanfunding.Coin{ + TxOut: wire.TxOut{ + Value: int64(utxo.Value), + PkScript: utxo.PkScript, + }, + OutPoint: utxo.OutPoint, + }) + } + var ( - selectedCoins []*Utxo + selectedCoins []chanfunding.Coin fundingAmt btcutil.Amount changeAmt btcutil.Amount ) @@ -1361,7 +1359,7 @@ func (l *LightningWallet) selectCoinsAndChange(feeRate chainfee.SatPerKWeight, // won't deduct more that the specified balance from our wallet. case subtractFees: dustLimit := l.Cfg.DefaultConstraints.DustLimit - selectedCoins, fundingAmt, changeAmt, err = coinSelectSubtractFees( + selectedCoins, fundingAmt, changeAmt, err = chanfunding.CoinSelectSubtractFees( feeRate, amt, dustLimit, coins, ) if err != nil { @@ -1372,7 +1370,7 @@ func (l *LightningWallet) selectCoinsAndChange(feeRate chainfee.SatPerKWeight, // amount. default: fundingAmt = amt - selectedCoins, changeAmt, err = coinSelect(feeRate, amt, coins) + selectedCoins, changeAmt, err = chanfunding.CoinSelect(feeRate, amt, coins) if err != nil { return nil, err } @@ -1468,179 +1466,6 @@ func initStateHints(commit1, commit2 *wire.MsgTx, return nil } -// selectInputs selects a slice of inputs necessary to meet the specified -// selection amount. If input selection is unable to succeed due to insufficient -// funds, a non-nil error is returned. Additionally, the total amount of the -// selected coins are returned in order for the caller to properly handle -// change+fees. -func selectInputs(amt btcutil.Amount, coins []*Utxo) (btcutil.Amount, []*Utxo, error) { - satSelected := btcutil.Amount(0) - for i, coin := range coins { - satSelected += coin.Value - if satSelected >= amt { - return satSelected, coins[:i+1], nil - } - } - return 0, nil, &ErrInsufficientFunds{amt, satSelected} -} - -// coinSelect attempts to select a sufficient amount of coins, including a -// change output to fund amt satoshis, adhering to the specified fee rate. The -// specified fee rate should be expressed in sat/kw for coin selection to -// function properly. -func coinSelect(feeRate chainfee.SatPerKWeight, amt btcutil.Amount, - coins []*Utxo) ([]*Utxo, btcutil.Amount, error) { - - amtNeeded := amt - for { - // First perform an initial round of coin selection to estimate - // the required fee. - totalSat, selectedUtxos, err := selectInputs(amtNeeded, coins) - if err != nil { - return nil, 0, err - } - - var weightEstimate input.TxWeightEstimator - - for _, utxo := range selectedUtxos { - switch utxo.AddressType { - case WitnessPubKey: - weightEstimate.AddP2WKHInput() - case NestedWitnessPubKey: - weightEstimate.AddNestedP2WKHInput() - default: - return nil, 0, fmt.Errorf("unsupported address type: %v", - utxo.AddressType) - } - } - - // Channel funding multisig output is P2WSH. - weightEstimate.AddP2WSHOutput() - - // Assume that change output is a P2WKH output. - // - // TODO: Handle wallets that generate non-witness change - // addresses. - // TODO(halseth): make coinSelect not estimate change output - // for dust change. - weightEstimate.AddP2WKHOutput() - - // The difference between the selected amount and the amount - // requested will be used to pay fees, and generate a change - // output with the remaining. - overShootAmt := totalSat - amt - - // Based on the estimated size and fee rate, if the excess - // amount isn't enough to pay fees, then increase the requested - // coin amount by the estimate required fee, performing another - // round of coin selection. - totalWeight := int64(weightEstimate.Weight()) - requiredFee := feeRate.FeeForWeight(totalWeight) - if overShootAmt < requiredFee { - amtNeeded = amt + requiredFee - continue - } - - // If the fee is sufficient, then calculate the size of the - // change output. - changeAmt := overShootAmt - requiredFee - - return selectedUtxos, changeAmt, nil - } -} - -// coinSelectSubtractFees attempts to select coins such that we'll spend up to -// amt in total after fees, adhering to the specified fee rate. The selected -// coins, the final output and change values are returned. -func coinSelectSubtractFees(feeRate chainfee.SatPerKWeight, amt, - dustLimit btcutil.Amount, coins []*Utxo) ([]*Utxo, btcutil.Amount, - btcutil.Amount, error) { - - // First perform an initial round of coin selection to estimate - // the required fee. - totalSat, selectedUtxos, err := selectInputs(amt, coins) - if err != nil { - return nil, 0, 0, err - } - - var weightEstimate input.TxWeightEstimator - for _, utxo := range selectedUtxos { - switch utxo.AddressType { - case WitnessPubKey: - weightEstimate.AddP2WKHInput() - case NestedWitnessPubKey: - weightEstimate.AddNestedP2WKHInput() - default: - return nil, 0, 0, fmt.Errorf("unsupported "+ - "address type: %v", utxo.AddressType) - } - } - - // Channel funding multisig output is P2WSH. - weightEstimate.AddP2WSHOutput() - - // At this point we've got two possibilities, either create a - // change output, or not. We'll first try without creating a - // change output. - // - // Estimate the fee required for a transaction without a change - // output. - totalWeight := int64(weightEstimate.Weight()) - requiredFee := feeRate.FeeForWeight(totalWeight) - - // For a transaction without a change output, we'll let everything go - // to our multi-sig output after subtracting fees. - outputAmt := totalSat - requiredFee - changeAmt := btcutil.Amount(0) - - // If the the output is too small after subtracting the fee, the coin - // selection cannot be performed with an amount this small. - if outputAmt <= dustLimit { - return nil, 0, 0, fmt.Errorf("output amount(%v) after "+ - "subtracting fees(%v) below dust limit(%v)", outputAmt, - requiredFee, dustLimit) - } - - // We were able to create a transaction with no change from the - // selected inputs. We'll remember the resulting values for - // now, while we try to add a change output. Assume that change output - // is a P2WKH output. - weightEstimate.AddP2WKHOutput() - - // Now that we have added the change output, redo the fee - // estimate. - totalWeight = int64(weightEstimate.Weight()) - requiredFee = feeRate.FeeForWeight(totalWeight) - - // For a transaction with a change output, everything we don't spend - // will go to change. - newChange := totalSat - amt - newOutput := amt - requiredFee - - // If adding a change output leads to both outputs being above - // the dust limit, we'll add the change output. Otherwise we'll - // go with the no change tx we originally found. - if newChange > dustLimit && newOutput > dustLimit { - outputAmt = newOutput - changeAmt = newChange - } - - // Sanity check the resulting output values to make sure we - // don't burn a great part to fees. - totalOut := outputAmt + changeAmt - fee := totalSat - totalOut - - // Fail if more than 20% goes to fees. - // TODO(halseth): smarter fee limit. Make configurable or dynamic wrt - // total funding size? - if fee > totalOut/5 { - return nil, 0, 0, fmt.Errorf("fee %v on total output"+ - "value %v", fee, totalOut) - } - - return selectedUtxos, outputAmt, changeAmt, nil -} - // ValidateChannel will attempt to fully validate a newly mined channel, given // its funding transaction and existing channel state. If this method returns // an error, then the mined channel is invalid, and shouldn't be used.