From e5625878e95c4b7b831cfb8081ff0a8a21142bcd Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 3 Mar 2022 17:03:54 -0800 Subject: [PATCH] lntest/itest: add new itest for 3rd party anchor spends In this commit, we add a new integration tests to exercise the fix introduced in the prior commit. In this test, we reconstruct a scenario for a 3rd party to sweep an anchor spend after force closing, causing a prior spend we had to be invalidated. Without the prior commit, this test fails as the original anchor sweep is still found in the wallet. --- lntest/itest/assertions.go | 67 ++++++ lntest/itest/lnd_channel_force_close_test.go | 12 +- lntest/itest/lnd_onchain_test.go | 239 +++++++++++++++++++ lntest/itest/lnd_test_list_on_test.go | 4 + 4 files changed, 316 insertions(+), 6 deletions(-) diff --git a/lntest/itest/assertions.go b/lntest/itest/assertions.go index 60e1963ea..248d3bcad 100644 --- a/lntest/itest/assertions.go +++ b/lntest/itest/assertions.go @@ -1789,3 +1789,70 @@ func assertChannelPolicyUpdate(t *testing.T, node *lntest.HarnessNode, ), "error while waiting for channel update", ) } + +func transactionInWallet(node *lntest.HarnessNode, txid chainhash.Hash) bool { + txStr := txid.String() + + txResp, err := node.GetTransactions( + context.Background(), &lnrpc.GetTransactionsRequest{}, + ) + if err != nil { + return false + } + + for _, txn := range txResp.Transactions { + if txn.TxHash == txStr { + return true + } + } + + return false +} + +func assertTransactionInWallet(t *testing.T, node *lntest.HarnessNode, txID chainhash.Hash) { + t.Helper() + + err := wait.Predicate(func() bool { + return transactionInWallet(node, txID) + }, defaultTimeout) + require.NoError( + t, err, fmt.Sprintf("transaction %v not found in wallet", txID), + ) +} + +func assertTransactionNotInWallet(t *testing.T, node *lntest.HarnessNode, + txID chainhash.Hash) { + + t.Helper() + + err := wait.Predicate(func() bool { + return !transactionInWallet(node, txID) + }, defaultTimeout) + require.NoError( + t, err, fmt.Sprintf("transaction %v found in wallet", txID), + ) +} + +func assertAnchorOutputLost(t *harnessTest, node *lntest.HarnessNode, + chanPoint wire.OutPoint) { + + pendingChansRequest := &lnrpc.PendingChannelsRequest{} + err := wait.Predicate(func() bool { + resp, pErr := node.PendingChannels( + context.Background(), pendingChansRequest, + ) + if pErr != nil { + return false + } + + for _, pendingChan := range resp.PendingForceClosingChannels { + if pendingChan.Channel.ChannelPoint == chanPoint.String() { + return (pendingChan.Anchor == + lnrpc.PendingChannelsResponse_ForceClosedChannel_LOST) + } + } + + return false + }, defaultTimeout) + require.NoError(t.t, err, "anchor doesn't show as being lost") +} diff --git a/lntest/itest/lnd_channel_force_close_test.go b/lntest/itest/lnd_channel_force_close_test.go index 02bbaf823..d36c50535 100644 --- a/lntest/itest/lnd_channel_force_close_test.go +++ b/lntest/itest/lnd_channel_force_close_test.go @@ -526,7 +526,7 @@ func channelForceClosureTest(net *lntest.NetworkHarness, t *harnessTest, aliceReports[aliceAnchor.OutPoint.String()] = &lnrpc.Resolution{ ResolutionType: lnrpc.ResolutionType_ANCHOR, Outcome: lnrpc.ResolutionOutcome_CLAIMED, - SweepTxid: aliceAnchor.SweepTx, + SweepTxid: aliceAnchor.SweepTx.TxHash().String(), Outpoint: &lnrpc.OutPoint{ TxidBytes: aliceAnchor.OutPoint.Hash[:], TxidStr: aliceAnchor.OutPoint.Hash.String(), @@ -632,7 +632,7 @@ func channelForceClosureTest(net *lntest.NetworkHarness, t *harnessTest, carolReports[carolAnchor.OutPoint.String()] = &lnrpc.Resolution{ ResolutionType: lnrpc.ResolutionType_ANCHOR, Outcome: lnrpc.ResolutionOutcome_CLAIMED, - SweepTxid: carolAnchor.SweepTx, + SweepTxid: carolAnchor.SweepTx.TxHash().String(), AmountSat: anchorSize, Outpoint: &lnrpc.OutPoint{ TxidBytes: carolAnchor.OutPoint.Hash[:], @@ -770,7 +770,7 @@ func channelForceClosureTest(net *lntest.NetworkHarness, t *harnessTest, OutputIndex: carolCommit.OutPoint.Index, }, AmountSat: uint64(pushAmt), - SweepTxid: carolCommit.SweepTx, + SweepTxid: carolCommit.SweepTx.TxHash().String(), } // Check that we can find the commitment sweep in our set of known @@ -1337,7 +1337,7 @@ func padCLTV(cltv uint32) uint32 { type sweptOutput struct { OutPoint wire.OutPoint - SweepTx string + SweepTx *wire.MsgTx } // findCommitAndAnchor looks for a commitment sweep and anchor sweep in the @@ -1364,7 +1364,7 @@ func findCommitAndAnchor(t *harnessTest, net *lntest.NetworkHarness, if len(inputs) == 1 { commitSweep = &sweptOutput{ OutPoint: inputs[0].PreviousOutPoint, - SweepTx: txHash.String(), + SweepTx: tx, } } else { // Since we have more than one input, we run through @@ -1375,7 +1375,7 @@ func findCommitAndAnchor(t *harnessTest, net *lntest.NetworkHarness, if outpointStr == closeTx { anchorSweep = &sweptOutput{ OutPoint: txin.PreviousOutPoint, - SweepTx: txHash.String(), + SweepTx: tx, } } } diff --git a/lntest/itest/lnd_onchain_test.go b/lntest/itest/lnd_onchain_test.go index d05cc0d91..aa05272fd 100644 --- a/lntest/itest/lnd_onchain_test.go +++ b/lntest/itest/lnd_onchain_test.go @@ -5,9 +5,12 @@ import ( "context" "fmt" "strings" + "time" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntest" @@ -379,3 +382,239 @@ func testAnchorReservedValue(net *lntest.NetworkHarness, t *harnessTest) { t.Fatalf("expected 1 output instead have %v", len(sweepTx.TxOut)) } } + +// genAnchorSweep generates a "3rd party" anchor sweeping from an existing one. +// In practice, we just re-use the existing witness, and track on our own +// output producing a 1-in-1-out transaction. +func genAnchorSweep(t *harnessTest, net *lntest.NetworkHarness, + aliceAnchor *sweptOutput, anchorCsv uint32) *btcutil.Tx { + + // At this point, we have the transaction that Alice used to try to + // sweep her anchor. As this is actually just something anyone can + // spend, just need to find the input spending the anchor output, then + // we can swap the output address. + aliceAnchorTxIn := func() wire.TxIn { + sweepCopy := aliceAnchor.SweepTx.Copy() + for _, txIn := range sweepCopy.TxIn { + if txIn.PreviousOutPoint == aliceAnchor.OutPoint { + return *txIn + } + } + + t.Fatalf("anchor op not found") + return wire.TxIn{} + }() + + // We'll set the signature on the input to nil, and then set the + // sequence to 16 (the anchor CSV period). + aliceAnchorTxIn.Witness[0] = nil + aliceAnchorTxIn.Sequence = anchorCsv + + minerAddr, err := net.Miner.NewAddress() + if err != nil { + t.Fatalf("unable to get miner addr: %v", err) + } + addrScript, err := txscript.PayToAddrScript(minerAddr) + if err != nil { + t.Fatalf("unable to gen addr script: %v", err) + } + + // Now that we have the txIn, we can just make a new transaction that + // uses a different script for the output. + tx := wire.NewMsgTx(2) + tx.AddTxIn(&aliceAnchorTxIn) + tx.AddTxOut(&wire.TxOut{ + PkScript: addrScript, + Value: anchorSize - 1, + }) + + return btcutil.NewTx(tx) +} + +// testAnchorThirdPartySpend tests that if we force close a channel, but then +// don't sweep the anchor in time and a 3rd party spends it, that we remove any +// transactions that are a descendent of that sweep. +func testAnchorThirdPartySpend(net *lntest.NetworkHarness, t *harnessTest) { + // First, we'll create two new nodes that both default to anchor + // channels. + // + // NOTE: The itests differ here as anchors is default off vs the normal + // lnd binary. + args := nodeArgsForCommitType(lnrpc.CommitmentType_ANCHORS) + alice := net.NewNode(t.t, "Alice", args) + defer shutdownAndAssert(net, t, alice) + + bob := net.NewNode(t.t, "Bob", args) + defer shutdownAndAssert(net, t, bob) + + ctxb := context.Background() + net.ConnectNodes(t.t, alice, bob) + + // We'll fund our Alice with coins, as she'll be opening the channel. + // We'll fund her with *just* enough coins to open the channel. + const ( + firstChanSize = 1_000_000 + anchorFeeBuffer = 500_000 + ) + net.SendCoins(t.t, firstChanSize, alice) + + // We'll give Alice another spare UTXO as well so she can use it to + // help sweep all coins. + net.SendCoins(t.t, anchorFeeBuffer, alice) + + // Open the channel between the two nodes and wait for it to confirm + // fully. + aliceChanPoint1 := openChannelAndAssert( + t, net, alice, bob, lntest.OpenChannelParams{ + Amt: firstChanSize, + }, + ) + + // With the channel open, we'll actually immediately force close it. We + // don't care about network announcements here since there's no routing + // in this test. + _, _, err := net.CloseChannel(alice, aliceChanPoint1, true) + if err != nil { + t.Fatalf("unable to execute force channel closure: %v", err) + } + + // Now that the channel has been force closed, it should show up in the + // PendingChannels RPC under the waiting close section. + pendingChansRequest := &lnrpc.PendingChannelsRequest{} + ctxt, _ := context.WithTimeout(ctxb, defaultTimeout) + pendingChanResp, err := alice.PendingChannels(ctxt, pendingChansRequest) + if err != nil { + t.Fatalf("unable to query for pending channels: %v", err) + } + err = checkNumWaitingCloseChannels(pendingChanResp, 1) + if err != nil { + t.Fatalf(err.Error()) + } + + // Get the normal channel outpoint so we can track it in the set of + // channels that are waiting to be closed. + fundingTxID, err := lnrpc.GetChanPointFundingTxid(aliceChanPoint1) + if err != nil { + t.Fatalf("unable to get txid: %v", err) + } + chanPoint := wire.OutPoint{ + Hash: *fundingTxID, + Index: aliceChanPoint1.OutputIndex, + } + waitingClose, err := findWaitingCloseChannel(pendingChanResp, &chanPoint) + if err != nil { + t.Fatalf(err.Error()) + } + + // At this point, the channel is waiting close, and we have both the + // commitment transaction and anchor sweep in the mempool. + const expectedTxns = 2 + sweepTxns, err := getNTxsFromMempool( + net.Miner.Client, expectedTxns, minerMempoolTimeout, + ) + require.NoError(t.t, err, "no sweep txns in miner mempool") + aliceCloseTx := waitingClose.Commitments.LocalTxid + _, aliceAnchor := findCommitAndAnchor(t, net, sweepTxns, aliceCloseTx) + + // We'll now mine _only_ the commitment force close transaction, as we + // want the anchor sweep to stay unconfirmed. + var emptyTime time.Time + forceCloseTxID, _ := chainhash.NewHashFromStr(aliceCloseTx) + commitTxn, err := net.Miner.Client.GetRawTransaction( + forceCloseTxID, + ) + if err != nil { + t.Fatalf("unable to get transaction: %v", err) + } + _, err = net.Miner.GenerateAndSubmitBlock( + []*btcutil.Tx{commitTxn}, -1, emptyTime, + ) + if err != nil { + t.Fatalf("unable to generate block: %v", err) + } + + // With the anchor output located, and the main commitment mined we'll + // instruct the wallet to send all coins in the wallet to a new address + // (to the miner), including unconfirmed change. + minerAddr, err := net.Miner.NewAddress() + if err != nil { + t.Fatalf("unable to create new miner addr: %v", err) + } + sweepReq := &lnrpc.SendCoinsRequest{ + Addr: minerAddr.String(), + SendAll: true, + MinConfs: 0, + SpendUnconfirmed: true, + } + ctxt, _ = context.WithTimeout(ctxb, defaultTimeout) + sweepAllResp, err := alice.SendCoins(ctxt, sweepReq) + if err != nil { + t.Fatalf("unable to sweep coins: %v", err) + } + + // Both the original anchor sweep transaction, as well as the + // transaction we created to sweep all the coins from Alice's wallet + // should be found in her transaction store. + sweepAllTxID, _ := chainhash.NewHashFromStr(sweepAllResp.Txid) + assertTransactionInWallet(t.t, alice, aliceAnchor.SweepTx.TxHash()) + assertTransactionInWallet(t.t, alice, *sweepAllTxID) + + // Next, we'll shutdown Alice, and allow 16 blocks to pass so that the + // anchor output can be swept by anyone. Rather than use the normal API + // call, we'll generate a series of _empty_ blocks here. + aliceRestart, err := net.SuspendNode(alice) + if err != nil { + t.Fatalf("unable to shutdown alice: %v", err) + } + const anchorCsv = 16 + for i := 0; i < anchorCsv; i++ { + _, err := net.Miner.GenerateAndSubmitBlock(nil, -1, emptyTime) + if err != nil { + t.Fatalf("unable to generate block: %v", err) + } + } + + // Before we sweep the anchor, we'll restart Alice. + if err := aliceRestart(); err != nil { + t.Fatalf("unable to restart alice: %v", err) + } + + // Now that the channel has been closed, and Alice has an unconfirmed + // transaction spending the output produced by her anchor sweep, we'll + // mine a transaction that double spends the output. + thirdPartyAnchorSweep := genAnchorSweep(t, net, aliceAnchor, anchorCsv) + _, err = net.Miner.GenerateAndSubmitBlock( + []*btcutil.Tx{thirdPartyAnchorSweep}, -1, emptyTime, + ) + if err != nil { + t.Fatalf("unable to generate block: %v", err) + } + + // At this point, we should no longer find Alice's transaction that + // tried to sweep the anchor in her wallet. + assertTransactionNotInWallet(t.t, alice, aliceAnchor.SweepTx.TxHash()) + + // In addition, the transaction she sent to sweep all her coins to the + // miner also should no longer be found. + assertTransactionNotInWallet(t.t, alice, *sweepAllTxID) + + // The anchor should now show as being "lost", while the force close + // response is still present. + assertAnchorOutputLost(t, alice, chanPoint) + + // At this point Alice's CSV output should already be fully spent and + // the channel marked as being resolved. We mine a block first, as so + // far we've been generating custom blocks this whole time.. + commitSweepOp := wire.OutPoint{ + Hash: *forceCloseTxID, + Index: 1, + } + assertSpendingTxInMempool( + t, net.Miner.Client, minerMempoolTimeout, commitSweepOp, + ) + _, err = net.Miner.Client.Generate(1) + if err != nil { + t.Fatalf("unable to generate block: %v", err) + } + assertNumPendingChannels(t, alice, 0, 0) +} diff --git a/lntest/itest/lnd_test_list_on_test.go b/lntest/itest/lnd_test_list_on_test.go index b53e751e8..24b6cd0d7 100644 --- a/lntest/itest/lnd_test_list_on_test.go +++ b/lntest/itest/lnd_test_list_on_test.go @@ -379,4 +379,8 @@ var allTestCases = []*testCase{ name: "remote signer", test: testRemoteSigner, }, + { + name: "3rd party anchor spend", + test: testAnchorThirdPartySpend, + }, }