From 22d98ca6d530e29d75fa3074cefc87d0802a24d0 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 13 Apr 2022 22:33:07 +0800 Subject: [PATCH] multi: use new revocation log when creating breach retribution This commit changes the `NewBreachRetribution` to use the new revocation log format, while maintaining the compatibilty to use an older revocation log format. Unit tests have been added to make sure a breach retribution can be created in both log formats. This also means the watch tower needs to pass the relevant commit tx at its backup height when creating the breach retribution during backing up. This is achieved by recording the current remote commitment state before advancing the remote commitment chain. --- contractcourt/breacharbiter_test.go | 4 +- contractcourt/chain_watcher.go | 1 + htlcswitch/link.go | 16 +- lnwallet/channel.go | 410 ++++++++++++++++++++-------- lnwallet/channel_test.go | 389 +++++++++++++++++++++++++- 5 files changed, 693 insertions(+), 127 deletions(-) diff --git a/contractcourt/breacharbiter_test.go b/contractcourt/breacharbiter_test.go index 4c8863ded..63161d6e5 100644 --- a/contractcourt/breacharbiter_test.go +++ b/contractcourt/breacharbiter_test.go @@ -1621,7 +1621,7 @@ func testBreachSpends(t *testing.T, test breachTest) { // Notify the breach arbiter about the breach. retribution, err := lnwallet.NewBreachRetribution( - alice.State(), height, 1, + alice.State(), height, 1, forceCloseTx, ) if err != nil { t.Fatalf("unable to create breach retribution: %v", err) @@ -1837,7 +1837,7 @@ func TestBreachDelayedJusticeConfirmation(t *testing.T) { // Notify the breach arbiter about the breach. retribution, err := lnwallet.NewBreachRetribution( - alice.State(), height, uint32(blockHeight), + alice.State(), height, uint32(blockHeight), forceCloseTx, ) if err != nil { t.Fatalf("unable to create breach retribution: %v", err) diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go index f9d919023..6ba4c2795 100644 --- a/contractcourt/chain_watcher.go +++ b/contractcourt/chain_watcher.go @@ -794,6 +794,7 @@ func (c *chainWatcher) handlePossibleBreach(commitSpend *chainntnfs.SpendDetail, spendHeight := uint32(commitSpend.SpendingHeight) retribution, err := lnwallet.NewBreachRetribution( c.cfg.chanState, broadcastStateNum, spendHeight, + commitSpend.SpendingTx, ) switch { diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 9f2cfc494..f8623eadb 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -1897,9 +1897,16 @@ func (l *channelLink) handleUpstreamMsg(msg lnwire.Message) { // We've received a revocation from the remote chain, if valid, // this moves the remote chain forward, and expands our // revocation window. - fwdPkg, adds, settleFails, remoteHTLCs, err := l.channel.ReceiveRevocation( - msg, - ) + // + // Before advancing our remote chain, we will record the + // current commit tx, which is used by the TowerClient to + // create backups. + oldCommitTx := l.channel.State().RemoteCommitment.CommitTx + + // We now process the message and advance our remote commit + // chain. + fwdPkg, adds, settleFails, remoteHTLCs, err := l.channel. + ReceiveRevocation(msg) if err != nil { // TODO(halseth): force close? l.fail(LinkFailureError{code: ErrInvalidRevocation}, @@ -1928,10 +1935,13 @@ func (l *channelLink) handleUpstreamMsg(msg lnwire.Message) { } // If we have a tower client for this channel type, we'll + // create a backup for the current state. if l.cfg.TowerClient != nil { state := l.channel.State() breachInfo, err := lnwallet.NewBreachRetribution( state, state.RemoteCommitment.CommitHeight-1, 0, + // OldCommitTx is the breaching tx at height-1. + oldCommitTx, ) if err != nil { l.fail(LinkFailureError{code: ErrInternalError}, diff --git a/lnwallet/channel.go b/lnwallet/channel.go index b151b9efa..d4b63ddcc 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -96,6 +96,17 @@ var ( // both parties can retrieve their funds. ErrCommitSyncRemoteDataLoss = fmt.Errorf("possible remote commitment " + "state data loss") + + // ErrNoRevocationLogFound is returned when both the returned logs are + // nil from querying the revocation log bucket. In theory this should + // never happen as the query will return `ErrLogEntryNotFound`, yet + // we'd still perform a sanity check to make sure at least one of the + // logs is non-nil. + ErrNoRevocationLogFound = errors.New("no revocation log found") + + // ErrOutputIndexOutOfRange is returned when an output index is greater + // than or equal to the length of a given transaction's outputs. + ErrOutputIndexOutOfRange = errors.New("output index is out of range") ) // ErrCommitSyncLocalDataLoss is returned in the case that we receive a valid @@ -2278,16 +2289,22 @@ type BreachRetribution struct { // passed channel, at a particular revoked state number, and one which targets // the passed commitment transaction. func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, - breachHeight uint32) (*BreachRetribution, error) { + breachHeight uint32, spendTx *wire.MsgTx) (*BreachRetribution, error) { // Query the on-disk revocation log for the snapshot which was recorded - // at this particular state num. - _, revokedSnapshot, err := chanState.FindPreviousState(stateNum) + // at this particular state num. Based on whether a legacy revocation + // log is returned or not, we will process them differently. + revokedLog, revokedLogLegacy, err := chanState.FindPreviousState( + stateNum, + ) if err != nil { return nil, err } - commitHash := revokedSnapshot.CommitTx.TxHash() + // Sanity check that at least one of the logs is returned. + if revokedLog == nil && revokedLogLegacy == nil { + return nil, ErrNoRevocationLogFound + } // With the state number broadcast known, we can now derive/restore the // proper revocation preimage necessary to sweep the remote party's @@ -2310,22 +2327,14 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, // Next, reconstruct the scripts as they were present at this state // number so we can have the proper witness script to sign and include // within the final witness. - theirDelay := uint32(chanState.RemoteChanCfg.CsvDelay) - isRemoteInitiator := !chanState.IsInitiator var leaseExpiry uint32 if chanState.ChanType.HasLeaseExpiration() { leaseExpiry = chanState.ThawHeight } - theirScript, err := CommitScriptToSelf( - chanState.ChanType, isRemoteInitiator, keyRing.ToLocalKey, - keyRing.RevocationKey, theirDelay, leaseExpiry, - ) - if err != nil { - return nil, err - } - // Since it is the remote breach we are reconstructing, the output going - // to us will be a to-remote script with our local params. + // Since it is the remote breach we are reconstructing, the output + // going to us will be a to-remote script with our local params. + isRemoteInitiator := !chanState.IsInitiator ourScript, ourDelay, err := CommitScriptToRemote( chanState.ChanType, isRemoteInitiator, keyRing.ToRemoteKey, leaseExpiry, @@ -2334,15 +2343,248 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, return nil, err } - // In order to fully populate the breach retribution struct, we'll need - // to find the exact index of the commitment outputs. + theirDelay := uint32(chanState.RemoteChanCfg.CsvDelay) + theirScript, err := CommitScriptToSelf( + chanState.ChanType, isRemoteInitiator, keyRing.ToLocalKey, + keyRing.RevocationKey, theirDelay, leaseExpiry, + ) + if err != nil { + return nil, err + } + + // Define an empty breach retribution that will be overwritten based on + // different version of the revocation log found. + var br *BreachRetribution + + // Define our and their amounts, that will be overwritten below. + var ourAmt, theirAmt int64 + + // If the returned *RevocationLog is non-nil, use it to derive the info + // we need. + if revokedLog != nil { + br, ourAmt, theirAmt, err = createBreachRetribution( + revokedLog, spendTx, chanState, keyRing, + commitmentSecret, leaseExpiry, + ) + if err != nil { + return nil, err + } + } else { + // The returned revocation log is in legacy format, which is a + // *ChannelCommitment. + // + // NOTE: this branch is kept for compatibility such that for + // old nodes which refuse to migrate the legacy revocation log + // data can still function. This branch can be deleted once we + // are confident that no legacy format is in use. + br, ourAmt, theirAmt, err = createBreachRetributionLegacy( + revokedLogLegacy, chanState, keyRing, commitmentSecret, + ourScript, theirScript, leaseExpiry, + ) + if err != nil { + return nil, err + } + } + + // Conditionally instantiate a sign descriptor for each of the + // commitment outputs. If either is considered dust using the remote + // party's dust limit, the respective sign descriptor will be nil. + // + // If our balance exceeds the remote party's dust limit, instantiate + // the sign descriptor for our output. + if ourAmt >= int64(chanState.RemoteChanCfg.DustLimit) { + br.LocalOutputSignDesc = &input.SignDescriptor{ + SingleTweak: keyRing.LocalCommitKeyTweak, + KeyDesc: chanState.LocalChanCfg.PaymentBasePoint, + WitnessScript: ourScript.WitnessScript, + Output: &wire.TxOut{ + PkScript: ourScript.PkScript, + Value: ourAmt, + }, + HashType: txscript.SigHashAll, + } + } + + // Similarly, if their balance exceeds the remote party's dust limit, + // assemble the sign descriptor for their output, which we can sweep. + if theirAmt >= int64(chanState.RemoteChanCfg.DustLimit) { + br.RemoteOutputSignDesc = &input.SignDescriptor{ + KeyDesc: chanState.LocalChanCfg. + RevocationBasePoint, + DoubleTweak: commitmentSecret, + WitnessScript: theirScript.WitnessScript, + Output: &wire.TxOut{ + PkScript: theirScript.PkScript, + Value: theirAmt, + }, + HashType: txscript.SigHashAll, + } + } + + // Finally, with all the necessary data constructed, we can pad the + // BreachRetribution struct which houses all the data necessary to + // swiftly bring justice to the cheating remote party. + br.BreachHeight = breachHeight + br.RevokedStateNum = stateNum + br.LocalDelay = ourDelay + br.RemoteDelay = theirDelay + + return br, nil +} + +// createHtlcRetribution is a helper function to construct an HtlcRetribution +// based on the passed params. +func createHtlcRetribution(chanState *channeldb.OpenChannel, + keyRing *CommitmentKeyRing, commitHash chainhash.Hash, + commitmentSecret *btcec.PrivateKey, leaseExpiry uint32, + htlc *channeldb.HTLCEntry) (HtlcRetribution, error) { + + var emptyRetribution HtlcRetribution + + theirDelay := uint32(chanState.RemoteChanCfg.CsvDelay) + isRemoteInitiator := !chanState.IsInitiator + + // We'll generate the original second level witness script now, as + // we'll need it if we're revoking an HTLC output on the remote + // commitment transaction, and *they* go to the second level. + secondLevelScript, err := SecondLevelHtlcScript( + chanState.ChanType, isRemoteInitiator, + keyRing.RevocationKey, keyRing.ToLocalKey, theirDelay, + leaseExpiry, + ) + if err != nil { + return emptyRetribution, err + } + + // If this is an incoming HTLC, then this means that they were the + // sender of the HTLC (relative to us). So we'll re-generate the sender + // HTLC script. Otherwise, is this was an outgoing HTLC that we sent, + // then from the PoV of the remote commitment state, they're the + // receiver of this HTLC. + htlcPkScript, htlcWitnessScript, err := genHtlcScript( + chanState.ChanType, htlc.Incoming, false, + htlc.RefundTimeout, htlc.RHash, keyRing, + ) + if err != nil { + return emptyRetribution, err + } + + return HtlcRetribution{ + SignDesc: input.SignDescriptor{ + KeyDesc: chanState.LocalChanCfg. + RevocationBasePoint, + DoubleTweak: commitmentSecret, + WitnessScript: htlcWitnessScript, + Output: &wire.TxOut{ + PkScript: htlcPkScript, + Value: int64(htlc.Amt), + }, + HashType: txscript.SigHashAll, + }, + OutPoint: wire.OutPoint{ + Hash: commitHash, + Index: uint32(htlc.OutputIndex), + }, + SecondLevelWitnessScript: secondLevelScript.WitnessScript, + IsIncoming: htlc.Incoming, + }, nil +} + +// createBreachRetribution creates a partially initiated BreachRetribution +// using a RevocationLog. Returns the constructed retribution, our amount, +// their amount, and a possible non-nil error. +func createBreachRetribution(revokedLog *channeldb.RevocationLog, + spendTx *wire.MsgTx, chanState *channeldb.OpenChannel, + keyRing *CommitmentKeyRing, commitmentSecret *btcec.PrivateKey, + leaseExpiry uint32) (*BreachRetribution, int64, int64, error) { + + commitHash := revokedLog.CommitTxHash + + // Create the htlc retributions. + htlcRetributions := make([]HtlcRetribution, len(revokedLog.HTLCEntries)) + for i, htlc := range revokedLog.HTLCEntries { + hr, err := createHtlcRetribution( + chanState, keyRing, commitHash, + commitmentSecret, leaseExpiry, htlc, + ) + if err != nil { + return nil, 0, 0, err + } + htlcRetributions[i] = hr + } + + var ourAmt, theirAmt int64 + + // Construct the our outpoint. + ourOutpoint := wire.OutPoint{ + Hash: commitHash, + } + if revokedLog.OurOutputIndex != channeldb.OutputIndexEmpty { + ourOutpoint.Index = uint32(revokedLog.OurOutputIndex) + + // Sanity check that OurOutputIndex is within range. + if int(ourOutpoint.Index) >= len(spendTx.TxOut) { + return nil, 0, 0, fmt.Errorf("%w: ours=%v, "+ + "len(TxOut)=%v", ErrOutputIndexOutOfRange, + ourOutpoint.Index, len(spendTx.TxOut), + ) + } + // Read the amounts from the breach transaction. + // + // NOTE: ourAmt here includes commit fee and anchor amount(if + // enabled). + ourAmt = spendTx.TxOut[ourOutpoint.Index].Value + } + + // Construct the their outpoint. + theirOutpoint := wire.OutPoint{ + Hash: commitHash, + } + if revokedLog.TheirOutputIndex != channeldb.OutputIndexEmpty { + theirOutpoint.Index = uint32(revokedLog.TheirOutputIndex) + + // Sanity check that TheirOutputIndex is within range. + if int(revokedLog.TheirOutputIndex) >= len(spendTx.TxOut) { + return nil, 0, 0, fmt.Errorf("%w: theirs=%v, "+ + "len(TxOut)=%v", ErrOutputIndexOutOfRange, + revokedLog.TheirOutputIndex, len(spendTx.TxOut), + ) + } + + // Read the amounts from the breach transaction. + theirAmt = spendTx.TxOut[theirOutpoint.Index].Value + } + + return &BreachRetribution{ + BreachTxHash: commitHash, + ChainHash: chanState.ChainHash, + LocalOutpoint: ourOutpoint, + RemoteOutpoint: theirOutpoint, + HtlcRetributions: htlcRetributions, + KeyRing: keyRing, + }, ourAmt, theirAmt, nil +} + +// createBreachRetributionLegacy creates a partially initiated +// BreachRetribution using a ChannelCommitment. Returns the constructed +// retribution, our amount, their amount, and a possible non-nil error. +func createBreachRetributionLegacy(revokedLog *channeldb.ChannelCommitment, + chanState *channeldb.OpenChannel, keyRing *CommitmentKeyRing, + commitmentSecret *btcec.PrivateKey, + ourScript, theirScript *ScriptInfo, + leaseExpiry uint32) (*BreachRetribution, int64, int64, error) { + + commitHash := revokedLog.CommitTx.TxHash() ourOutpoint := wire.OutPoint{ Hash: commitHash, } theirOutpoint := wire.OutPoint{ Hash: commitHash, } - for i, txOut := range revokedSnapshot.CommitTx.TxOut { + + // In order to fully populate the breach retribution struct, we'll need + // to find the exact index of the commitment outputs. + for i, txOut := range revokedLog.CommitTx.TxOut { switch { case bytes.Equal(txOut.PkScript, ourScript.PkScript): ourOutpoint.Index = uint32(i) @@ -2351,126 +2593,52 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, } } - // Conditionally instantiate a sign descriptor for each of the - // commitment outputs. If either is considered dust using the remote - // party's dust limit, the respective sign descriptor will be nil. - var ( - ourSignDesc *input.SignDescriptor - theirSignDesc *input.SignDescriptor - ) - - // Compute the balances in satoshis. - ourAmt := revokedSnapshot.LocalBalance.ToSatoshis() - theirAmt := revokedSnapshot.RemoteBalance.ToSatoshis() - - // If our balance exceeds the remote party's dust limit, instantiate - // the sign descriptor for our output. - if ourAmt >= chanState.RemoteChanCfg.DustLimit { - ourSignDesc = &input.SignDescriptor{ - SingleTweak: keyRing.LocalCommitKeyTweak, - KeyDesc: chanState.LocalChanCfg.PaymentBasePoint, - WitnessScript: ourScript.WitnessScript, - Output: &wire.TxOut{ - PkScript: ourScript.PkScript, - Value: int64(ourAmt), - }, - HashType: txscript.SigHashAll, - } - } - - // Similarly, if their balance exceeds the remote party's dust limit, - // assemble the sign descriptor for their output, which we can sweep. - if theirAmt >= chanState.RemoteChanCfg.DustLimit { - theirSignDesc = &input.SignDescriptor{ - KeyDesc: chanState.LocalChanCfg.RevocationBasePoint, - DoubleTweak: commitmentSecret, - WitnessScript: theirScript.WitnessScript, - Output: &wire.TxOut{ - PkScript: theirScript.PkScript, - Value: int64(theirAmt), - }, - HashType: txscript.SigHashAll, - } - } - // With the commitment outputs located, we'll now generate all the // retribution structs for each of the HTLC transactions active on the // remote commitment transaction. - htlcRetributions := make([]HtlcRetribution, 0, len(revokedSnapshot.Htlcs)) - for _, htlc := range revokedSnapshot.Htlcs { + htlcRetributions := make([]HtlcRetribution, len(revokedLog.Htlcs)) + for i, htlc := range revokedLog.Htlcs { // If the HTLC is dust, then we'll skip it as it doesn't have // an output on the commitment transaction. if HtlcIsDust( chanState.ChanType, htlc.Incoming, false, - chainfee.SatPerKWeight(revokedSnapshot.FeePerKw), - htlc.Amt.ToSatoshis(), chanState.RemoteChanCfg.DustLimit, + chainfee.SatPerKWeight(revokedLog.FeePerKw), + htlc.Amt.ToSatoshis(), + chanState.RemoteChanCfg.DustLimit, ) { + continue } - // We'll generate the original second level witness script now, - // as we'll need it if we're revoking an HTLC output on the - // remote commitment transaction, and *they* go to the second - // level. - secondLevelScript, err := SecondLevelHtlcScript( - chanState.ChanType, isRemoteInitiator, - keyRing.RevocationKey, keyRing.ToLocalKey, theirDelay, - leaseExpiry, + entry := &channeldb.HTLCEntry{ + RHash: htlc.RHash, + RefundTimeout: htlc.RefundTimeout, + OutputIndex: uint16(htlc.OutputIndex), + Incoming: htlc.Incoming, + Amt: htlc.Amt.ToSatoshis(), + } + hr, err := createHtlcRetribution( + chanState, keyRing, commitHash, + commitmentSecret, leaseExpiry, entry, ) if err != nil { - return nil, err + return nil, 0, 0, err } - - // If this is an incoming HTLC, then this means that they were - // the sender of the HTLC (relative to us). So we'll - // re-generate the sender HTLC script. Otherwise, is this was - // an outgoing HTLC that we sent, then from the PoV of the - // remote commitment state, they're the receiver of this HTLC. - htlcPkScript, htlcWitnessScript, err := genHtlcScript( - chanState.ChanType, htlc.Incoming, false, - htlc.RefundTimeout, htlc.RHash, keyRing, - ) - if err != nil { - return nil, err - } - - htlcRetributions = append(htlcRetributions, HtlcRetribution{ - SignDesc: input.SignDescriptor{ - KeyDesc: chanState.LocalChanCfg.RevocationBasePoint, - DoubleTweak: commitmentSecret, - WitnessScript: htlcWitnessScript, - Output: &wire.TxOut{ - PkScript: htlcPkScript, - Value: int64(htlc.Amt.ToSatoshis()), - }, - HashType: txscript.SigHashAll, - }, - OutPoint: wire.OutPoint{ - Hash: commitHash, - Index: uint32(htlc.OutputIndex), - }, - SecondLevelWitnessScript: secondLevelScript.WitnessScript, - IsIncoming: htlc.Incoming, - }) + htlcRetributions[i] = hr } - // Finally, with all the necessary data constructed, we can create the - // BreachRetribution struct which houses all the data necessary to - // swiftly bring justice to the cheating remote party. + // Compute the balances in satoshis. + ourAmt := int64(revokedLog.LocalBalance.ToSatoshis()) + theirAmt := int64(revokedLog.RemoteBalance.ToSatoshis()) + return &BreachRetribution{ - BreachTxHash: commitHash, - ChainHash: chanState.ChainHash, - BreachHeight: breachHeight, - RevokedStateNum: stateNum, - LocalOutpoint: ourOutpoint, - LocalOutputSignDesc: ourSignDesc, - LocalDelay: ourDelay, - RemoteOutpoint: theirOutpoint, - RemoteOutputSignDesc: theirSignDesc, - RemoteDelay: theirDelay, - HtlcRetributions: htlcRetributions, - KeyRing: keyRing, - }, nil + BreachTxHash: commitHash, + ChainHash: chanState.ChainHash, + LocalOutpoint: ourOutpoint, + RemoteOutpoint: theirOutpoint, + HtlcRetributions: htlcRetributions, + KeyRing: keyRing, + }, ourAmt, theirAmt, nil } // HtlcIsDust determines if an HTLC output is dust or not depending on two diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go index 4962c61d6..812eb4b0e 100644 --- a/lnwallet/channel_test.go +++ b/lnwallet/channel_test.go @@ -14,6 +14,7 @@ import ( "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/davecgh/go-spew/spew" @@ -7106,8 +7107,9 @@ func TestNewBreachRetributionSkipsDustHtlcs(t *testing.T) { // At this point, we'll now simulate a contract breach by Bob using the // NewBreachRetribution method. + breachTx := aliceChannel.channelState.RemoteCommitment.CommitTx breachRet, err := NewBreachRetribution( - aliceChannel.channelState, revokedStateNum, 100, + aliceChannel.channelState, revokedStateNum, 100, breachTx, ) if err != nil { t.Fatalf("unable to create breach retribution: %v", err) @@ -10249,3 +10251,388 @@ func testGetDustSum(t *testing.T, chantype channeldb.ChannelType) { checkDust(bobChannel, htlc2Amt+htlc3Amt, htlc2Amt+htlc3Amt) } } + +// deriveDummyRetributionParams is a helper function that derives a list of +// dummy params to assist retribution creation related tests. +func deriveDummyRetributionParams(chanState *channeldb.OpenChannel) (uint32, + *CommitmentKeyRing, chainhash.Hash) { + + config := chanState.RemoteChanCfg + commitHash := chanState.RemoteCommitment.CommitTx.TxHash() + keyRing := DeriveCommitmentKeys( + config.RevocationBasePoint.PubKey, false, chanState.ChanType, + &chanState.LocalChanCfg, &chanState.RemoteChanCfg, + ) + leaseExpiry := chanState.ThawHeight + return leaseExpiry, keyRing, commitHash +} + +// TestCreateHtlcRetribution checks that `createHtlcRetribution` behaves as +// epxected. +func TestCreateHtlcRetribution(t *testing.T) { + t.Parallel() + + // Create a dummy private key and an HTLC amount for testing. + dummyPrivate, _ := btcec.PrivKeyFromBytes([]byte{1}) + testAmt := btcutil.Amount(100) + + // Create a test channel. + aliceChannel, _, cleanUp, err := CreateTestChannels( + channeldb.ZeroHtlcTxFeeBit, + ) + require.NoError(t, err) + defer cleanUp() + + // Prepare the params needed to call the function. Note that the values + // here are not necessary "cryptography-correct", we just use them to + // construct the htlc retribution. + leaseExpiry, keyRing, commitHash := deriveDummyRetributionParams( + aliceChannel.channelState, + ) + htlc := &channeldb.HTLCEntry{ + Amt: testAmt, + Incoming: true, + OutputIndex: 1, + } + + // Create the htlc retribution. + hr, err := createHtlcRetribution( + aliceChannel.channelState, keyRing, commitHash, + dummyPrivate, leaseExpiry, htlc, + ) + // Expect no error. + require.NoError(t, err) + + // Check the fields have expected values. + require.EqualValues(t, testAmt, hr.SignDesc.Output.Value) + require.Equal(t, commitHash, hr.OutPoint.Hash) + require.EqualValues(t, htlc.OutputIndex, hr.OutPoint.Index) + require.Equal(t, htlc.Incoming, hr.IsIncoming) +} + +// TestCreateBreachRetribution checks that `createBreachRetribution` behaves as +// epxected. +func TestCreateBreachRetribution(t *testing.T) { + t.Parallel() + + // Create dummy values for the test. + dummyPrivate, _ := btcec.PrivKeyFromBytes([]byte{1}) + testAmt := int64(100) + ourAmt := int64(1000) + theirAmt := int64(2000) + localIndex := uint32(0) + remoteIndex := uint32(1) + htlcIndex := uint32(2) + + // Create a dummy breach tx, which has our output located at index 0 + // and theirs at 1. + spendTx := &wire.MsgTx{ + TxOut: []*wire.TxOut{ + {Value: ourAmt}, + {Value: theirAmt}, + {Value: testAmt}, + }, + } + + // Create a test channel. + aliceChannel, _, cleanUp, err := CreateTestChannels( + channeldb.ZeroHtlcTxFeeBit, + ) + require.NoError(t, err) + defer cleanUp() + + // Prepare the params needed to call the function. Note that the values + // here are not necessary "cryptography-correct", we just use them to + // construct the retribution. + leaseExpiry, keyRing, commitHash := deriveDummyRetributionParams( + aliceChannel.channelState, + ) + htlc := &channeldb.HTLCEntry{ + Amt: btcutil.Amount(testAmt), + Incoming: true, + OutputIndex: uint16(htlcIndex), + } + + // Create a dummy revocation log. + revokedLog := channeldb.RevocationLog{ + CommitTxHash: commitHash, + OurOutputIndex: uint16(localIndex), + TheirOutputIndex: uint16(remoteIndex), + HTLCEntries: []*channeldb.HTLCEntry{htlc}, + } + + // Create a log with an empty local output index. + revokedLogNoLocal := revokedLog + revokedLogNoLocal.OurOutputIndex = channeldb.OutputIndexEmpty + + // Create a log with an empty remote output index. + revokedLogNoRemote := revokedLog + revokedLogNoRemote.TheirOutputIndex = channeldb.OutputIndexEmpty + + testCases := []struct { + name string + revocationLog *channeldb.RevocationLog + expectedErr error + expectedOurAmt int64 + expectedTheirAmt int64 + }{ + { + name: "create retribution successfully", + revocationLog: &revokedLog, + expectedErr: nil, + expectedOurAmt: ourAmt, + expectedTheirAmt: theirAmt, + }, + { + name: "fail due to our index too big", + revocationLog: &channeldb.RevocationLog{ + OurOutputIndex: uint16(htlcIndex + 1), + }, + expectedErr: ErrOutputIndexOutOfRange, + }, + { + name: "fail due to their index too big", + revocationLog: &channeldb.RevocationLog{ + TheirOutputIndex: uint16(htlcIndex + 1), + }, + expectedErr: ErrOutputIndexOutOfRange, + }, + { + name: "empty local output index", + revocationLog: &revokedLogNoLocal, + expectedErr: nil, + expectedOurAmt: 0, + expectedTheirAmt: theirAmt, + }, + { + name: "empty remote output index", + revocationLog: &revokedLogNoRemote, + expectedErr: nil, + expectedOurAmt: ourAmt, + expectedTheirAmt: 0, + }, + } + + // assertRetribution is a helper closure that checks a given breach + // retribution has the expected values on certain fields. + assertRetribution := func(br *BreachRetribution, our, their int64) { + chainHash := aliceChannel.channelState.ChainHash + require.Equal(t, commitHash, br.BreachTxHash) + require.Equal(t, chainHash, br.ChainHash) + + // Construct local outpoint, we only have the index when the + // amount is not zero. + local := wire.OutPoint{ + Hash: commitHash, + } + if our != 0 { + local.Index = localIndex + } + + // Construct remote outpoint, we only have the index when the + // amount is not zero. + remote := wire.OutPoint{ + Hash: commitHash, + } + if their != 0 { + remote.Index = remoteIndex + } + + require.Equal(t, local, br.LocalOutpoint) + require.Equal(t, remote, br.RemoteOutpoint) + + for _, hr := range br.HtlcRetributions { + require.EqualValues(t, testAmt, + hr.SignDesc.Output.Value) + require.Equal(t, commitHash, hr.OutPoint.Hash) + require.EqualValues(t, htlcIndex, hr.OutPoint.Index) + require.Equal(t, htlc.Incoming, hr.IsIncoming) + } + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + br, our, their, err := createBreachRetribution( + tc.revocationLog, spendTx, + aliceChannel.channelState, keyRing, + dummyPrivate, leaseExpiry, + ) + + // Check the error if expected. + if tc.expectedErr != nil { + require.ErrorIs(t, err, tc.expectedErr) + } else { + // Otherwise we expect no error. + require.NoError(t, err) + + // Check the amounts and the contructed partial + // retribution are returned as expected. + require.Equal(t, tc.expectedOurAmt, our) + require.Equal(t, tc.expectedTheirAmt, their) + assertRetribution(br, our, their) + } + }) + } +} + +// TestCreateBreachRetributionLegacy checks that +// `createBreachRetributionLegacy` behaves as expected. +func TestCreateBreachRetributionLegacy(t *testing.T) { + t.Parallel() + + // Create dummy values for the test. + dummyPrivate, _ := btcec.PrivKeyFromBytes([]byte{1}) + + // Create a test channel. + aliceChannel, _, cleanUp, err := CreateTestChannels( + channeldb.ZeroHtlcTxFeeBit, + ) + require.NoError(t, err) + defer cleanUp() + + // Prepare the params needed to call the function. Note that the values + // here are not necessary "cryptography-correct", we just use them to + // construct the retribution. + leaseExpiry, keyRing, _ := deriveDummyRetributionParams( + aliceChannel.channelState, + ) + + // Use the remote commitment as our revocation log. + revokedLog := aliceChannel.channelState.RemoteCommitment + + ourOp := revokedLog.CommitTx.TxOut[0] + theirOp := revokedLog.CommitTx.TxOut[1] + + // Create the dummy scripts. + ourScript := &ScriptInfo{ + PkScript: ourOp.PkScript, + } + theirScript := &ScriptInfo{ + PkScript: theirOp.PkScript, + } + + // Create the breach retribution using the legacy format. + br, ourAmt, theirAmt, err := createBreachRetributionLegacy( + &revokedLog, aliceChannel.channelState, keyRing, + dummyPrivate, ourScript, theirScript, leaseExpiry, + ) + require.NoError(t, err) + + // Check the commitHash and chainHash. + commitHash := revokedLog.CommitTx.TxHash() + chainHash := aliceChannel.channelState.ChainHash + require.Equal(t, commitHash, br.BreachTxHash) + require.Equal(t, chainHash, br.ChainHash) + + // Check the outpoints. + local := wire.OutPoint{ + Hash: commitHash, + Index: 0, + } + remote := wire.OutPoint{ + Hash: commitHash, + Index: 1, + } + require.Equal(t, local, br.LocalOutpoint) + require.Equal(t, remote, br.RemoteOutpoint) + + // Validate the amounts, note that in the legacy format, our amount is + // not directly the amount found in the to local output. Rather, it's + // the local output value minus the commit fee and anchor value(if + // present). + require.EqualValues(t, revokedLog.LocalBalance.ToSatoshis(), ourAmt) + require.Equal(t, theirOp.Value, theirAmt) +} + +// TestNewBreachRetribution tests that the function `NewBreachRetribution` +// behaves as expected. +func TestNewBreachRetribution(t *testing.T) { + t.Run("non-anchor", func(t *testing.T) { + testNewBreachRetribution(t, channeldb.ZeroHtlcTxFeeBit) + }) + t.Run("anchor", func(t *testing.T) { + chanType := channeldb.SingleFunderTweaklessBit | + channeldb.AnchorOutputsBit + testNewBreachRetribution(t, chanType) + }) +} + +// testNewBreachRetribution takes a channel type and tests the function +// `NewBreachRetribution`. +func testNewBreachRetribution(t *testing.T, chanType channeldb.ChannelType) { + t.Parallel() + + aliceChannel, bobChannel, cleanUp, err := CreateTestChannels(chanType) + require.NoError(t, err) + defer cleanUp() + + breachHeight := uint32(101) + stateNum := uint64(0) + chainHash := aliceChannel.channelState.ChainHash + theirDelay := uint32(aliceChannel.channelState.RemoteChanCfg.CsvDelay) + breachTx := aliceChannel.channelState.RemoteCommitment.CommitTx + + // Create a breach retribution at height 0, which should give us an + // error as there are no past delta state saved as revocation logs yet. + _, err = NewBreachRetribution( + aliceChannel.channelState, stateNum, breachHeight, breachTx, + ) + require.ErrorIs(t, err, channeldb.ErrNoPastDeltas) + + // We now force a state transition which will give us a revocation log + // at height 0. + txid := aliceChannel.channelState.RemoteCommitment.CommitTx.TxHash() + err = ForceStateTransition(aliceChannel, bobChannel) + require.NoError(t, err) + + // assertRetribution is a helper closure that checks a given breach + // retribution has the expected values on certain fields. + assertRetribution := func(br *BreachRetribution, + localIndex, remoteIndex uint32) { + + require.Equal(t, txid, br.BreachTxHash) + require.Equal(t, chainHash, br.ChainHash) + require.Equal(t, breachHeight, br.BreachHeight) + require.Equal(t, stateNum, br.RevokedStateNum) + require.Equal(t, theirDelay, br.RemoteDelay) + + local := wire.OutPoint{ + Hash: txid, + Index: localIndex, + } + remote := wire.OutPoint{ + Hash: txid, + Index: remoteIndex, + } + + if chanType.HasAnchors() { + // For anchor channels, we expect the local delay to be + // 1 otherwise 0. + require.EqualValues(t, 1, br.LocalDelay) + } else { + require.Zero(t, br.LocalDelay) + } + + require.Equal(t, local, br.LocalOutpoint) + require.Equal(t, remote, br.RemoteOutpoint) + } + + // Create the retribution again and we should expect it to be created + // successfully. + br, err := NewBreachRetribution( + aliceChannel.channelState, stateNum, breachHeight, breachTx, + ) + require.NoError(t, err) + + // Check the retribution is as expected. + t.Log(spew.Sdump(breachTx)) + assertRetribution(br, 1, 0) + + // Create the retribution using a stateNum+1 and we should expect an + // error. + _, err = NewBreachRetribution( + aliceChannel.channelState, stateNum+1, breachHeight, breachTx, + ) + require.ErrorIs(t, err, channeldb.ErrLogEntryNotFound) +}