diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 183ef7f5e..970dc32e3 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -542,4 +542,8 @@ var allTestCases = []*lntest.TestCase{ Name: "custom features", TestFunc: testCustomFeatures, }, + { + Name: "utxo selection funding", + TestFunc: testChannelUtxoSelection, + }, } diff --git a/itest/lnd_channel_funding_utxo_selection_test.go b/itest/lnd_channel_funding_utxo_selection_test.go new file mode 100644 index 000000000..f52af7a41 --- /dev/null +++ b/itest/lnd_channel_funding_utxo_selection_test.go @@ -0,0 +1,369 @@ +package itest + +import ( + "context" + "fmt" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/walletrpc" + "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lntest/node" + "github.com/lightningnetwork/lnd/lnwallet" +) + +type chanFundUtxoSelectionTestCase struct { + // name is the name of the target test case. + name string + + // initialCoins are the initial coins in Alice's wallet. + initialCoins []btcutil.Amount + + // selectedCoins are the coins alice is selecting for funding a channel. + selectedCoins []btcutil.Amount + + // localAmt is the local portion of the channel funding amount. + localAmt btcutil.Amount + + // pushAmt is the amount to be pushed to Bob. + pushAmt btcutil.Amount + + // feeRate is an optional fee in satoshi/bytes used when opening a + // channel. + feeRate btcutil.Amount + + // expectedBalance is Alice's expected balance in her channel. + expectedBalance btcutil.Amount + + // remainingWalletBalance is Alice's expected remaining wallet balance + // after she opened a channgel. + remainingWalletBalance btcutil.Amount + + // chanOpenShouldFail denotes if we expect the channel opening to fail. + chanOpenShouldFail bool + + // expectedErrStr contains the expected error in case chanOpenShouldFail + // is set to true. + expectedErrStr string + + // commitmentType allows to define the exact type when opening the + // channel. + commitmentType lnrpc.CommitmentType + + // reuseUtxo tries to spent a previously spent output. + reuseUtxo bool +} + +// testChannelUtxoSelection checks various channel funding scenarios where the +// user instructed the wallet to use a selection funds available in the wallet. +func testChannelUtxoSelection(ht *lntest.HarnessTest) { + // Create two new nodes that open a channel between each other for these + // tests. + args := lntest.NodeArgsForCommitType(lnrpc.CommitmentType_ANCHORS) + alice := ht.NewNode("Alice", args) + defer ht.Shutdown(alice) + + bob := ht.NewNode("Bob", args) + defer ht.Shutdown(bob) + + // Ensure both sides are connected so the funding flow can be properly + // executed. + ht.EnsureConnected(alice, bob) + + // Calculate reserve amount for one channel. + reserveResp, _ := alice.RPC.WalletKit.RequiredReserve( + context.Background(), &walletrpc.RequiredReserveRequest{ + AdditionalPublicChannels: 1, + }, + ) + + reserveAmount := btcutil.Amount(reserveResp.RequiredReserve) + + var tcs = []*chanFundUtxoSelectionTestCase{ + // Selected coins would leave a dust output after subtracting + // miner fees. + { + name: "fundmax, wallet amount is dust", + initialCoins: []btcutil.Amount{2_000}, + selectedCoins: []btcutil.Amount{2_000}, + chanOpenShouldFail: true, + feeRate: 15, + expectedErrStr: "output amount(0.00000174 BTC) after " + + "subtracting fees(0.00001826 BTC) below dust " + + "limit(0.0000033 BTC)", + }, + // Selected coins don't cover the minimum channel size. + { + name: "fundmax, local amount < min chan " + + "size", + initialCoins: []btcutil.Amount{18_000}, + selectedCoins: []btcutil.Amount{18_000}, + feeRate: 1, + chanOpenShouldFail: true, + expectedErrStr: "available funds(0.00017877 BTC) " + + "below the minimum amount(0.0002 BTC)", + }, + // The local amount exceeds the value of the selected coins. + { + name: "selected, local amount > " + + "selected amount", + initialCoins: []btcutil.Amount{100_000, 50_000}, + selectedCoins: []btcutil.Amount{100_000}, + localAmt: btcutil.Amount(210_337), + chanOpenShouldFail: true, + expectedErrStr: "not enough witness outputs to " + + "create funding transaction, need 0.00210337 " + + "BTC only have 0.001 BTC available", + }, + // We are spending two selected coins partially out of three + // available in the wallet and expect a change output and the + // unselected coin as remaining wallet balance. + { + name: "selected, local amount > " + + "min chan size", + initialCoins: []btcutil.Amount{ + 200_000, 50_000, 100_000, + }, + selectedCoins: []btcutil.Amount{ + 200_000, 100_000, + }, + localAmt: btcutil.Amount(250_000), + expectedBalance: btcutil.Amount(250_000), + remainingWalletBalance: btcutil.Amount(350_000) - + btcutil.Amount(250_000) - + fundingFee(2, true), + }, + // We are spending the entirety of two selected coins out of + // three available in the wallet and expect no change output and + // the unselected coin as remaining wallet balance. + { + name: "fundmax, local amount > min " + + "chan size", + initialCoins: []btcutil.Amount{ + 200_000, 100_000, 50_000, + }, + selectedCoins: []btcutil.Amount{ + 200_000, 50_000, + }, + expectedBalance: btcutil.Amount(200_000) + + btcutil.Amount(50_000) - + fundingFee(2, false), + remainingWalletBalance: btcutil.Amount(100_000), + }, + // Select all coins in wallet and use the maximum available + // local amount to fund an anchor channel. + { + name: "selected, local amount leaves sufficient " + + "reserve", + initialCoins: []btcutil.Amount{ + 200_000, 100_000, + }, + selectedCoins: []btcutil.Amount{200_000, 100_000}, + commitmentType: lnrpc.CommitmentType_ANCHORS, + localAmt: btcutil.Amount(300_000) - + reserveAmount - + fundingFee(2, true), + expectedBalance: btcutil.Amount(300_000) - + reserveAmount - + fundingFee(2, true), + remainingWalletBalance: reserveAmount, + }, + // Select all coins in wallet towards local amount except for a + // anchor reserve portion. + { + name: "selected, reserve from selected", + initialCoins: []btcutil.Amount{ + 200_000, reserveAmount, 100_000, + }, + selectedCoins: []btcutil.Amount{ + 200_000, reserveAmount, 100_000, + }, + commitmentType: lnrpc.CommitmentType_ANCHORS, + localAmt: btcutil.Amount(300_000) - + fundingFee(3, true), + expectedBalance: btcutil.Amount(300_000) - + fundingFee(3, true), + remainingWalletBalance: reserveAmount, + }, + // Select all coins in wallet and use more than the maximum + // available local amount to fund an anchor channel. + { + name: "selected, local amount leaves insufficient " + + "reserve", + initialCoins: []btcutil.Amount{ + 200_000, 100_000, + }, + selectedCoins: []btcutil.Amount{200_000, 100_000}, + commitmentType: lnrpc.CommitmentType_ANCHORS, + localAmt: btcutil.Amount(300_000) - + reserveAmount + 1 - + fundingFee(2, true), + chanOpenShouldFail: true, + expectedErrStr: "reserved wallet balance " + + "invalidated: transaction would leave " + + "insufficient funds for fee bumping anchor " + + "channel closings", + }, + // We fund an anchor channel with a single coin and just keep + // enough funds in the wallet to cover for the anchor reserve. + { + name: "fundmax, sufficient reserve", + initialCoins: []btcutil.Amount{ + 200_000, reserveAmount, + }, + selectedCoins: []btcutil.Amount{200_000}, + commitmentType: lnrpc.CommitmentType_ANCHORS, + expectedBalance: btcutil.Amount(200_000) - + fundingFee(1, false), + remainingWalletBalance: reserveAmount, + }, + // We fund an anchor channel with a single coin and expect the + // reserve amount left in the wallet. + { + name: "fundmax, sufficient reserve from channel " + + "balance carve out", + initialCoins: []btcutil.Amount{ + 200_000, + }, + selectedCoins: []btcutil.Amount{200_000}, + commitmentType: lnrpc.CommitmentType_ANCHORS, + expectedBalance: btcutil.Amount(200_000) - + reserveAmount - + fundingFee(1, true), + remainingWalletBalance: reserveAmount, + }, + // Confirm that already spent outputs can't be reused to fund + // another channel. + { + name: "output already spent", + initialCoins: []btcutil.Amount{ + 200_000, + }, + selectedCoins: []btcutil.Amount{200_000}, + reuseUtxo: true, + }, + } + + for _, tc := range tcs { + success := ht.Run( + tc.name, func(tt *testing.T) { + runUtxoSelectionTestCase( + ht, tt, alice, bob, tc, + reserveAmount, + ) + }, + ) + + // Stop at the first failure. Mimic behavior of original test + if !success { + break + } + } +} + +// runUtxoSelectionTestCase runs a single test case asserting that test +// conditions are met. +func runUtxoSelectionTestCase(ht *lntest.HarnessTest, t *testing.T, alice, + bob *node.HarnessNode, tc *chanFundUtxoSelectionTestCase, + reserveAmount btcutil.Amount) { + + // fund initial coins + for _, initialCoin := range tc.initialCoins { + ht.FundCoins(initialCoin, alice) + } + defer func() { + // Fund additional coins to sweep in case the wallet contains + // dust. + ht.FundCoins(100_000, alice) + + // Remove all funds from Alice. + sweepNodeWalletAndAssert(ht, alice) + }() + + // Create an outpoint lookup for each unique amount. + lookup := make(map[int64]*lnrpc.OutPoint) + res := alice.RPC.ListUnspent(&walletrpc.ListUnspentRequest{}) + for _, utxo := range res.Utxos { + lookup[utxo.AmountSat] = utxo.Outpoint + } + + // Map the selected coin to the respective outpoint. + selectedOutpoints := []*lnrpc.OutPoint{} + for _, selectedCoin := range tc.selectedCoins { + if outpoint, ok := lookup[int64(selectedCoin)]; ok { + selectedOutpoints = append( + selectedOutpoints, outpoint, + ) + } + } + + commitType := tc.commitmentType + if commitType == lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE { + commitType = lnrpc.CommitmentType_STATIC_REMOTE_KEY + } + + // The parameters to try opening the channel with. + fundMax := false + if tc.localAmt == 0 { + fundMax = true + } + chanParams := lntest.OpenChannelParams{ + Amt: tc.localAmt, + FundMax: fundMax, + PushAmt: tc.pushAmt, + CommitmentType: commitType, + SatPerVByte: tc.feeRate, + Outpoints: selectedOutpoints, + } + + // If we don't expect the channel opening to be + // successful, simply check for an error. + if tc.chanOpenShouldFail { + expectedErr := fmt.Errorf(tc.expectedErrStr) + ht.OpenChannelAssertErr( + alice, bob, chanParams, expectedErr, + ) + + return + } + + // Otherwise, if we expect to open a channel use the helper function. + chanPoint := ht.OpenChannel(alice, bob, chanParams) + defer ht.CloseChannel(alice, chanPoint) + + // When re-selecting a spent output for funding another channel we + // expect the respective error message. + if tc.reuseUtxo { + expectedErrStr := fmt.Sprintf("outpoint already spent: %s:%d", + selectedOutpoints[0].TxidStr, + selectedOutpoints[0].OutputIndex) + expectedErr := fmt.Errorf(expectedErrStr) + ht.OpenChannelAssertErr( + alice, bob, chanParams, expectedErr, + ) + + return + } + + cType := ht.GetChannelCommitType(alice, chanPoint) + + // Alice's balance should be her amount subtracted by the commitment + // transaction fee. + checkChannelBalance( + ht, alice, tc.expectedBalance-lntest.CalcStaticFee(cType, 0), + tc.pushAmt, + ) + + // Ensure Bob's balance within the channel is equal to the push amount. + checkChannelBalance( + ht, bob, tc.pushAmt, + tc.expectedBalance-lntest.CalcStaticFee(cType, 0), + ) + + ht.AssertWalletAccountBalance( + alice, lnwallet.DefaultAccountName, + int64(tc.remainingWalletBalance), + 0, + ) +} diff --git a/lntest/harness.go b/lntest/harness.go index f77be59f4..b12255957 100644 --- a/lntest/harness.go +++ b/lntest/harness.go @@ -896,6 +896,13 @@ type OpenChannelParams struct { // has no bearing on the channel's operation. Max allowed length is 500 // characters. Memo string + + // Outpoints is a list of client-selected outpoints that should be used + // for funding a channel. If Amt is specified then this amount is + // allocated from the sum of outpoints towards funding. If the + // FundMax flag is specified the entirety of selected funds is + // allocated towards channel funding. + Outpoints []*lnrpc.OutPoint } // prepareOpenChannel waits for both nodes to be synced to chain and returns an @@ -938,6 +945,7 @@ func (h *HarnessTest) prepareOpenChannel(srcNode, destNode *node.HarnessNode, UseFeeRate: p.UseFeeRate, FundMax: p.FundMax, Memo: p.Memo, + Outpoints: p.Outpoints, } }