From 72477f348fca5d0e869a295d73ff167ea904aebc Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sat, 6 Jul 2024 12:58:53 -0300 Subject: [PATCH] itest: test scbforceclose scenarios - Make sure channel.backup file is updated upon LND shutdown. - Make sure channel.backup file has sufficient information combined with seed and password to produce a force close tx and sweep the funds. - Make sure that if the latest channel version is used in channel.backup file in scbforceclose recovery method, another peer does not penalize. - Control for the previous test case: make sure that if an outdated channel state in channel.backup is used, another node can penalize. To decrypt channel.backup and to sign force close tx, KeyRing and Signer objects are needed. I copy-pasted parts of chantools/lnd/lnd/hdkeychain.go and chantools/lnd/signer.go to itest/ directory, making types private so they are not used accidentally by other code. --- itest/hdkeychain.go | 88 ++++ itest/hdsigner.go | 176 ++++++++ itest/lnd_channel_backup_test.go | 695 ++++++++++++++++++++++++++++++- 3 files changed, 945 insertions(+), 14 deletions(-) create mode 100644 itest/hdkeychain.go create mode 100644 itest/hdsigner.go diff --git a/itest/hdkeychain.go b/itest/hdkeychain.go new file mode 100644 index 0000000000..346ea80809 --- /dev/null +++ b/itest/hdkeychain.go @@ -0,0 +1,88 @@ +package itest + +import ( + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightningnetwork/lnd/keychain" +) + +func deriveChildren(key *hdkeychain.ExtendedKey, path []uint32) ( + *hdkeychain.ExtendedKey, error) { + + var currentKey = key + for idx, pathPart := range path { + derivedKey, err := currentKey.DeriveNonStandard(pathPart) + if err != nil { + return nil, err + } + + // There's this special case in lnd's wallet (btcwallet) where + // the coin type and account keys are always serialized as a + // string and encrypted, which actually fixes the key padding + // issue that makes the difference between DeriveNonStandard and + // Derive. To replicate lnd's behavior exactly, we need to + // serialize and de-serialize the extended key at the coin type + // and account level (depth = 2 or depth = 3). This does not + // apply to the default account (id = 0) because that is always + // derived directly. + depth := derivedKey.Depth() + keyID := pathPart - hdkeychain.HardenedKeyStart + nextID := uint32(0) + if depth == 2 && len(path) > 2 { + nextID = path[idx+1] - hdkeychain.HardenedKeyStart + } + if (depth == 2 && nextID != 0) || (depth == 3 && keyID != 0) { + currentKey, err = hdkeychain.NewKeyFromString( + derivedKey.String(), + ) + if err != nil { + return nil, err + } + } else { + currentKey = derivedKey + } + } + + return currentKey, nil +} + +type hdKeyRing struct { + ExtendedKey *hdkeychain.ExtendedKey + ChainParams *chaincfg.Params +} + +func (r *hdKeyRing) DeriveNextKey(_ keychain.KeyFamily) ( + keychain.KeyDescriptor, error) { + + return keychain.KeyDescriptor{}, nil +} + +func (r *hdKeyRing) DeriveKey(keyLoc keychain.KeyLocator) ( + keychain.KeyDescriptor, error) { + + var empty = keychain.KeyDescriptor{} + const HardenedKeyStart = uint32(hdkeychain.HardenedKeyStart) + derivedKey, err := deriveChildren(r.ExtendedKey, []uint32{ + HardenedKeyStart + uint32(keychain.BIP0043Purpose), + HardenedKeyStart + r.ChainParams.HDCoinType, + HardenedKeyStart + uint32(keyLoc.Family), + 0, + keyLoc.Index, + }) + if err != nil { + return empty, err + } + + derivedPubKey, err := derivedKey.ECPubKey() + if err != nil { + return empty, err + } + + return keychain.KeyDescriptor{ + KeyLocator: keychain.KeyLocator{ + Family: keyLoc.Family, + Index: keyLoc.Index, + }, + PubKey: derivedPubKey, + }, nil +} diff --git a/itest/hdsigner.go b/itest/hdsigner.go new file mode 100644 index 0000000000..01ce699f76 --- /dev/null +++ b/itest/hdsigner.go @@ -0,0 +1,176 @@ +package itest + +import ( + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" +) + +type hdSigner struct { + *input.MusigSessionManager + + ExtendedKey *hdkeychain.ExtendedKey + ChainParams *chaincfg.Params +} + +func (s *hdSigner) SignOutputRaw(tx *wire.MsgTx, + signDesc *input.SignDescriptor) (input.Signature, error) { + + // First attempt to fetch the private key which corresponds to the + // specified public key. + privKey, err := s.FetchPrivateKey(&signDesc.KeyDesc) + if err != nil { + return nil, err + } + + return s.signOutputRawWithPrivateKey(tx, signDesc, privKey) +} + +func (s *hdSigner) signOutputRawWithPrivateKey(tx *wire.MsgTx, + signDesc *input.SignDescriptor, + privKey *secp256k1.PrivateKey) (input.Signature, error) { + + witnessScript := signDesc.WitnessScript + privKey = maybeTweakPrivKey(signDesc, privKey) + + sigHashes := txscript.NewTxSigHashes(tx, signDesc.PrevOutputFetcher) + if txscript.IsPayToTaproot(signDesc.Output.PkScript) { + // Are we spending a script path or the key path? The API is + // slightly different, so we need to account for that to get the + // raw signature. + var ( + rawSig []byte + err error + ) + + switch signDesc.SignMethod { + case input.TaprootKeySpendBIP0086SignMethod, + input.TaprootKeySpendSignMethod: + + // This function tweaks the private key using the tap + // root key supplied as the tweak. + rawSig, err = txscript.RawTxInTaprootSignature( + tx, sigHashes, signDesc.InputIndex, + signDesc.Output.Value, signDesc.Output.PkScript, + signDesc.TapTweak, signDesc.HashType, + privKey, + ) + if err != nil { + return nil, err + } + + case input.TaprootScriptSpendSignMethod: + leaf := txscript.TapLeaf{ + LeafVersion: txscript.BaseLeafVersion, + Script: witnessScript, + } + rawSig, err = txscript.RawTxInTapscriptSignature( + tx, sigHashes, signDesc.InputIndex, + signDesc.Output.Value, signDesc.Output.PkScript, + leaf, signDesc.HashType, privKey, + ) + if err != nil { + return nil, err + } + + default: + return nil, fmt.Errorf("unknown sign method: %v", + signDesc.SignMethod) + } + + // The signature returned above might have a sighash flag + // attached if a non-default type was used. We'll slice this + // off if it exists to ensure we can properly parse the raw + // signature. + sig, err := schnorr.ParseSignature( + rawSig[:schnorr.SignatureSize], + ) + if err != nil { + return nil, err + } + + return sig, nil + } + + amt := signDesc.Output.Value + sig, err := txscript.RawTxInWitnessSignature( + tx, sigHashes, signDesc.InputIndex, amt, + witnessScript, signDesc.HashType, privKey, + ) + if err != nil { + return nil, err + } + + // Chop off the sighash flag at the end of the signature. + return ecdsa.ParseDERSignature(sig[:len(sig)-1]) +} + +func (s *hdSigner) ComputeInputScript(_ *wire.MsgTx, _ *input.SignDescriptor) ( + *input.Script, error) { + + return nil, errors.New("unimplemented") +} + +func (s *hdSigner) FetchPrivateKey(descriptor *keychain.KeyDescriptor) ( + *btcec.PrivateKey, error) { + + const HardenedKeyStart = uint32(hdkeychain.HardenedKeyStart) + key, err := deriveChildren(s.ExtendedKey, []uint32{ + HardenedKeyStart + uint32(keychain.BIP0043Purpose), + HardenedKeyStart + s.ChainParams.HDCoinType, + HardenedKeyStart + uint32(descriptor.Family), + 0, + descriptor.Index, + }) + if err != nil { + return nil, err + } + + return key.ECPrivKey() +} + +// maybeTweakPrivKey examines the single tweak parameters on the passed sign +// descriptor and may perform a mapping on the passed private key in order to +// utilize the tweaks, if populated. +func maybeTweakPrivKey(signDesc *input.SignDescriptor, + privKey *btcec.PrivateKey) *btcec.PrivateKey { + + if signDesc.SingleTweak != nil { + return input.TweakPrivKey(privKey, signDesc.SingleTweak) + } + + return privKey +} + +// ECDH performs a scalar multiplication (ECDH-like operation) between +// the target key descriptor and remote public key. The output +// returned will be the sha256 of the resulting shared point serialized +// in compressed format. If k is our private key, and P is the public +// key, we perform the following operation: +// +// sx := k*P +// s := sha256(sx.SerializeCompressed()) +// +// NOTE: This is part of the keychain.ECDHRing interface. +func (s *hdSigner) ECDH(keyDesc keychain.KeyDescriptor, + pubKey *btcec.PublicKey) ([32]byte, error) { + + // First, derive the private key. + privKey, err := s.FetchPrivateKey(&keyDesc) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to derive the private "+ + "key: %w", err) + } + + return (&keychain.PrivKeyECDH{PrivKey: privKey}).ECDH(pubKey) +} diff --git a/itest/lnd_channel_backup_test.go b/itest/lnd_channel_backup_test.go index 6f2877ba6c..d9c8bb55b2 100644 --- a/itest/lnd_channel_backup_test.go +++ b/itest/lnd_channel_backup_test.go @@ -1,6 +1,7 @@ package itest import ( + "bytes" "context" "fmt" "os" @@ -12,9 +13,12 @@ import ( "time" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/aezeed" "github.com/lightningnetwork/lnd/chanbackup" "github.com/lightningnetwork/lnd/funding" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntest" @@ -50,6 +54,10 @@ type chanRestoreScenario struct { password []byte mnemonic []string params lntest.OpenChannelParams + + forceCloseTx *wire.MsgTx + startCarolAfterMiningCloseTx bool + useOutdatedScbToClose bool } // newChanRestoreScenario creates a new scenario that has two nodes, Carol and @@ -161,6 +169,11 @@ func (c *chanRestoreScenario) restoreDave(ht *lntest.HarnessTest, // 4. validate pending channel state and check we cannot force close it. // 5. validate Carol's UTXOs. // 6. assert DLP is executed. +// +// If forceCloseTx is provided, Carol is not restarted and that transaction is +// broadcasted instead. This is needed to test scbforceclose. +// If useOutdatedScbToClose, expect Carol to penalize Dave for publishing an +// outdated force close transaction. func (c *chanRestoreScenario) testScenario(ht *lntest.HarnessTest, restoredNodeFunc nodeRestorer) { @@ -214,24 +227,71 @@ func (c *chanRestoreScenario) testScenario(ht *lntest.HarnessTest, // be cpfp'ed in case of anchor commitments. ht.SetFeeEstimate(30000) - // Now that we have ensured that the channels restored by the backup - // are in the correct state even without the remote peer telling us so, - // let's start up Carol again. - require.NoError(ht, restartCarol(), "restart carol failed") + if c.forceCloseTx == nil { + // DLP case. - if lntest.CommitTypeHasAnchors(c.params.CommitmentType) { - ht.AssertNumUTXOs(carol, 2) - } else { - ht.AssertNumUTXOs(carol, 1) + // Now that we have ensured that the channels restored by the + // backup are in the correct state even without the remote peer + // telling us so, let's start up Carol again. + require.NoError(ht, restartCarol(), "restart carol failed") + + if lntest.CommitTypeHasAnchors(c.params.CommitmentType) { + ht.AssertNumUTXOs(carol, 2) + } else { + ht.AssertNumUTXOs(carol, 1) + } + + // Now we'll assert that both sides properly execute the DLP + // protocol. We grab their balances now to ensure that they're + // made whole at the end of the protocol. + assertDLPExecuted( + ht, carol, carolStartingBalance, dave, + daveStartingBalance, c.params.CommitmentType, + ) + + return } - // Now we'll assert that both sides properly execute the DLP protocol. - // We grab their balances now to ensure that they're made whole at the - // end of the protocol. - assertDLPExecuted( - ht, carol, carolStartingBalance, dave, - daveStartingBalance, c.params.CommitmentType, + // Here we test 3 non-DLP cases, using scbforceclose: + // 1. Without Carol. + // 2. Carol wakes up after force close tx is broadcasted. + // 3. Outdated force close transaction is broadcasted, Carol + // wakes up and penalized Dave. + + // Broadcast force close transaction coming from SCB. + const allowHighFees = true + _, err = ht.Miner.Client.SendRawTransaction( + c.forceCloseTx, allowHighFees, ) + require.NoError(ht, err) + + // Now run the checks depending on a tested case. + switch { + // Simple case of scbforceclose: Carol is always offline. + case !c.startCarolAfterMiningCloseTx: + assertFundsRecovered( + ht, dave, daveStartingBalance, c.params.CommitmentType, + ) + + // Complex case of scbforceclose: start Carol after mining force close + // tx and make sure the funds are still recovered (Carol does not grab + // them with a penalty transaction). + case !c.useOutdatedScbToClose: + assertFundsRecoveredStartCarol( + ht, carol, carolStartingBalance, dave, + daveStartingBalance, c.params.CommitmentType, + restartCarol, + ) + + // Control for the previous case: use an outdated SCB and provoke + // Carol's penalty. Control case for the previous case to ensure Carol + // can do it, when needed. + default: + assertPenalty( + ht, carol, carolStartingBalance, dave, + daveStartingBalance, restartCarol, + ) + } } // testChannelBackupRestore tests that we're able to recover from, and initiate @@ -600,6 +660,63 @@ func testChannelBackupRestoreCommitTypes(ht *lntest.HarnessTest) { if !success { break } + + // Test scbforceclose scenario: generate force close tx + // from SCB, Carol is offline forever. + success = ht.Run( + "scbforceclose_no_carol/"+tc.name, + func(t *testing.T) { + h := ht.Subtest(t) + + runScbForceCloseCommitTypes( + h, tc.ct, tc.zeroConf, false, false, + ) + }, + ) + if !success { + break + } + + // Test scbforceclose scenario when Carol wakes up after our + // force close tx is mined. Make sure she can not make a penalty + // transaction. + success = ht.Run( + "scbforceclose_carol_wakes_up/"+tc.name, + func(t *testing.T) { + h := ht.Subtest(t) + + startCarolAfterMiningCloseTx := true + runScbForceCloseCommitTypes( + h, tc.ct, tc.zeroConf, + startCarolAfterMiningCloseTx, false, + ) + }, + ) + if !success { + break + } + + // Control for the previous case: make sure that Carol can + // produce a penalty transaction is needed. To do it, use an + // outdated SCB for scbforceclose and wake up Carol after mining + // the force close transaction, like in the previous case. + success = ht.Run( + "scbforceclose_carol_penalizes/"+tc.name, + func(t *testing.T) { + h := ht.Subtest(t) + + startCarolAfterMiningCloseTx := true + useOutdatedScbToClose := true + runScbForceCloseCommitTypes( + h, tc.ct, tc.zeroConf, + startCarolAfterMiningCloseTx, + useOutdatedScbToClose, + ) + }, + ) + if !success { + break + } } } @@ -665,6 +782,170 @@ func runChanRestoreScenarioCommitTypes(ht *lntest.HarnessTest, crs.testScenario(ht, restoredNodeFunc) } +// runScbForceCloseCommitTypes tests that SCB file is updated on LND +// shutdown, shuts down another mode, restores the node using SCB, signs and +// broadcasts the force close transaction produced from SCB, continues recovery +// without another node until funds are recovered. +func runScbForceCloseCommitTypes(ht *lntest.HarnessTest, + ct lnrpc.CommitmentType, zeroConf, startCarolAfterMiningCloseTx, + useOutdatedScbToClose bool) { + + // Create a new restore scenario. + crs := newChanRestoreScenario(ht, ct, zeroConf) + crs.startCarolAfterMiningCloseTx = startCarolAfterMiningCloseTx + crs.useOutdatedScbToClose = useOutdatedScbToClose + carol, dave := crs.carol, crs.dave + + // If we are testing zero-conf channels, setup a ChannelAcceptor for + // the fundee. + var cancelAcceptor context.CancelFunc + if zeroConf { + // Setup a ChannelAcceptor. + acceptStream, cancel := carol.RPC.ChannelAcceptor() + cancelAcceptor = cancel + go acceptChannel(ht.T, true, acceptStream) + } + + var fundingShim *lnrpc.FundingShim + if ct == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE { + _, minerHeight := ht.Miner.GetBestBlock() + thawHeight := uint32(minerHeight + thawHeightDelta) + + fundingShim, _ = deriveFundingShim( + ht, dave, carol, crs.params.Amt, thawHeight, true, ct, + ) + crs.params.FundingShim = fundingShim + } + ht.OpenChannel(dave, carol, crs.params) + + // Remove the ChannelAcceptor. + if zeroConf { + cancelAcceptor() + } + + // Cache file path to channel.backup file. + backupFilePath := dave.Cfg.ChanBackupPath() + + // If this was a zero conf taproot channel, then since it's private, + // we'll need to mine an extra block (framework won't mine extra blocks + // otherwise). + if ct == lnrpc.CommitmentType_SIMPLE_TAPROOT && zeroConf { + ht.MineBlocksAndAssertNumTxes(1, 1) + } + + // Create extended key to work with SCB internals. + var seedMnemonic aezeed.Mnemonic + copy(seedMnemonic[:], crs.mnemonic) + cipherSeed, err := seedMnemonic.ToCipherSeed(crs.password) + require.NoError(ht, err) + extendedRootKey, err := hdkeychain.NewMaster( + cipherSeed.Entropy[:], dave.Cfg.NetParams, + ) + require.NoError(ht, err) + + // Create keyRing. + keyRing := &hdKeyRing{ + ExtendedKey: extendedRootKey, + ChainParams: dave.Cfg.NetParams, + } + + // Create signer. + signer := &hdSigner{ + ExtendedKey: extendedRootKey, + ChainParams: dave.Cfg.NetParams, + } + signer.MusigSessionManager = input.NewMusigSessionManager( + signer.FetchPrivateKey, + ) + + // readBackup reads, decrypts and parses backup file. It also returns + // raw bytes of channel.backup file. + readBackup := func() (chanbackup.Single, []byte) { + // Read the entire Multi backup stored within this node's + // channels.backup file. + multi, err := os.ReadFile(backupFilePath) + require.NoError(ht, err) + + // Decrypt and parse the file. + var m chanbackup.Multi + err = m.UnpackFromReader(bytes.NewReader(multi), keyRing) + require.NoError(ht, err) + require.Equal(ht, 1, len(m.StaticBackups)) + single := m.StaticBackups[0] + require.NotNil(ht, single.CloseTxInputs) + require.NotNil(ht, single.CloseTxInputs.CommitTx) + require.NotEmpty(ht, single.CloseTxInputs.CommitSig) + + return single, multi + } + + // outputsAsString formats values of transaction outputs as a comma + // separated string. + outputsAsString := func(tx *wire.MsgTx) string { + outputs := make([]string, 0, len(tx.TxOut)) + for _, txOut := range tx.TxOut { + value := txOut.Value + outputs = append(outputs, fmt.Sprintf("%d", value)) + } + + return strings.Join(outputs, ",") + } + + // Make sure the nodes are connected for the payment to pass. + ht.EnsureConnected(carol, dave) + + // Send some sats from Dave to Carol. The channel state will change, + // but not in the backup. + payReqs, _, _ := ht.CreatePayReqs(carol, 100_000, 1) + ht.CompletePaymentRequests(dave, payReqs) + + // Snapshot channel backup state before Dave's shutdown. + backupBeforeShutdown, _ := readBackup() + balancesBeforeShutdown := outputsAsString( + backupBeforeShutdown.CloseTxInputs.CommitTx, + ) + + // Now shutdown Dave. It should update the backup file upon shutdown. + // Use method SuspendNode() instead of Shutdown() to prevent node's + // directory from being cleaned up, so we can read channel.backup. + ht.SuspendNode(dave) + + // Snapshot channel backup state after Dave's shutdown. + backupAfterShutdown, multiAfterShutdown := readBackup() + balancesAfterShutdown := outputsAsString( + backupAfterShutdown.CloseTxInputs.CommitTx, + ) + + // Make sure balances in the backup changed during shutdown. + require.NotEqual(ht, balancesBeforeShutdown, balancesAfterShutdown) + + // Generate force close transaction from SCB to use it to restore. + var scbForCloseTx chanbackup.Single + if useOutdatedScbToClose { + // Intentionally use an outdated SCB to test Carol's penalty. + scbForCloseTx = backupBeforeShutdown + } else { + // Use the latest SCB to produce force close tx. + scbForCloseTx = backupAfterShutdown + } + crs.forceCloseTx, err = chanbackup.SignCloseTx( + scbForCloseTx, keyRing, signer, signer, + ) + require.NoError(ht, err) + + // Start Dave again, because testScenario expects it to be up. + ht.RestartNode(dave) + + // Now that we have Dave's backup file, we'll create a new nodeRestorer + // that we'll restore using the on-disk channels.backup. + restoredNodeFunc := chanRestoreViaRPC( + ht, crs.password, crs.mnemonic, multiAfterShutdown, dave, + ) + + // Test the scenario. + crs.testScenario(ht, restoredNodeFunc) +} + // testChannelBackupRestoreLegacy checks a channel with the legacy revocation // producer format and makes sure old SCBs can still be recovered. func testChannelBackupRestoreLegacy(ht *lntest.HarnessTest) { @@ -1673,3 +1954,389 @@ func assertDLPExecuted(ht *lntest.HarnessTest, ht.AssertNodeNumChannels(dave, 0) ht.AssertNodeNumChannels(carol, 0) } + +// assertFundsRecovered asserts that funds are recovered after Dave running +// scbforceclose procedure. Force close transaction is created from Dave's SCB +// backup and broadcasted by the caller of this function. Carol is suspended. +func assertFundsRecovered(ht *lntest.HarnessTest, dave *node.HarnessNode, + daveStartingBalance int64, commitType lnrpc.CommitmentType) { + + ht.Helper() + + // Increase the fee estimate so that the following force close tx will + // be cpfp'ed. + ht.SetFeeEstimate(30000) + + // We detect the manually broadcasts Dave's tx. + ht.Miner.AssertNumTxsInMempool(1) + + // Dave should also consider the channel "waiting close", as he noticed + // the channel was out of sync, and is now waiting for a force close to + // hit the chain. + ht.AssertNumWaitingClose(dave, 1) + + // Restart Dave to make sure he is able to sweep the funds after + // shutdown. + ht.RestartNode(dave) + + // Generate a single block, which should confirm the closing tx. + ht.MineBlocksAndAssertNumTxes(1, 1) + blocksMined := uint32(1) + + // Dave should consider the channel pending force close (since he is + // waiting for his sweep to confirm). + ht.AssertNumPendingForceClose(dave, 1) + + if commitType == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE { + // Dave should sweep his anchor only, since he still has the + // lease CLTV constraint on his commitment output. + + // Dave should have an anchor sweep request. + // Note that they cannot sweep them as these anchor sweepings + // are uneconomical. + ht.AssertNumPendingSweeps(dave, 1) + + // The commit sweep resolver publishes the sweep tx at + // defaultCSV-1 and we already mined one block after the + // commitmment was published, so take that into account. + ht.MineEmptyBlocks(int(defaultCSV - blocksMined)) + + // We'll now mine the remaining blocks to prompt Dave to sweep + // his CLTV-constrained output. + resp := dave.RPC.PendingChannels() + blocksTilMaturity := + resp.PendingForceClosingChannels[0].BlocksTilMaturity + require.Positive(ht, blocksTilMaturity) + + ht.MineEmptyBlocks(int(blocksTilMaturity)) + + // Dave should have two sweep requests - one for his commit + // output and the other for his anchor. + ht.AssertNumPendingSweeps(dave, 2) + + // Mine a block to trigger the sweep. + ht.MineEmptyBlocks(1) + ht.MineBlocksAndAssertNumTxes(1, 1) + + // Now Dave should consider the channel fully closed. + ht.AssertNumPendingForceClose(dave, 0) + } else { + // The funds are timelocked, so Dave has one sweep. + ht.AssertNumPendingSweeps(dave, 1) + + // Mine one block to trigger the sweeper to sweep. + ht.MineEmptyBlocks(1) + blocksMined++ + + // After Dave's output matures, he should also reclaim his + // funds. + // + // The commit sweep resolver publishes the sweep tx at + // defaultCSV-1 and we already have blocks mined after the + // commitmment was published, so take that into account. + ht.MineEmptyBlocks(int(defaultCSV - blocksMined)) + + // Mine one block to trigger the sweeper to sweep. + ht.MineEmptyBlocks(1) + + // Dave's sweeper swept the outputs of force close tx. + if lntest.CommitTypeHasAnchors(commitType) { + ht.AssertNumPendingSweeps(dave, 2) + } else { + ht.AssertNumPendingSweeps(dave, 1) + } + + // Assert the sweeping tx is mined. + ht.MineBlocksAndAssertNumTxes(1, 1) + + // Funds are now in Dave's wallet. + ht.AssertNumPendingForceClose(dave, 0) + } + + // We query Dave's balance to make sure it increased after the channel + // closed. This checks that he was able to sweep the funds he had in + // the channel. + daveBalResp := dave.RPC.WalletBalance() + daveBalance := daveBalResp.ConfirmedBalance + require.Greater(ht, daveBalance, daveStartingBalance, + "balance not increased") + + ht.AssertNodeNumChannels(dave, 0) +} + +// assertFundsRecoveredStartCarol asserts that funds are recovered after Dave +// running scbforceclose procedure. Force close transaction is created from +// Dave's SCB backup and broadcasted by the caller of this function. Carol is +// started after the force close transaction is mined. +func assertFundsRecoveredStartCarol(ht *lntest.HarnessTest, + carol *node.HarnessNode, carolStartingBalance int64, + dave *node.HarnessNode, daveStartingBalance int64, + commitType lnrpc.CommitmentType, startCarol func() error) { + + ht.Helper() + + // Increase the fee estimate so that the following force close tx will + // be cpfp'ed. + ht.SetFeeEstimate(30000) + + // Upon reconnection, the nodes should detect that Dave is out of sync. + // Carol should force close the channel using her latest commitment. + // For scbforceclose case: we detect the manually broadcasts Dave's tx. + ht.Miner.AssertNumTxsInMempool(1) + + // Dave should also consider the channel "waiting close", as he noticed + // the channel was out of sync, and is now waiting for a force close to + // hit the chain. + ht.AssertNumWaitingClose(dave, 1) + + // Restart Dave to make sure he is able to sweep the funds after + // shutdown. + ht.RestartNode(dave) + + // Generate a single block, which should confirm the closing tx. + ht.MineBlocksAndAssertNumTxes(1, 1) + blocksMined := uint32(1) + + // Start Carol after mining closing tx if requested. It is done here to + // ensure that she does not publish her version of force close tx. + require.NoError(ht, startCarol()) + ht.EnsureConnected(carol, dave) + + // Dave should consider the channel pending force close (since he is + // waiting for his sweep to confirm). + ht.AssertNumPendingForceClose(dave, 1) + + // Carol is considering it "pending force close", as we must + // wait before she can sweep her outputs. + ht.AssertNumPendingForceClose(carol, 1) + + if commitType == lnrpc.CommitmentType_SCRIPT_ENFORCED_LEASE { + // Dave should sweep his anchor only, since he still has the + // lease CLTV constraint on his commitment output. We'd also + // see Carol's anchor sweep here. + + // Both Dave and Carol should have an anchor sweep request. + // Note that they cannot sweep them as these anchor sweepings + // are uneconomical. + ht.AssertNumPendingSweeps(dave, 1) + + // Carol has two sweeps. + ht.AssertNumPendingSweeps(carol, 2) + + // After Carol's output matures, she should also reclaim her + // funds. + // + // The commit sweep resolver publishes the sweep tx at + // defaultCSV-1 and we already mined one block after the + // commitmment was published, so take that into account. + ht.MineEmptyBlocks(int(defaultCSV - blocksMined)) + + // Carol should have two sweep requests - one for her commit + // output and the other for her anchor. + ht.AssertNumPendingSweeps(carol, 2) + + // Mine a block to trigger the sweep in Carol. + ht.MineEmptyBlocks(1) + ht.MineBlocksAndAssertNumTxes(1, 1) + + // Now the channel should be fully closed also from + // Carol's POV. + ht.AssertNumPendingForceClose(carol, 0) + + // We'll now mine the remaining blocks to prompt Dave to sweep + // his CLTV-constrained output. + resp := dave.RPC.PendingChannels() + blocksTilMaturity := + resp.PendingForceClosingChannels[0].BlocksTilMaturity + require.Positive(ht, blocksTilMaturity) + + ht.MineEmptyBlocks(int(blocksTilMaturity)) + + // Dave should have two sweep requests - one for his commit + // output and the other for his anchor. + ht.AssertNumPendingSweeps(dave, 2) + + // Mine a block to trigger the sweep. + ht.MineEmptyBlocks(1) + ht.MineBlocksAndAssertNumTxes(1, 1) + + // Now Dave should consider the channel fully closed. + ht.AssertNumPendingForceClose(dave, 0) + } else { + // Carol should sweep her funds immediately, as they are not + // timelocked. We also expect Carol and Dave sweep their anchors + // if it's an anchor channel. Dave's funds are timelocked, so + // expect just one sweep. + if lntest.CommitTypeHasAnchors(commitType) { + ht.AssertNumPendingSweeps(carol, 2) + } else { + ht.AssertNumPendingSweeps(carol, 1) + } + ht.AssertNumPendingSweeps(dave, 1) + + // Mine one block to trigger the sweeper to sweep. + ht.MineEmptyBlocks(1) + blocksMined++ + + // Expect one tx - the commitment sweep from Carol. For + // anchor channels, we expect the two anchor sweeping + // txns to be failed due they are uneconomical. + ht.MineBlocksAndAssertNumTxes(1, 1) + blocksMined++ + + // Now Carol should consider the channel fully closed. + ht.AssertNumPendingForceClose(carol, 0) + + // After Dave's output matures, he should also reclaim his + // funds. + // + // The commit sweep resolver publishes the sweep tx at + // defaultCSV-1 and we already have blocks mined after the + // commitmment was published, so take that into account. + ht.MineEmptyBlocks(int(defaultCSV - blocksMined)) + + // Mine one block to trigger the sweeper to sweep. + ht.MineEmptyBlocks(1) + + // Dave's sweeper swept the outputs of force close tx. + if lntest.CommitTypeHasAnchors(commitType) { + ht.AssertNumPendingSweeps(dave, 2) + } else { + ht.AssertNumPendingSweeps(dave, 1) + } + + // Assert the sweeping tx is mined. + ht.MineBlocksAndAssertNumTxes(1, 1) + + // Funds are now in Dave's wallet. + ht.AssertNumPendingForceClose(dave, 0) + + // Now the channel should be fully closed also from + // Carol's POV. + ht.AssertNumPendingForceClose(carol, 0) + } + + // We query Dave's balance to make sure it increased after the channel + // closed. This checks that he was able to sweep the funds he had in + // the channel. + daveBalResp := dave.RPC.WalletBalance() + daveBalance := daveBalResp.ConfirmedBalance + require.Greater(ht, daveBalance, daveStartingBalance, + "balance not increased") + + ht.AssertNodeNumChannels(dave, 0) + + // Make sure Carol got her balance back. + err := wait.NoError(func() error { + carolBalResp := carol.RPC.WalletBalance() + carolBalance := carolBalResp.ConfirmedBalance + + // With Neutrino we don't get a backend error when trying to + // publish an orphan TX (which is what the sweep for the remote + // anchor is since the remote commitment TX was not broadcast). + // That's why the wallet still sees that as unconfirmed and we + // need to count the total balance instead of the confirmed. + if ht.IsNeutrinoBackend() { + carolBalance = carolBalResp.TotalBalance + } + + if carolBalance <= carolStartingBalance { + return fmt.Errorf("expected carol to have balance "+ + "above %d, instead had %v", + carolStartingBalance, carolBalance) + } + + return nil + }, defaultTimeout) + require.NoError(ht, err, "timeout while checking carol's balance") + + ht.AssertNodeNumChannels(carol, 0) +} + +// assertPenalty asserts that Carol penalized Dave for using an outdated +// force close transaction. +func assertPenalty(ht *lntest.HarnessTest, + carol *node.HarnessNode, carolStartingBalance int64, + dave *node.HarnessNode, daveStartingBalance int64, + startCarol func() error) { + + ht.Helper() + + // Detect manually broadcasted Dave's force close tx. + ht.Miner.AssertNumTxsInMempool(1) + + // Dave should consider the channel "waiting close", as he noticed the + // channel was out of sync, and is now waiting for a force close to hit + // the chain. + ht.AssertNumWaitingClose(dave, 1) + + // Restart Dave to make sure he is able to sweep the funds after + // shutdown. + ht.RestartNode(dave) + + // Generate a single block, which should confirm the closing tx. + ht.MineBlocksAndAssertNumTxes(1, 1) + + // Start Carol after mining closing tx if requested. It is done here to + // ensure that she does not publish her version of force close tx. + require.NoError(ht, startCarol()) + ht.EnsureConnected(carol, dave) + + // Wait for Carol to publish a penalty transaction. + require.Eventually(ht, func() bool { + // To fail fast if there is more than 1 transaction, wait for + // at least 1 transaction here. If there is more than one tx, + // the MineBlocksAndAssertNumTxes call below will fail. + return len(ht.Miner.GetRawMempool()) >= 1 + }, time.Minute, time.Second) + + // Now Carol should publish a penalty transaction, collecting the whole + // channel balance. + ht.MineBlocksAndAssertNumTxes(1, 1) + + // Ensure that Carol has a breach channel. + carolClosed := carol.RPC.ClosedChannels(&lnrpc.ClosedChannelsRequest{ + Breach: true, + }) + require.Equal(ht, 1, len(carolClosed.Channels)) + require.Equal( + ht, lnrpc.ChannelCloseSummary_BREACH_CLOSE, + carolClosed.Channels[0].CloseType, + ) + + // We query Dave's balance to make sure it didn't increase after the + // channel closed. Carol collected all the funds. + daveBalResp := dave.RPC.WalletBalance() + daveBalance := daveBalResp.ConfirmedBalance + require.Equal(ht, daveStartingBalance, daveBalance) + + // Make sure Carol's balance increased. + err := wait.NoError(func() error { + carolBalResp := carol.RPC.WalletBalance() + carolBalance := carolBalResp.ConfirmedBalance + + // With Neutrino we don't get a backend error when trying to + // publish an orphan TX (which is what the sweep for the remote + // anchor is since the remote commitment TX was not broadcast). + // That's why the wallet still sees that as unconfirmed and we + // need to count the total balance instead of the confirmed. + if ht.IsNeutrinoBackend() { + carolBalance = carolBalResp.TotalBalance + } + + increase := carolBalance - carolStartingBalance + + // Expect the balance to rise at least by 900k sats. + const wantIncrease = 900_000 + if increase < wantIncrease { + return fmt.Errorf("expected carol to have her balance "+ + "increased by %d, instead had %d", + wantIncrease, increase) + } + + return nil + }, defaultTimeout) + require.NoError(ht, err, "timeout while checking carol's balance") + + ht.AssertNodeNumChannels(carol, 0) +}