package wtclient import ( "encoding/binary" "testing" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/watchtower/blob" "github.com/lightningnetwork/lnd/watchtower/wtdb" "github.com/lightningnetwork/lnd/watchtower/wtmock" "github.com/lightningnetwork/lnd/watchtower/wtpolicy" "github.com/stretchr/testify/require" ) const csvDelay uint32 = 144 var ( zeroSig = makeSig(0) revPrivBytes = []byte{ 0x8f, 0x4b, 0x51, 0x83, 0xa9, 0x34, 0xbd, 0x5f, 0x74, 0x6c, 0x9d, 0x5c, 0xae, 0x88, 0x2d, 0x31, 0x06, 0x90, 0xdd, 0x8c, 0x9b, 0x31, 0xbc, 0xd1, 0x78, 0x91, 0x88, 0x2a, 0xf9, 0x74, 0xa0, 0xef, } toLocalPrivBytes = []byte{ 0xde, 0x17, 0xc1, 0x2f, 0xdc, 0x1b, 0xc0, 0xc6, 0x59, 0x5d, 0xf9, 0xc1, 0x3e, 0x89, 0xbc, 0x6f, 0x01, 0x85, 0x45, 0x76, 0x26, 0xce, 0x9c, 0x55, 0x3b, 0xc9, 0xec, 0x3d, 0xd8, 0x8b, 0xac, 0xa8, } toRemotePrivBytes = []byte{ 0x28, 0x59, 0x6f, 0x36, 0xb8, 0x9f, 0x19, 0x5d, 0xcb, 0x07, 0x48, 0x8a, 0xe5, 0x89, 0x71, 0x74, 0x70, 0x4c, 0xff, 0x1e, 0x9c, 0x00, 0x93, 0xbe, 0xe2, 0x2e, 0x68, 0x08, 0x4c, 0xb4, 0x0f, 0x4f, } ) type backupTaskTest struct { name string chanID lnwire.ChannelID breachInfo *lnwallet.BreachRetribution expToLocalInput input.Input expToRemoteInput input.Input expTotalAmt btcutil.Amount expSweepAmt int64 expRewardAmt int64 expRewardScript []byte session *wtdb.ClientSessionBody bindErr error expSweepScript []byte signer input.Signer chanType channeldb.ChannelType commitType blob.CommitmentType } // genTaskTest creates a instance of a backupTaskTest using the passed // parameters. This method handles generating a breach transaction and its // corresponding BreachInfo, as well as setting the wtpolicy.Policy of the given // session. func genTaskTest( t *testing.T, name string, stateNum uint64, toLocalAmt int64, toRemoteAmt int64, blobType blob.Type, sweepFeeRate chainfee.SatPerKWeight, rewardScript []byte, expSweepAmt int64, expRewardAmt int64, bindErr error, chanType channeldb.ChannelType) backupTaskTest { // Set the anchor or taproot flag in the blob type if the session needs // to support anchor or taproot channels. if chanType.IsTaproot() { blobType |= blob.Type(blob.FlagTaprootChannel) } else if chanType.HasAnchors() { blobType |= blob.Type(blob.FlagAnchorChannel) } // Parse the key pairs for all keys used in the test. revSK, revPK := btcec.PrivKeyFromBytes(revPrivBytes) _, toLocalPK := btcec.PrivKeyFromBytes(toLocalPrivBytes) toRemoteSK, toRemotePK := btcec.PrivKeyFromBytes(toRemotePrivBytes) commitType, err := blobType.CommitmentType(&chanType) require.NoError(t, err) // Create the signer, and add the revocation and to-remote privkeys. signer := wtmock.NewMockSigner() var ( revKeyLoc = signer.AddPrivKey(revSK) toRemoteKeyLoc = signer.AddPrivKey(toRemoteSK) ) // First, we'll initialize a new breach transaction and the // corresponding breach retribution. The retribution stores a pointer to // the breach transaction, which we will continue to modify. breachTxn := wire.NewMsgTx(2) breachInfo := &lnwallet.BreachRetribution{ RevokedStateNum: stateNum, BreachTxHash: breachTxn.TxHash(), KeyRing: &lnwallet.CommitmentKeyRing{ RevocationKey: revPK, ToLocalKey: toLocalPK, ToRemoteKey: toRemotePK, }, RemoteDelay: csvDelay, } // Add the sign descriptors and outputs corresponding to the to-local // and to-remote outputs, respectively, if either input amount is // non-zero. Note that the naming here seems reversed, but both are // correct. For example, the to-remote output on the remote party's // commitment is an output that pays to us. Hence the retribution refers // to that output as local, though relative to their commitment, it is // paying to-the-remote party (which is us). if toLocalAmt > 0 { var toLocalSignDesc *input.SignDescriptor if chanType.IsTaproot() { scriptTree, _ := input.NewLocalCommitScriptTree( csvDelay, toLocalPK, revPK, fn.None[txscript.TapLeaf](), ) pkScript, _ := input.PayToTaprootScript( scriptTree.TaprootKey, ) revokeTapleafHash := txscript.NewBaseTapLeaf( scriptTree.RevocationLeaf.Script, ).TapHash() tapTree := scriptTree.TapscriptTree revokeIdx := tapTree.LeafProofIndex[revokeTapleafHash] revokeMerkleProof := tapTree.LeafMerkleProofs[revokeIdx] revokeControlBlock := revokeMerkleProof.ToControlBlock( &input.TaprootNUMSKey, ) ctrlBytes, _ := revokeControlBlock.ToBytes() toLocalSignDesc = &input.SignDescriptor{ KeyDesc: keychain.KeyDescriptor{ KeyLocator: revKeyLoc, PubKey: revPK, }, Output: &wire.TxOut{ Value: toLocalAmt, PkScript: pkScript, }, WitnessScript: scriptTree.RevocationLeaf.Script, SignMethod: input.TaprootScriptSpendSignMethod, //nolint:lll HashType: txscript.SigHashDefault, ControlBlock: ctrlBytes, } } else { toLocalSignDesc = &input.SignDescriptor{ KeyDesc: keychain.KeyDescriptor{ KeyLocator: revKeyLoc, PubKey: revPK, }, Output: &wire.TxOut{ Value: toLocalAmt, }, HashType: txscript.SigHashAll, } } breachInfo.RemoteOutputSignDesc = toLocalSignDesc breachTxn.AddTxOut(toLocalSignDesc.Output) } if toRemoteAmt > 0 { var toRemoteSignDesc *input.SignDescriptor if chanType.IsTaproot() { scriptTree, _ := input.NewRemoteCommitScriptTree( toRemotePK, fn.None[txscript.TapLeaf](), ) pkScript, _ := input.PayToTaprootScript( scriptTree.TaprootKey, ) revokeTapleafHash := txscript.NewBaseTapLeaf( scriptTree.SettleLeaf.Script, ).TapHash() tapTree := scriptTree.TapscriptTree revokeIdx := tapTree.LeafProofIndex[revokeTapleafHash] revokeMerkleProof := tapTree.LeafMerkleProofs[revokeIdx] revokeControlBlock := revokeMerkleProof.ToControlBlock( &input.TaprootNUMSKey, ) ctrlBytes, _ := revokeControlBlock.ToBytes() toRemoteSignDesc = &input.SignDescriptor{ KeyDesc: keychain.KeyDescriptor{ KeyLocator: toRemoteKeyLoc, PubKey: toRemotePK, }, WitnessScript: scriptTree.SettleLeaf.Script, SignMethod: input.TaprootScriptSpendSignMethod, //nolint:lll Output: &wire.TxOut{ Value: toRemoteAmt, PkScript: pkScript, }, HashType: txscript.SigHashDefault, InputIndex: 1, ControlBlock: ctrlBytes, } } else { toRemoteSignDesc = &input.SignDescriptor{ KeyDesc: keychain.KeyDescriptor{ KeyLocator: toRemoteKeyLoc, PubKey: toRemotePK, }, Output: &wire.TxOut{ Value: toRemoteAmt, }, HashType: txscript.SigHashAll, } } breachInfo.LocalOutputSignDesc = toRemoteSignDesc breachTxn.AddTxOut(toRemoteSignDesc.Output) } var ( toLocalInput input.Input toRemoteInput input.Input ) // Now that the breach transaction has all its outputs, we can compute // its txid and inputs spending from it. We also generate the // input.Inputs that should be derived by the backup task. txid := breachTxn.TxHash() var index uint32 if toLocalAmt > 0 { breachInfo.RemoteOutpoint = wire.OutPoint{ Hash: txid, Index: index, } toLocalInput, err = commitType.ToLocalInput(breachInfo) require.NoError(t, err) index++ } if toRemoteAmt > 0 { breachInfo.LocalOutpoint = wire.OutPoint{ Hash: txid, Index: index, } toRemoteInput, err = commitType.ToRemoteInput(breachInfo) require.NoError(t, err) } return backupTaskTest{ name: name, breachInfo: breachInfo, expToLocalInput: toLocalInput, expToRemoteInput: toRemoteInput, expTotalAmt: btcutil.Amount(toLocalAmt + toRemoteAmt), expSweepAmt: expSweepAmt, expRewardAmt: expRewardAmt, expRewardScript: rewardScript, session: &wtdb.ClientSessionBody{ Policy: wtpolicy.Policy{ TxPolicy: wtpolicy.TxPolicy{ BlobType: blobType, SweepFeeRate: sweepFeeRate, RewardRate: 10000, }, }, RewardPkScript: rewardScript, }, bindErr: bindErr, expSweepScript: sweepAddr, signer: signer, chanType: chanType, commitType: commitType, } } var ( blobTypeCommitNoReward = blob.FlagCommitOutputs.Type() blobTypeCommitReward = (blob.FlagCommitOutputs | blob.FlagReward).Type() addr, _ = btcutil.DecodeAddress( "tb1pw8gzj8clt3v5lxykpgacpju5n8xteskt7gxhmudu6pa70nwfhe6s3unsyk", &chaincfg.TestNet3Params, ) addrScript, _ = txscript.PayToAddrScript(addr) sweepAddrScript, _ = btcutil.DecodeAddress( "tb1qs3jyc9sf5kak3x0w99cav9u605aeu3t600xxx0", &chaincfg.TestNet3Params, ) sweepAddr, _ = txscript.PayToAddrScript(sweepAddrScript) ) // TestBackupTaskBind tests the initialization and binding of a backupTask to a // ClientSession. After a successful bind, all parameters of the justice // transaction should be solidified, so we assert there correctness. In an // unsuccessful bind, the session-dependent parameters should be unmodified so // that the backup task can be rescheduled if necessary. Finally, we assert that // the backup task is able to encrypt a valid justice kit, and that we can // decrypt it using the breach txid. func TestBackupTask(t *testing.T) { t.Parallel() chanTypes := []channeldb.ChannelType{ channeldb.SingleFunderBit, channeldb.SingleFunderTweaklessBit, channeldb.AnchorOutputsBit, channeldb.SimpleTaprootFeatureBit, } var backupTaskTests []backupTaskTest for _, chanType := range chanTypes { // Depending on whether the test is for anchor channels or // legacy (tweaked and non-tweaked) channels, adjust the // expected sweep amount to accommodate. These are different for // several reasons: // - anchor to-remote outputs require a P2WSH sweep rather // than a P2WKH sweep. // - the to-local weight estimate fixes an off-by-one. // In tests related to the dust threshold, the size difference // between the channel types makes it so that the threshold fee // rate is slightly lower (since the transactions are heavier). var ( expSweepCommitNoRewardBoth int64 = 299241 expSweepCommitNoRewardLocal int64 = 199514 expSweepCommitNoRewardRemote int64 = 99561 expSweepCommitRewardBoth int64 = 296069 expSweepCommitRewardLocal int64 = 197342 expSweepCommitRewardRemote int64 = 98389 sweepFeeRateNoRewardRemoteDust chainfee.SatPerKWeight = 227500 sweepFeeRateRewardRemoteDust chainfee.SatPerKWeight = 175350 ) if chanType.IsTaproot() { expSweepCommitNoRewardBoth = 299165 expSweepCommitNoRewardLocal = 199468 expSweepCommitNoRewardRemote = 99531 sweepFeeRateNoRewardRemoteDust = 213200 expSweepCommitRewardBoth = 295993 expSweepCommitRewardLocal = 197296 expSweepCommitRewardRemote = 98359 sweepFeeRateRewardRemoteDust = 167000 } else if chanType.HasAnchors() { expSweepCommitNoRewardBoth = 299236 expSweepCommitNoRewardLocal = 199513 expSweepCommitNoRewardRemote = 99557 expSweepCommitRewardBoth = 296064 expSweepCommitRewardLocal = 197341 expSweepCommitRewardRemote = 98385 sweepFeeRateNoRewardRemoteDust = 225400 sweepFeeRateRewardRemoteDust = 174100 } backupTaskTests = append(backupTaskTests, []backupTaskTest{ genTaskTest( t, "commit no-reward, both outputs", 100, // stateNum 200000, // toLocalAmt 100000, // toRemoteAmt blobTypeCommitNoReward, // blobType 1000, // sweepFeeRate nil, // rewardScript expSweepCommitNoRewardBoth, // expSweepAmt 0, // expRewardAmt nil, // bindErr chanType, ), genTaskTest( t, "commit no-reward, to-local output only", 1000, // stateNum 200000, // toLocalAmt 0, // toRemoteAmt blobTypeCommitNoReward, // blobType 1000, // sweepFeeRate nil, // rewardScript expSweepCommitNoRewardLocal, // expSweepAmt 0, // expRewardAmt nil, // bindErr chanType, ), genTaskTest( t, "commit no-reward, to-remote output only", 1, // stateNum 0, // toLocalAmt 100000, // toRemoteAmt blobTypeCommitNoReward, // blobType 1000, // sweepFeeRate nil, // rewardScript expSweepCommitNoRewardRemote, // expSweepAmt 0, // expRewardAmt nil, // bindErr chanType, ), genTaskTest( t, "commit no-reward, to-remote output only, creates dust", 1, // stateNum 0, // toLocalAmt 100000, // toRemoteAmt blobTypeCommitNoReward, // blobType sweepFeeRateNoRewardRemoteDust, // sweepFeeRate nil, // rewardScript 0, // expSweepAmt 0, // expRewardAmt wtpolicy.ErrCreatesDust, // bindErr chanType, ), genTaskTest( t, "commit no-reward, no outputs, fee rate exceeds inputs", 300, // stateNum 0, // toLocalAmt 0, // toRemoteAmt blobTypeCommitNoReward, // blobType 1000, // sweepFeeRate nil, // rewardScript 0, // expSweepAmt 0, // expRewardAmt wtpolicy.ErrFeeExceedsInputs, // bindErr chanType, ), genTaskTest( t, "commit no-reward, no outputs, fee rate of 0 creates dust", 300, // stateNum 0, // toLocalAmt 0, // toRemoteAmt blobTypeCommitNoReward, // blobType 0, // sweepFeeRate nil, // rewardScript 0, // expSweepAmt 0, // expRewardAmt wtpolicy.ErrCreatesDust, // bindErr chanType, ), genTaskTest( t, "commit reward, both outputs", 100, // stateNum 200000, // toLocalAmt 100000, // toRemoteAmt blobTypeCommitReward, // blobType 1000, // sweepFeeRate addrScript, // rewardScript expSweepCommitRewardBoth, // expSweepAmt 3000, // expRewardAmt nil, // bindErr chanType, ), genTaskTest( t, "commit reward, to-local output only", 1000, // stateNum 200000, // toLocalAmt 0, // toRemoteAmt blobTypeCommitReward, // blobType 1000, // sweepFeeRate addrScript, // rewardScript expSweepCommitRewardLocal, // expSweepAmt 2000, // expRewardAmt nil, // bindErr chanType, ), genTaskTest( t, "commit reward, to-remote output only", 1, // stateNum 0, // toLocalAmt 100000, // toRemoteAmt blobTypeCommitReward, // blobType 1000, // sweepFeeRate addrScript, // rewardScript expSweepCommitRewardRemote, // expSweepAmt 1000, // expRewardAmt nil, // bindErr chanType, ), genTaskTest( t, "commit reward, to-remote output only, creates dust", 1, // stateNum 0, // toLocalAmt 108221, // toRemoteAmt blobTypeCommitReward, // blobType sweepFeeRateRewardRemoteDust, // sweepFeeRate addrScript, // rewardScript 0, // expSweepAmt 0, // expRewardAmt wtpolicy.ErrCreatesDust, // bindErr chanType, ), genTaskTest( t, "commit reward, no outputs, fee rate exceeds inputs", 300, // stateNum 0, // toLocalAmt 0, // toRemoteAmt blobTypeCommitReward, // blobType 1000, // sweepFeeRate addrScript, // rewardScript 0, // expSweepAmt 0, // expRewardAmt wtpolicy.ErrFeeExceedsInputs, // bindErr chanType, ), genTaskTest( t, "commit reward, no outputs, fee rate of 0 creates dust", 300, // stateNum 0, // toLocalAmt 0, // toRemoteAmt blobTypeCommitReward, // blobType 0, // sweepFeeRate addrScript, // rewardScript 0, // expSweepAmt 0, // expRewardAmt wtpolicy.ErrCreatesDust, // bindErr chanType, ), }...) } for _, test := range backupTaskTests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() testBackupTask(t, test) }) } } func testBackupTask(t *testing.T, test backupTaskTest) { // Create a new backupTask from the channel id and breach info. id := wtdb.BackupID{ ChanID: test.chanID, CommitHeight: test.breachInfo.RevokedStateNum, } task := newBackupTask(id, test.expSweepScript) // getBreachInfo is a helper closure that returns the breach retribution // info and channel type for the given channel and commit height. getBreachInfo := func(id lnwire.ChannelID, commitHeight uint64) ( *lnwallet.BreachRetribution, channeldb.ChannelType, error) { return test.breachInfo, test.chanType, nil } // Reconstruct the expected input.Inputs that will be returned by the // task's inputs() method. expInputs := make(map[wire.OutPoint]input.Input) if task.toLocalInput != nil { expInputs[task.toLocalInput.OutPoint()] = task.toLocalInput } if task.toRemoteInput != nil { expInputs[task.toRemoteInput.OutPoint()] = task.toRemoteInput } // Assert that the inputs method returns the correct slice of // input.Inputs. inputs := task.inputs() require.Equal(t, expInputs, inputs) // Now, bind the session to the task. If successful, this locks in the // session's negotiated parameters and allows the backup task to derive // the final free variables in the justice transaction. err := task.bindSession(test.session, getBreachInfo) require.ErrorIs(t, err, test.bindErr) // Assert that all parameters set during after binding the backup task // are properly populated. require.Equal(t, test.chanID, task.id.ChanID) require.Equal(t, test.breachInfo.RevokedStateNum, task.id.CommitHeight) require.Equal(t, test.expTotalAmt, task.totalAmt) require.Equal(t, test.breachInfo, task.breachInfo) require.Equal(t, test.expToLocalInput, task.toLocalInput) require.Equal(t, test.expToRemoteInput, task.toRemoteInput) // Exit early if the bind was supposed to fail. But first, we check that // all fields set during a bind are still unset. This ensure that a // failed bind doesn't have side-effects if the task is retried with a // different session. if test.bindErr != nil { require.Zerof(t, task.blobType, "blob type should not be set "+ "on failed bind, found: %s", task.blobType) require.Nilf(t, task.outputs, "justice outputs should not be "+ " set on failed bind, found: %v", task.outputs) return } // Otherwise, the binding succeeded. Assert that all values set during // the bind are properly populated. policy := test.session.Policy require.Equal(t, policy.BlobType, task.blobType) // Compute the expected outputs on the justice transaction. var expOutputs = []*wire.TxOut{ { PkScript: test.expSweepScript, Value: test.expSweepAmt, }, } // If the policy specifies a reward output, add it to the expected list // of outputs. if test.session.Policy.BlobType.Has(blob.FlagReward) { expOutputs = append(expOutputs, &wire.TxOut{ PkScript: test.expRewardScript, Value: test.expRewardAmt, }) } // Assert that the computed outputs match our expected outputs. require.Equal(t, expOutputs, task.outputs) // Now, we'll construct, sign, and encrypt the blob containing the parts // needed to reconstruct the justice transaction. hint, encBlob, err := task.craftSessionPayload(test.signer) require.NoError(t, err, "unable to craft session payload") // Verify that the breach hint matches the breach txid's prefix. breachTxID := test.breachInfo.BreachTxHash expHint := blob.NewBreachHintFromHash(&breachTxID) require.Equal(t, expHint, hint) // Decrypt the return blob to obtain the JusticeKit containing its // contents. key := blob.NewBreachKeyFromHash(&breachTxID) jKit, err := blob.Decrypt(key, encBlob, policy.BlobType) require.NoError(t, err, "unable to decrypt blob") keyRing := test.breachInfo.KeyRing expToLocalPK := keyRing.ToLocalKey expRevPK := keyRing.RevocationKey expToRemotePK := keyRing.ToRemoteKey breachInfo := &lnwallet.BreachRetribution{ RemoteDelay: csvDelay, KeyRing: &lnwallet.CommitmentKeyRing{ ToLocalKey: expToLocalPK, RevocationKey: expRevPK, ToRemoteKey: expToRemotePK, }, } expectedKit, err := test.commitType.NewJusticeKit( test.expSweepScript, breachInfo, test.expToRemoteInput != nil, ) require.NoError(t, err) jKit.AddToLocalSig(zeroSig) jKit.AddToRemoteSig(zeroSig) require.Equal(t, expectedKit, jKit) } func makeSig(i int) lnwire.Sig { var sigBytes [64]byte binary.BigEndian.PutUint64(sigBytes[:8], uint64(i)) sig, _ := lnwire.NewSigFromWireECDSA(sigBytes[:]) return sig }