Files
lnd/lnwallet/chanfunding/coin_select.go
Oliver Gugger 9bdddbcc56 mod+chanfunding: use coin selection strategy for channel funding
The wallet assembler is now aware of the node config level coin
selection strategy, so we can use it when creating new channels.
2024-02-23 08:58:09 +01:00

366 lines
12 KiB
Go

package chanfunding
import (
"errors"
"fmt"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcwallet/wallet"
"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 due to
// 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)
}
// errUnsupportedInput is a type matching the error interface, which is returned
// when trying to calculate the fee of a transaction that references an
// unsupported script in the outpoint of a transaction input.
type errUnsupportedInput struct {
PkScript []byte
}
// Error returns a human-readable string describing the error.
func (e *errUnsupportedInput) Error() string {
return fmt.Sprintf("unsupported address type: %x", e.PkScript)
}
// 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 []wallet.Coin,
strategy wallet.CoinSelectionStrategy,
feeRate chainfee.SatPerKWeight) (btcutil.Amount, []wallet.Coin, error) {
// All coin selection code in the btcwallet library requires sat/KB.
feeSatPerKB := btcutil.Amount(feeRate.FeePerKVByte())
arrangedCoins, err := strategy.ArrangeCoins(coins, feeSatPerKB)
if err != nil {
return 0, nil, err
}
satSelected := btcutil.Amount(0)
for i, coin := range arrangedCoins {
satSelected += btcutil.Amount(coin.Value)
if satSelected >= amt {
return satSelected, arrangedCoins[:i+1], nil
}
}
return 0, nil, &ErrInsufficientFunds{amt, satSelected}
}
// calculateFees returns for the specified utxos and fee rate two fee
// estimates, one calculated using a change output and one without. The weight
// added to the estimator from a change output is for a P2WKH output.
func calculateFees(utxos []wallet.Coin,
feeRate chainfee.SatPerKWeight) (btcutil.Amount, btcutil.Amount,
error) {
var weightEstimate input.TxWeightEstimator
for _, utxo := range utxos {
switch {
case txscript.IsPayToWitnessPubKeyHash(utxo.PkScript):
weightEstimate.AddP2WKHInput()
case txscript.IsPayToScriptHash(utxo.PkScript):
weightEstimate.AddNestedP2WKHInput()
case txscript.IsPayToTaproot(utxo.PkScript):
weightEstimate.AddTaprootKeySpendInput(
txscript.SigHashDefault,
)
default:
return 0, 0, &errUnsupportedInput{utxo.PkScript}
}
}
// Channel funding multisig output is P2WSH.
weightEstimate.AddP2WSHOutput()
// Estimate the fee required for a transaction without a change
// output.
totalWeight := int64(weightEstimate.Weight())
requiredFeeNoChange := feeRate.FeeForWeight(totalWeight)
// Estimate the fee required for a transaction with a change output.
// Assume that change output is a P2TR output.
weightEstimate.AddP2TROutput()
// Now that we have added the change output, redo the fee
// estimate.
totalWeight = int64(weightEstimate.Weight())
requiredFeeWithChange := feeRate.FeeForWeight(totalWeight)
return requiredFeeNoChange, requiredFeeWithChange, nil
}
// sanityCheckFee checks if the specified fee amounts to over 20% of the total
// output amount and raises an error.
func sanityCheckFee(totalOut, fee btcutil.Amount) error {
// 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 fmt.Errorf("fee %v on total output value %v", fee,
totalOut)
}
return nil
}
// 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, dustLimit btcutil.Amount,
coins []wallet.Coin,
strategy wallet.CoinSelectionStrategy) ([]wallet.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, strategy, feeRate,
)
if err != nil {
return nil, 0, err
}
// Obtain fee estimates both with and without using a change
// output.
requiredFeeNoChange, requiredFeeWithChange, err := calculateFees(
selectedUtxos, feeRate,
)
if err != nil {
return nil, 0, err
}
// 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
var changeAmt btcutil.Amount
switch {
// If the excess amount isn't enough to pay for fees based on
// fee rate and estimated size without using a change output,
// then increase the requested coin amount by the estimate
// required fee without using change, performing another round
// of coin selection.
case overShootAmt < requiredFeeNoChange:
amtNeeded = amt + requiredFeeNoChange
continue
// If sufficient funds were selected to cover the fee required
// to include a change output, the remainder will be our change
// amount.
case overShootAmt > requiredFeeWithChange:
changeAmt = overShootAmt - requiredFeeWithChange
// Otherwise we have selected enough to pay for a tx without a
// change output.
default:
changeAmt = 0
}
if changeAmt < dustLimit {
changeAmt = 0
}
// Sanity check the resulting output values to make sure we
// don't burn a great part to fees.
totalOut := amt + changeAmt
err = sanityCheckFee(totalOut, totalSat-totalOut)
if err != nil {
return nil, 0, err
}
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 []wallet.Coin,
strategy wallet.CoinSelectionStrategy) ([]wallet.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, strategy, feeRate,
)
if err != nil {
return nil, 0, 0, err
}
// Obtain fee estimates both with and without using a change
// output.
requiredFeeNoChange, requiredFeeWithChange, err := calculateFees(
selectedUtxos, feeRate,
)
if err != nil {
return nil, 0, 0, err
}
// For a transaction without a change output, we'll let everything go
// to our multi-sig output after subtracting fees.
outputAmt := totalSat - requiredFeeNoChange
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,
requiredFeeNoChange, dustLimit)
}
// For a transaction with a change output, everything we don't spend
// will go to change.
newOutput := amt - requiredFeeWithChange
newChange := totalSat - amt
// 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
err = sanityCheckFee(totalOut, totalSat-totalOut)
if err != nil {
return nil, 0, 0, err
}
return selectedUtxos, outputAmt, changeAmt, nil
}
// CoinSelectUpToAmount attempts to select coins such that we'll select up to
// maxAmount exclusive of fees and optional reserve if sufficient funds are
// available. If insufficient funds are available this method selects all
// available coins.
func CoinSelectUpToAmount(feeRate chainfee.SatPerKWeight, minAmount, maxAmount,
reserved, dustLimit btcutil.Amount, coins []wallet.Coin,
strategy wallet.CoinSelectionStrategy) ([]wallet.Coin, btcutil.Amount,
btcutil.Amount, error) {
var (
// selectSubtractFee is tracking if our coin selection was
// unsuccessful and whether we have to start a new round of
// selecting coins considering fees.
selectSubtractFee = false
outputAmount = maxAmount
)
// Get total balance from coins which we need for reserve considerations
// and fee sanity checks.
var totalBalance btcutil.Amount
for _, coin := range coins {
totalBalance += btcutil.Amount(coin.Value)
}
// First we try to select coins to create an output of the specified
// maxAmount with or without a change output that covers the miner fee.
selected, changeAmt, err := CoinSelect(
feeRate, maxAmount, dustLimit, coins, strategy,
)
var errInsufficientFunds *ErrInsufficientFunds
if err == nil { //nolint:gocritic,ifElseChain
// If the coin selection succeeds we check if our total balance
// covers the selected set of coins including fees plus an
// optional anchor reserve.
// First we sum up the value of all selected coins.
var sumSelected btcutil.Amount
for _, coin := range selected {
sumSelected += btcutil.Amount(coin.Value)
}
// We then subtract the change amount from the value of all
// selected coins to obtain the actual amount that is selected.
sumSelected -= changeAmt
// Next we check if our total balance can cover for the selected
// output plus the optional anchor reserve.
if totalBalance-sumSelected < reserved {
// If our local balance is insufficient to cover for the
// reserve we try to select an output amount that uses
// our total balance minus reserve and fees.
selectSubtractFee = true
}
} else if errors.As(err, &errInsufficientFunds) {
// If the initial coin selection fails due to insufficient funds
// we select our total available balance minus fees.
selectSubtractFee = true
} else {
return nil, 0, 0, err
}
// If we determined that our local balance is insufficient we check
// our total balance minus fees and optional reserve.
if selectSubtractFee {
selected, outputAmount, changeAmt, err = CoinSelectSubtractFees(
feeRate, totalBalance-reserved, dustLimit, coins,
strategy,
)
if err != nil {
return nil, 0, 0, err
}
}
// Sanity check the resulting output values to make sure we don't burn a
// great part to fees.
totalOut := outputAmount + changeAmt
sum := func(coins []wallet.Coin) btcutil.Amount {
var sum btcutil.Amount
for _, coin := range coins {
sum += btcutil.Amount(coin.Value)
}
return sum
}
err = sanityCheckFee(totalOut, sum(selected)-totalOut)
if err != nil {
return nil, 0, 0, err
}
// In case the selected amount is lower than minimum funding amount we
// must return an error. The minimum funding amount is determined
// upstream and denotes either the minimum viable channel size or an
// amount sufficient to cover for the initial remote balance.
if outputAmount < minAmount {
return nil, 0, 0, fmt.Errorf("available funds(%v) below the "+
"minimum amount(%v)", outputAmount, minAmount)
}
return selected, outputAmount, changeAmt, nil
}