package lookout_test import ( "testing" "time" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/txsort" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" secp "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/watchtower/blob" "github.com/lightningnetwork/lnd/watchtower/lookout" "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 ( 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, } rewardCommitType = blob.TypeFromFlags( blob.FlagReward, blob.FlagCommitOutputs, ) altruistCommitType = blob.FlagCommitOutputs.Type() altruistAnchorCommitType = blob.TypeAltruistAnchorCommit altruisticTaprootCommitType = blob.TypeAltruistTaprootCommit ) // TestJusticeDescriptor asserts that a JusticeDescriptor is able to produce the // correct justice transaction for different blob types. func TestJusticeDescriptor(t *testing.T) { tests := []struct { name string blobType blob.Type }{ { name: "reward and commit type", blobType: rewardCommitType, }, { name: "altruist and commit type", blobType: altruistCommitType, }, { name: "altruist anchor commit type", blobType: altruistAnchorCommitType, }, { name: "altruist taproot commit type", blobType: altruisticTaprootCommitType, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { testJusticeDescriptor(t, test.blobType) }) } } func testJusticeDescriptor(t *testing.T, blobType blob.Type) { isAnchorChannel := blobType.IsAnchorChannel() isTaprootChannel := blobType.IsTaprootChannel() const ( localAmount = btcutil.Amount(100000) remoteAmount = btcutil.Amount(200000) totalAmount = localAmount + remoteAmount ) // 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) // Get the commitment type. commitType, err := blobType.CommitmentType(nil) 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) ) var ( toLocalScript, toLocalScriptHash []byte toLocalCommitTree *input.CommitScriptTree ) if isTaprootChannel { toLocalCommitTree, err = input.NewLocalCommitScriptTree( csvDelay, toLocalPK, revPK, ) require.NoError(t, err) toLocalScript = toLocalCommitTree.RevocationLeaf.Script toLocalScriptHash, err = input.PayToTaprootScript( toLocalCommitTree.TaprootKey, ) require.NoError(t, err) } else { // Construct the to-local witness script. toLocalScript, err = input.CommitScriptToSelf( csvDelay, toLocalPK, revPK, ) require.NoError(t, err) // Compute the to-local witness script hash. toLocalScriptHash, err = input.WitnessScriptHash(toLocalScript) require.NoError(t, err) } // Compute the to-remote redeem script, witness script hash, and // sequence numbers. // // NOTE: This is pretty subtle. // // The actual redeem script for a p2wkh output is just the pubkey, but // the witness sighash calculation injects the classic p2kh script: // OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG. When // signing for p2wkh we don't pass the raw pubkey as the witness script // to the sign descriptor (since that's also not a valid script). // Instead we give it the _pkscript_ of the form OP_0 // from which pubkey-hash160 is extracted during sighash calculation. // // On the other hand, signing for the anchor p2wsh to-remote outputs // requires the sign descriptor to contain the redeem script ver batim. // This difference in behavior forces us to use a distinct // toRemoteSigningScript to handle both cases. var ( toRemoteSequence uint32 toRemoteRedeemScript []byte toRemoteScriptHash []byte toRemoteSigningScript []byte toRemoteCtrlBlock []byte ) switch { case isTaprootChannel: toRemoteSequence = 1 commitScriptTree, err := input.NewRemoteCommitScriptTree( toRemotePK, ) require.NoError(t, err) toRemoteSigningScript = commitScriptTree.SettleLeaf.Script toRemoteScriptHash, err = input.PayToTaprootScript( commitScriptTree.TaprootKey, ) require.NoError(t, err) tree := commitScriptTree.TapscriptTree settleTapleafHash := commitScriptTree.SettleLeaf.TapHash() settleIdx := tree.LeafProofIndex[settleTapleafHash] settleMerkleProof := tree.LeafMerkleProofs[settleIdx] settleControlBlock := settleMerkleProof.ToControlBlock( &input.TaprootNUMSKey, ) ctrlBytes, err := settleControlBlock.ToBytes() require.NoError(t, err) toRemoteCtrlBlock = ctrlBytes case isAnchorChannel: toRemoteSequence = 1 toRemoteRedeemScript, err = input.CommitScriptToRemoteConfirmed( toRemotePK, ) require.NoError(t, err) toRemoteScriptHash, err = input.WitnessScriptHash( toRemoteRedeemScript, ) require.NoError(t, err) // As it should be. toRemoteSigningScript = toRemoteRedeemScript default: toRemoteRedeemScript = toRemotePK.SerializeCompressed() toRemoteScriptHash, err = input.CommitScriptUnencumbered( toRemotePK, ) require.NoError(t, err) // NOTE: This is the _pkscript_. toRemoteSigningScript = toRemoteScriptHash } // Construct the breaching commitment txn, containing the to-local and // to-remote outputs. We don't need any inputs for this test. breachTxn := &wire.MsgTx{ Version: 2, TxIn: []*wire.TxIn{}, TxOut: []*wire.TxOut{ { Value: int64(localAmount), PkScript: toLocalScriptHash, }, { Value: int64(remoteAmount), PkScript: toRemoteScriptHash, }, }, } breachTxID := breachTxn.TxHash() // Compute the weight estimate for our justice transaction. var weightEstimate input.TxWeightEstimator // Add the local witness size to the weight estimator. toLocalWitnessSize, err := commitType.ToLocalWitnessSize() require.NoError(t, err) weightEstimate.AddWitnessInput(toLocalWitnessSize) // Add the remote witness size to the weight estimator. toRemoteWitnessSize, err := commitType.ToRemoteWitnessSize() require.NoError(t, err) weightEstimate.AddWitnessInput(toRemoteWitnessSize) // Add the sweep output to the weight estimator. weightEstimate.AddP2WKHOutput() // Add the reward output to the weight estimator. if blobType.Has(blob.FlagReward) { weightEstimate.AddP2WKHOutput() } txWeight := weightEstimate.Weight() // Create a session info so that simulate agreement of the sweep // parameters that should be used in constructing the justice // transaction. policy := wtpolicy.Policy{ TxPolicy: wtpolicy.TxPolicy{ BlobType: blobType, SweepFeeRate: 2000, RewardRate: 900000, }, } sessionInfo := &wtdb.SessionInfo{ Policy: policy, RewardAddress: makeAddrSlice(22), } breachInfo := &lnwallet.BreachRetribution{ RemoteDelay: csvDelay, KeyRing: &lnwallet.CommitmentKeyRing{ ToLocalKey: toLocalPK, ToRemoteKey: toRemotePK, RevocationKey: revPK, }, } justiceKit, err := commitType.NewJusticeKit( makeAddrSlice(22), breachInfo, true, ) require.NoError(t, err) // Create a transaction spending from the outputs of the breach // transaction created earlier. The inputs are always ordered w/ // to-local and then to-remote. The outputs are always added as the // sweep address then reward address. justiceTxn := &wire.MsgTx{ Version: 2, TxIn: []*wire.TxIn{ { PreviousOutPoint: wire.OutPoint{ Hash: breachTxID, Index: 0, }, }, { PreviousOutPoint: wire.OutPoint{ Hash: breachTxID, Index: 1, }, Sequence: toRemoteSequence, }, }, } outputs, err := policy.ComputeJusticeTxOuts( totalAmount, int64(txWeight), justiceKit.SweepAddress(), sessionInfo.RewardAddress, ) require.NoError(t, err) // Attach the txouts and BIP69 sort the resulting transaction. justiceTxn.TxOut = outputs txsort.InPlaceSort(justiceTxn) var ( toLocalSignDesc *input.SignDescriptor toRemoteSignDesc *input.SignDescriptor ) if isTaprootChannel { prevOuts := map[wire.OutPoint]*wire.TxOut{ { Hash: breachTxID, Index: 0, }: breachTxn.TxOut[0], { Hash: breachTxID, Index: 1, }: breachTxn.TxOut[1], } prevOutputFetcher := txscript.NewMultiPrevOutFetcher(prevOuts) hashCache := txscript.NewTxSigHashes( justiceTxn, prevOutputFetcher, ) toLocalSignDesc = &input.SignDescriptor{ KeyDesc: keychain.KeyDescriptor{ KeyLocator: revKeyLoc, }, WitnessScript: toLocalScript, Output: breachTxn.TxOut[0], SigHashes: hashCache, PrevOutputFetcher: prevOutputFetcher, InputIndex: 0, HashType: txscript.SigHashDefault, SignMethod: input.TaprootScriptSpendSignMethod, } toRemoteSignDesc = &input.SignDescriptor{ KeyDesc: keychain.KeyDescriptor{ KeyLocator: toRemoteKeyLoc, PubKey: toRemotePK, }, WitnessScript: toRemoteSigningScript, Output: breachTxn.TxOut[1], PrevOutputFetcher: prevOutputFetcher, SigHashes: hashCache, InputIndex: 1, HashType: txscript.SigHashDefault, SignMethod: input.TaprootScriptSpendSignMethod, } } else { hashCache := input.NewTxSigHashesV0Only(justiceTxn) // Create the sign descriptor used to sign for the to-local // input. toLocalSignDesc = &input.SignDescriptor{ KeyDesc: keychain.KeyDescriptor{ KeyLocator: revKeyLoc, }, WitnessScript: toLocalScript, Output: breachTxn.TxOut[0], SigHashes: hashCache, InputIndex: 0, HashType: txscript.SigHashAll, } // Create the sign descriptor used to sign for the to-remote // input. toRemoteSignDesc = &input.SignDescriptor{ KeyDesc: keychain.KeyDescriptor{ KeyLocator: toRemoteKeyLoc, PubKey: toRemotePK, }, WitnessScript: toRemoteSigningScript, Output: breachTxn.TxOut[1], SigHashes: hashCache, InputIndex: 1, HashType: txscript.SigHashAll, } } // Verify that our test justice transaction is sane. btx := btcutil.NewTx(justiceTxn) err = blockchain.CheckTransactionSanity(btx) require.Nil(t, err) // Compute a signature for the to-local input. toLocalSigRaw, err := signer.SignOutputRaw(justiceTxn, toLocalSignDesc) require.Nil(t, err) // Compute the witness for the to-remote input. toRemoteSigRaw, err := signer.SignOutputRaw(justiceTxn, toRemoteSignDesc) require.Nil(t, err) // Convert the to-local sig into a fixed-size signature. toLocalSig, err := lnwire.NewSigFromSignature(toLocalSigRaw) require.Nil(t, err) // Convert the to-remote sig into a fixed-size signature. toRemoteSig, err := lnwire.NewSigFromSignature(toRemoteSigRaw) require.Nil(t, err) // Complete our justice kit by copying the signatures into the payload. justiceKit.AddToLocalSig(toLocalSig) justiceKit.AddToRemoteSig(toRemoteSig) justiceDesc := &lookout.JusticeDescriptor{ BreachedCommitTx: breachTxn, SessionInfo: sessionInfo, JusticeKit: justiceKit, } // Construct a breach punisher that will feed published transactions // over the buffered channel. publications := make(chan *wire.MsgTx, 1) punisher := lookout.NewBreachPunisher(&lookout.PunisherConfig{ PublishTx: func(tx *wire.MsgTx, _ string) error { publications <- tx return nil }, }) // Exact retribution on the offender. If no error is returned, we expect // the justice transaction to be published via the channel. err = punisher.Punish(justiceDesc, nil) require.NoError(t, err) // Retrieve the published justice transaction. var wtJusticeTxn *wire.MsgTx select { case wtJusticeTxn = <-publications: case <-time.After(50 * time.Millisecond): t.Fatalf("punisher did not publish justice txn") } if isTaprootChannel { revokeLeaf := txscript.NewBaseTapLeaf(toLocalScript) outputKey := txscript.ComputeTaprootOutputKey( &input.TaprootNUMSKey, toLocalCommitTree.TapscriptRoot, ) var outputKeyYIsOdd bool if outputKey.SerializeCompressed()[0] == secp.PubKeyFormatCompressedOdd { outputKeyYIsOdd = true } delayScriptHash := toLocalCommitTree.SettleLeaf.TapHash() ctrlBlock := txscript.ControlBlock{ InternalKey: &input.TaprootNUMSKey, OutputKeyYIsOdd: outputKeyYIsOdd, LeafVersion: revokeLeaf.LeafVersion, InclusionProof: delayScriptHash[:], } ctrlBytes, err := ctrlBlock.ToBytes() require.NoError(t, err) justiceTxn.TxIn[0].Witness = make([][]byte, 3) justiceTxn.TxIn[0].Witness[0] = toLocalSigRaw.Serialize() justiceTxn.TxIn[0].Witness[1] = toLocalScript justiceTxn.TxIn[0].Witness[2] = ctrlBytes // Construct the test's to-remote witness. justiceTxn.TxIn[1].Witness = make([][]byte, 3) justiceTxn.TxIn[1].Witness[0] = toRemoteSigRaw.Serialize() justiceTxn.TxIn[1].Witness[1] = toRemoteSigningScript justiceTxn.TxIn[1].Witness[2] = toRemoteCtrlBlock } else { // Construct the test's to-local witness. justiceTxn.TxIn[0].Witness = make([][]byte, 3) justiceTxn.TxIn[0].Witness[0] = append( toLocalSigRaw.Serialize(), byte(txscript.SigHashAll), ) justiceTxn.TxIn[0].Witness[1] = []byte{1} justiceTxn.TxIn[0].Witness[2] = toLocalScript // Construct the test's to-remote witness. justiceTxn.TxIn[1].Witness = make([][]byte, 2) justiceTxn.TxIn[1].Witness[0] = append( toRemoteSigRaw.Serialize(), byte(txscript.SigHashAll), ) justiceTxn.TxIn[1].Witness[1] = toRemoteRedeemScript } // Assert that the watchtower derives the same justice txn. require.Equal(t, justiceTxn, wtJusticeTxn) }