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) +}