lnd.xprv/watchtower/wtclient/backup_task_internal_test.go

606 lines
19 KiB
Go

package wtclient
import (
"bytes"
"crypto/rand"
"io"
"reflect"
"testing"
"github.com/btcsuite/btcd/btcec"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcutil"
"github.com/davecgh/go-spew/spew"
"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/wtdb"
"github.com/lightningnetwork/lnd/watchtower/wtmock"
"github.com/lightningnetwork/lnd/watchtower/wtpolicy"
)
const csvDelay uint32 = 144
var (
zeroPK [33]byte
zeroSig [64]byte
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,
}
)
func makeAddrSlice(size int) []byte {
addr := make([]byte, size)
if _, err := io.ReadFull(rand.Reader, addr); err != nil {
panic("cannot make addr")
}
return addr
}
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.SessionInfo
bindErr error
expSweepScript []byte
signer input.Signer
}
// 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(
name string,
stateNum uint64,
toLocalAmt int64,
toRemoteAmt int64,
blobType blob.Type,
sweepFeeRate lnwallet.SatPerKWeight,
rewardScript []byte,
expSweepAmt int64,
expRewardAmt int64,
bindErr error) backupTaskTest {
// Parse the key pairs for all keys used in the test.
revSK, revPK := btcec.PrivKeyFromBytes(
btcec.S256(), revPrivBytes,
)
_, toLocalPK := btcec.PrivKeyFromBytes(
btcec.S256(), toLocalPrivBytes,
)
toRemoteSK, toRemotePK := btcec.PrivKeyFromBytes(
btcec.S256(), toRemotePrivBytes,
)
// 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,
BreachTransaction: breachTxn,
KeyRing: &lnwallet.CommitmentKeyRing{
RevocationKey: revPK,
DelayKey: toLocalPK,
NoDelayKey: 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 {
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 {
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 = input.NewBaseInput(
&breachInfo.RemoteOutpoint,
input.CommitmentRevoke,
breachInfo.RemoteOutputSignDesc,
0,
)
index++
}
if toRemoteAmt > 0 {
breachInfo.LocalOutpoint = wire.OutPoint{
Hash: txid,
Index: index,
}
toRemoteInput = input.NewBaseInput(
&breachInfo.LocalOutpoint,
input.CommitmentNoDelay,
breachInfo.LocalOutputSignDesc,
0,
)
}
return backupTaskTest{
name: name,
breachInfo: breachInfo,
expToLocalInput: toLocalInput,
expToRemoteInput: toRemoteInput,
expTotalAmt: btcutil.Amount(toLocalAmt + toRemoteAmt),
expSweepAmt: expSweepAmt,
expRewardAmt: expRewardAmt,
expRewardScript: rewardScript,
session: &wtdb.SessionInfo{
Policy: wtpolicy.Policy{
BlobType: blobType,
SweepFeeRate: sweepFeeRate,
RewardRate: 10000,
},
RewardAddress: rewardScript,
},
bindErr: bindErr,
expSweepScript: makeAddrSlice(22),
signer: signer,
}
}
var (
blobTypeCommitNoReward = blob.FlagCommitOutputs.Type()
blobTypeCommitReward = (blob.FlagCommitOutputs | blob.FlagReward).Type()
addr, _ = btcutil.DecodeAddress(
"mrX9vMRYLfVy1BnZbc5gZjuyaqH3ZW2ZHz", &chaincfg.TestNet3Params,
)
addrScript, _ = txscript.PayToAddrScript(addr)
)
var backupTaskTests = []backupTaskTest{
genTaskTest(
"commit no-reward, both outputs",
100, // stateNum
200000, // toLocalAmt
100000, // toRemoteAmt
blobTypeCommitNoReward, // blobType
1000, // sweepFeeRate
nil, // rewardScript
299241, // expSweepAmt
0, // expRewardAmt
nil, // bindErr
),
genTaskTest(
"commit no-reward, to-local output only",
1000, // stateNum
200000, // toLocalAmt
0, // toRemoteAmt
blobTypeCommitNoReward, // blobType
1000, // sweepFeeRate
nil, // rewardScript
199514, // expSweepAmt
0, // expRewardAmt
nil, // bindErr
),
genTaskTest(
"commit no-reward, to-remote output only",
1, // stateNum
0, // toLocalAmt
100000, // toRemoteAmt
blobTypeCommitNoReward, // blobType
1000, // sweepFeeRate
nil, // rewardScript
99561, // expSweepAmt
0, // expRewardAmt
nil, // bindErr
),
genTaskTest(
"commit no-reward, to-remote output only, creates dust",
1, // stateNum
0, // toLocalAmt
100000, // toRemoteAmt
blobTypeCommitNoReward, // blobType
227500, // sweepFeeRate
nil, // rewardScript
0, // expSweepAmt
0, // expRewardAmt
wtpolicy.ErrCreatesDust, // bindErr
),
genTaskTest(
"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
),
genTaskTest(
"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
),
genTaskTest(
"commit reward, both outputs",
100, // stateNum
200000, // toLocalAmt
100000, // toRemoteAmt
blobTypeCommitReward, // blobType
1000, // sweepFeeRate
addrScript, // rewardScript
296241, // expSweepAmt
3000, // expRewardAmt
nil, // bindErr
),
genTaskTest(
"commit reward, to-local output only",
1000, // stateNum
200000, // toLocalAmt
0, // toRemoteAmt
blobTypeCommitReward, // blobType
1000, // sweepFeeRate
addrScript, // rewardScript
197514, // expSweepAmt
2000, // expRewardAmt
nil, // bindErr
),
genTaskTest(
"commit reward, to-remote output only",
1, // stateNum
0, // toLocalAmt
100000, // toRemoteAmt
blobTypeCommitReward, // blobType
1000, // sweepFeeRate
addrScript, // rewardScript
98561, // expSweepAmt
1000, // expRewardAmt
nil, // bindErr
),
genTaskTest(
"commit reward, to-remote output only, creates dust",
1, // stateNum
0, // toLocalAmt
100000, // toRemoteAmt
blobTypeCommitReward, // blobType
225000, // sweepFeeRate
addrScript, // rewardScript
0, // expSweepAmt
0, // expRewardAmt
wtpolicy.ErrCreatesDust, // bindErr
),
genTaskTest(
"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
),
genTaskTest(
"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
),
}
// TestBackupTaskBind tests the initialization and binding of a backupTask to a
// SessionInfo. After a succesfful 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()
for _, test := range backupTaskTests {
t.Run(test.name, func(t *testing.T) {
testBackupTask(t, test)
})
}
}
func testBackupTask(t *testing.T, test backupTaskTest) {
// Create a new backupTask from the channel id and breach info.
task := newBackupTask(&test.chanID, test.breachInfo)
// Assert that all parameters set during initialization are properly
// populated.
if task.chanID != test.chanID {
t.Fatalf("channel id mismatch, want: %s, got: %s",
test.chanID, task.chanID)
}
if task.commitHeight != test.breachInfo.RevokedStateNum {
t.Fatalf("commit height mismatch, want: %d, got: %d",
test.breachInfo.RevokedStateNum, task.commitHeight)
}
if task.totalAmt != test.expTotalAmt {
t.Fatalf("total amount mismatch, want: %d, got: %v",
test.expTotalAmt, task.totalAmt)
}
if !reflect.DeepEqual(task.breachInfo, test.breachInfo) {
t.Fatalf("breach info mismatch, want: %v, got: %v",
test.breachInfo, task.breachInfo)
}
if !reflect.DeepEqual(task.toLocalInput, test.expToLocalInput) {
t.Fatalf("to-local input mismatch, want: %v, got: %v",
test.expToLocalInput, task.toLocalInput)
}
if !reflect.DeepEqual(task.toRemoteInput, test.expToRemoteInput) {
t.Fatalf("to-local input mismatch, want: %v, got: %v",
test.expToRemoteInput, task.toRemoteInput)
}
// 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()
if !reflect.DeepEqual(expInputs, inputs) {
t.Fatalf("inputs mismatch, want: %v, got: %v",
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, test.expSweepScript)
if err != test.bindErr {
t.Fatalf("expected: %v when binding session, got: %v",
test.bindErr, err)
}
// 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 {
if task.blobType != 0 {
t.Fatalf("blob type should not be set on failed bind, "+
"found: %s", task.blobType)
}
if task.outputs != nil {
t.Fatalf("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
if task.blobType != policy.BlobType {
t.Fatalf("blob type mismatch, want: %s, got %s",
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.
if !reflect.DeepEqual(expOutputs, task.outputs) {
t.Fatalf("justice txn output mismatch, want: %v,\ngot: %v",
spew.Sdump(expOutputs), spew.Sdump(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.expSweepScript, test.signer,
)
if err != nil {
t.Fatalf("unable to craft session payload: %v", err)
}
// Verify that the breach hint matches the breach txid's prefix.
breachTxID := test.breachInfo.BreachTransaction.TxHash()
expHint := wtdb.NewBreachHintFromHash(&breachTxID)
if hint != expHint {
t.Fatalf("breach hint mismatch, want: %x, got: %v",
expHint, hint)
}
// Decrypt the return blob to obtain the JusticeKit containing its
// contents.
jKit, err := blob.Decrypt(breachTxID[:], encBlob, policy.BlobType)
if err != nil {
t.Fatalf("unable to decrypt blob: %v", err)
}
keyRing := test.breachInfo.KeyRing
expToLocalPK := keyRing.DelayKey.SerializeCompressed()
expRevPK := keyRing.RevocationKey.SerializeCompressed()
expToRemotePK := keyRing.NoDelayKey.SerializeCompressed()
// Assert that the blob contained the serialized revocation and to-local
// pubkeys.
if !bytes.Equal(jKit.RevocationPubKey[:], expRevPK) {
t.Fatalf("revocation pk mismatch, want: %x, got: %x",
expRevPK, jKit.RevocationPubKey[:])
}
if !bytes.Equal(jKit.LocalDelayPubKey[:], expToLocalPK) {
t.Fatalf("revocation pk mismatch, want: %x, got: %x",
expToLocalPK, jKit.LocalDelayPubKey[:])
}
// Determine if the breach transaction has a to-remote output and/or
// to-local output to spend from. Note the seemingly-reversed
// nomenclature.
hasToRemote := test.breachInfo.LocalOutputSignDesc != nil
hasToLocal := test.breachInfo.RemoteOutputSignDesc != nil
// If the to-remote output is present, assert that the to-remote public
// key was included in the blob.
if hasToRemote &&
!bytes.Equal(jKit.CommitToRemotePubKey[:], expToRemotePK) {
t.Fatalf("mismatch to-remote pubkey, want: %x, got: %x",
expToRemotePK, jKit.CommitToRemotePubKey)
}
// Otherwise if the to-local output is not present, assert that a blank
// public key was inserted.
if !hasToRemote &&
!bytes.Equal(jKit.CommitToRemotePubKey[:], zeroPK[:]) {
t.Fatalf("mismatch to-remote pubkey, want: %x, got: %x",
zeroPK, jKit.CommitToRemotePubKey)
}
// Assert that the CSV is encoded in the blob.
if jKit.CSVDelay != test.breachInfo.RemoteDelay {
t.Fatalf("mismatch remote delay, want: %d, got: %v",
test.breachInfo.RemoteDelay, jKit.CSVDelay)
}
// Assert that the sweep pkscript is included.
if !bytes.Equal(jKit.SweepAddress, test.expSweepScript) {
t.Fatalf("sweep pkscript mismatch, want: %x, got: %x",
test.expSweepScript, jKit.SweepAddress)
}
// Finally, verify that the signatures are encoded in the justice kit.
// We don't validate the actual signatures produced here, since at the
// moment, it is tested indirectly by other packages and integration
// tests.
// TODO(conner): include signature validation checks
emptyToLocalSig := bytes.Equal(jKit.CommitToLocalSig[:], zeroSig[:])
switch {
case hasToLocal && emptyToLocalSig:
t.Fatalf("to-local signature should not be empty")
case !hasToLocal && !emptyToLocalSig:
t.Fatalf("to-local signature should be empty")
}
emptyToRemoteSig := bytes.Equal(jKit.CommitToRemoteSig[:], zeroSig[:])
switch {
case hasToRemote && emptyToRemoteSig:
t.Fatalf("to-remote signature should not be empty")
case !hasToRemote && !emptyToRemoteSig:
t.Fatalf("to-remote signature should be empty")
}
}