chanfunding: fee estimation based on change output in CoinSelect

Add a dust-limit to `CoinSelect` and let the fee estimation consider if a change output is needed or not, assuring that if the change is below the dust-limit it will go towards the fee instead.
This commit is contained in:
Bjarne Magnussen 2021-04-13 11:37:06 +02:00
parent 235e73205d
commit 59c40ec8b4
No known key found for this signature in database
GPG Key ID: B0A9ADF6B24CE67F
3 changed files with 72 additions and 33 deletions

@ -122,7 +122,7 @@ func sanityCheckFee(totalOut, fee btcutil.Amount) error {
// change output to fund amt satoshis, adhering to the specified fee rate. The // 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 // specified fee rate should be expressed in sat/kw for coin selection to
// function properly. // function properly.
func CoinSelect(feeRate chainfee.SatPerKWeight, amt btcutil.Amount, func CoinSelect(feeRate chainfee.SatPerKWeight, amt, dustLimit btcutil.Amount,
coins []Coin) ([]Coin, btcutil.Amount, error) { coins []Coin) ([]Coin, btcutil.Amount, error) {
amtNeeded := amt amtNeeded := amt
@ -136,29 +136,55 @@ func CoinSelect(feeRate chainfee.SatPerKWeight, amt btcutil.Amount,
// Obtain fee estimates both with and without using a change // Obtain fee estimates both with and without using a change
// output. // output.
_, requiredFeeWithChange, err := calculateFees( requiredFeeNoChange, requiredFeeWithChange, err := calculateFees(
selectedUtxos, feeRate, selectedUtxos, feeRate,
) )
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
// The difference between the selected amount and the amount // The difference between the selected amount and the amount
// requested will be used to pay fees, and generate a change // requested will be used to pay fees, and generate a change
// output with the remaining. // output with the remaining.
overShootAmt := totalSat - amt overShootAmt := totalSat - amt
// Based on the estimated size and fee rate, if the excess var changeAmt btcutil.Amount
// amount isn't enough to pay fees, then increase the requested
// coin amount by the estimate required fee, performing another switch {
// round of coin selection.
if overShootAmt < requiredFeeWithChange { // If the excess amount isn't enough to pay for fees based on
amtNeeded = amt + requiredFeeWithChange // 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 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 the fee is sufficient, then calculate the size of the if changeAmt < dustLimit {
// change output. changeAmt = 0
changeAmt := overShootAmt - requiredFeeWithChange }
// 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 return selectedUtxos, changeAmt, nil
} }

@ -143,7 +143,7 @@ func TestCoinSelect(t *testing.T) {
t.Parallel() t.Parallel()
const feeRate = chainfee.SatPerKWeight(100) const feeRate = chainfee.SatPerKWeight(100)
const dust = btcutil.Amount(100) const dustLimit = btcutil.Amount(1000)
type testCase struct { type testCase struct {
name string name string
@ -197,7 +197,7 @@ func TestCoinSelect(t *testing.T) {
{ {
// We have a 1 BTC input, and want to create an output // We have a 1 BTC input, and want to create an output
// as big as possible, such that the remaining change // as big as possible, such that the remaining change
// will be dust. // would be dust but instead goes to fees.
name: "dust change", name: "dust change",
coins: []Coin{ coins: []Coin{
{ {
@ -208,41 +208,53 @@ func TestCoinSelect(t *testing.T) {
}, },
}, },
// We tune the output value by subtracting the expected // We tune the output value by subtracting the expected
// fee and a small dust amount. // fee and the dustlimit.
outputValue: 1*btcutil.SatoshiPerBitcoin - fundingFee(feeRate, 1, true) - dust, outputValue: 1*btcutil.SatoshiPerBitcoin - fundingFee(feeRate, 1, false) - dustLimit,
expectedInput: []btcutil.Amount{ expectedInput: []btcutil.Amount{
1 * btcutil.SatoshiPerBitcoin, 1 * btcutil.SatoshiPerBitcoin,
}, },
// Change will the dust. // Change must be zero.
expectedChange: dust, expectedChange: 0,
}, },
{ {
// We have a 1 BTC input, and want to create an output // We got just enough funds to create a change output above the
// as big as possible, such that there is nothing left // dust limit.
// for change. name: "change right above dustlimit",
name: "no change",
coins: []Coin{ coins: []Coin{
{ {
TxOut: wire.TxOut{ TxOut: wire.TxOut{
PkScript: p2wkhScript, PkScript: p2wkhScript,
Value: 1 * btcutil.SatoshiPerBitcoin, Value: int64(fundingFee(feeRate, 1, true) + 2*(dustLimit+1)),
}, },
}, },
}, },
// We tune the output value to be the maximum amount // We tune the output value to be just above the dust limit.
// possible, leaving just enough for fees. outputValue: dustLimit + 1,
outputValue: 1*btcutil.SatoshiPerBitcoin - fundingFee(feeRate, 1, true),
expectedInput: []btcutil.Amount{ expectedInput: []btcutil.Amount{
1 * btcutil.SatoshiPerBitcoin, fundingFee(feeRate, 1, true) + 2*(dustLimit+1),
}, },
// We have just enough left to pay the fee, so there is
// nothing left for change. // After paying for the fee the change output should be just above
// TODO(halseth): currently coinselect estimates fees // the dust limit.
// assuming a change output. expectedChange: dustLimit + 1,
expectedChange: 0, },
{
// If more than 20% of funds goes to fees, it should fail.
name: "high fee",
coins: []Coin{
{
TxOut: wire.TxOut{
PkScript: p2wkhScript,
Value: int64(5 * fundingFee(feeRate, 1, false)),
},
},
},
outputValue: 4 * fundingFee(feeRate, 1, false),
expectErr: true,
}, },
} }
@ -252,7 +264,7 @@ func TestCoinSelect(t *testing.T) {
t.Parallel() t.Parallel()
selected, changeAmt, err := CoinSelect( selected, changeAmt, err := CoinSelect(
feeRate, test.outputValue, test.coins, feeRate, test.outputValue, dustLimit, test.coins,
) )
if !test.expectErr && err != nil { if !test.expectErr && err != nil {
t.Fatalf(err.Error()) t.Fatalf(err.Error())

@ -289,9 +289,10 @@ func (w *WalletAssembler) ProvisionChannel(r *Request) (Intent, error) {
// Otherwise do a normal coin selection where we target a given // Otherwise do a normal coin selection where we target a given
// funding amount. // funding amount.
default: default:
dustLimit := w.cfg.DustLimit
localContributionAmt = r.LocalAmt localContributionAmt = r.LocalAmt
selectedCoins, changeAmt, err = CoinSelect( selectedCoins, changeAmt, err = CoinSelect(
r.FeeRate, r.LocalAmt, coins, r.FeeRate, r.LocalAmt, dustLimit, coins,
) )
if err != nil { if err != nil {
return err return err