lnd.xprv/contractcourt/chain_watcher_test.go

628 lines
19 KiB
Go
Raw Permalink Normal View History

package contractcourt
import (
"bytes"
"crypto/sha256"
"fmt"
"testing"
"time"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lntest/mock"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwire"
)
// TestChainWatcherRemoteUnilateralClose tests that the chain watcher is able
// to properly detect a normal unilateral close by the remote node using their
// lowest commitment.
func TestChainWatcherRemoteUnilateralClose(t *testing.T) {
t.Parallel()
// First, we'll create two channels which already have established a
// commitment contract between themselves.
aliceChannel, bobChannel, cleanUp, err := lnwallet.CreateTestChannels(
channeldb.SingleFunderTweaklessBit,
)
if err != nil {
t.Fatalf("unable to create test channels: %v", err)
}
defer cleanUp()
// With the channels created, we'll now create a chain watcher instance
// which will be watching for any closes of Alice's channel.
aliceNotifier := &mock.ChainNotifier{
SpendChan: make(chan *chainntnfs.SpendDetail),
EpochChan: make(chan *chainntnfs.BlockEpoch),
ConfChan: make(chan *chainntnfs.TxConfirmation),
}
aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{
chanState: aliceChannel.State(),
notifier: aliceNotifier,
signer: aliceChannel.Signer,
extractStateNumHint: lnwallet.GetStateNumHint,
})
if err != nil {
t.Fatalf("unable to create chain watcher: %v", err)
}
err = aliceChainWatcher.Start()
if err != nil {
t.Fatalf("unable to start chain watcher: %v", err)
}
defer aliceChainWatcher.Stop()
// We'll request a new channel event subscription from Alice's chain
// watcher.
chanEvents := aliceChainWatcher.SubscribeChannelEvents()
// If we simulate an immediate broadcast of the current commitment by
// Bob, then the chain watcher should detect this case.
bobCommit := bobChannel.State().LocalCommitment.CommitTx
bobTxHash := bobCommit.TxHash()
bobSpend := &chainntnfs.SpendDetail{
SpenderTxHash: &bobTxHash,
SpendingTx: bobCommit,
}
aliceNotifier.SpendChan <- bobSpend
// We should get a new spend event over the remote unilateral close
// event channel.
multi: address lingering TODO by no longer wiping out local HTLCs on remote close In this commit, we fix a lingering TOOD statement in the channel arb. Before this commitment, we would simply wipe our our local HTLC set of the HTLC set that was on the remote commitment transaction on force close. This was incorrect as if our commitment transaction had an HTLC that the remote commitment didn't, then we would fail to cancel that back, and cause both channels to time out on chain. In order to remedy this, we introduce a new `HtlcSetKey` struct to track all 3 possible in-flight set of HTLCs: ours, theirs, and their pending. We also we start to tack on additional data to all the unilateral close messages we send to subscribers. This new data is the CommitSet, or the set of valid commitments at channel closure time. This new information will be used by the channel arb in an upcoming commit to ensure it will cancel back HTLCs in the case of split commitment state. Finally, we start to thread through an optional *CommitSet to the advanceState method. This additional information will give the channel arb addition information it needs to ensure it properly cancels back HTLCs that are about to time out or may time out depending on which commitment is played. Within the htlcswitch pakage, we modify the `SignNextCommitment` method to return the new set of pending HTLCs for the remote party's commitment transaction and `ReceiveRevocation` to return the latest set of commitment transactions on the remote party's commitment as well. This is a preparatory change which is part of a larger change to address a lingering TODO in the cnct. Additionally, rather than just send of the set of HTLCs after the we revoke, we'll also send of the set of HTLCs after the remote party revokes, and we create a pending commitment state for it.
2019-05-17 03:23:26 +03:00
var uniClose *RemoteUnilateralCloseInfo
select {
case uniClose = <-chanEvents.RemoteUnilateralClosure:
case <-time.After(time.Second * 15):
t.Fatalf("didn't receive unilateral close event")
}
// The unilateral close should have properly located Alice's output in
// the commitment transaction.
if uniClose.CommitResolution == nil {
t.Fatalf("unable to find alice's commit resolution")
}
}
func addFakeHTLC(t *testing.T, htlcAmount lnwire.MilliSatoshi, id uint64,
aliceChannel, bobChannel *lnwallet.LightningChannel) {
preimage := bytes.Repeat([]byte{byte(id)}, 32)
paymentHash := sha256.Sum256(preimage)
var returnPreimage [32]byte
copy(returnPreimage[:], preimage)
htlc := &lnwire.UpdateAddHTLC{
ID: uint64(id),
PaymentHash: paymentHash,
Amount: htlcAmount,
Expiry: uint32(5),
}
if _, err := aliceChannel.AddHTLC(htlc, nil); err != nil {
t.Fatalf("alice unable to add htlc: %v", err)
}
if _, err := bobChannel.ReceiveHTLC(htlc); err != nil {
t.Fatalf("bob unable to recv add htlc: %v", err)
}
}
// TestChainWatcherRemoteUnilateralClosePendingCommit tests that the chain
// watcher is able to properly detect a unilateral close wherein the remote
// node broadcasts their newly received commitment, without first revoking the
// old one.
func TestChainWatcherRemoteUnilateralClosePendingCommit(t *testing.T) {
t.Parallel()
// First, we'll create two channels which already have established a
// commitment contract between themselves.
aliceChannel, bobChannel, cleanUp, err := lnwallet.CreateTestChannels(
channeldb.SingleFunderTweaklessBit,
)
if err != nil {
t.Fatalf("unable to create test channels: %v", err)
}
defer cleanUp()
// With the channels created, we'll now create a chain watcher instance
// which will be watching for any closes of Alice's channel.
aliceNotifier := &mock.ChainNotifier{
SpendChan: make(chan *chainntnfs.SpendDetail),
EpochChan: make(chan *chainntnfs.BlockEpoch),
ConfChan: make(chan *chainntnfs.TxConfirmation),
}
aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{
chanState: aliceChannel.State(),
notifier: aliceNotifier,
signer: aliceChannel.Signer,
extractStateNumHint: lnwallet.GetStateNumHint,
})
if err != nil {
t.Fatalf("unable to create chain watcher: %v", err)
}
if err := aliceChainWatcher.Start(); err != nil {
t.Fatalf("unable to start chain watcher: %v", err)
}
defer aliceChainWatcher.Stop()
// We'll request a new channel event subscription from Alice's chain
// watcher.
chanEvents := aliceChainWatcher.SubscribeChannelEvents()
// Next, we'll create a fake HTLC just so we can advance Alice's
// channel state to a new pending commitment on her remote commit chain
// for Bob.
htlcAmount := lnwire.NewMSatFromSatoshis(20000)
addFakeHTLC(t, htlcAmount, 0, aliceChannel, bobChannel)
// With the HTLC added, we'll now manually initiate a state transition
// from Alice to Bob.
multi: address lingering TODO by no longer wiping out local HTLCs on remote close In this commit, we fix a lingering TOOD statement in the channel arb. Before this commitment, we would simply wipe our our local HTLC set of the HTLC set that was on the remote commitment transaction on force close. This was incorrect as if our commitment transaction had an HTLC that the remote commitment didn't, then we would fail to cancel that back, and cause both channels to time out on chain. In order to remedy this, we introduce a new `HtlcSetKey` struct to track all 3 possible in-flight set of HTLCs: ours, theirs, and their pending. We also we start to tack on additional data to all the unilateral close messages we send to subscribers. This new data is the CommitSet, or the set of valid commitments at channel closure time. This new information will be used by the channel arb in an upcoming commit to ensure it will cancel back HTLCs in the case of split commitment state. Finally, we start to thread through an optional *CommitSet to the advanceState method. This additional information will give the channel arb addition information it needs to ensure it properly cancels back HTLCs that are about to time out or may time out depending on which commitment is played. Within the htlcswitch pakage, we modify the `SignNextCommitment` method to return the new set of pending HTLCs for the remote party's commitment transaction and `ReceiveRevocation` to return the latest set of commitment transactions on the remote party's commitment as well. This is a preparatory change which is part of a larger change to address a lingering TODO in the cnct. Additionally, rather than just send of the set of HTLCs after the we revoke, we'll also send of the set of HTLCs after the remote party revokes, and we create a pending commitment state for it.
2019-05-17 03:23:26 +03:00
_, _, _, err = aliceChannel.SignNextCommitment()
if err != nil {
t.Fatal(err)
}
// At this point, we'll now Bob broadcasting this new pending unrevoked
// commitment.
bobPendingCommit, err := aliceChannel.State().RemoteCommitChainTip()
if err != nil {
t.Fatal(err)
}
// We'll craft a fake spend notification with Bob's actual commitment.
// The chain watcher should be able to detect that this is a pending
// commit broadcast based on the state hints in the commitment.
bobCommit := bobPendingCommit.Commitment.CommitTx
bobTxHash := bobCommit.TxHash()
bobSpend := &chainntnfs.SpendDetail{
SpenderTxHash: &bobTxHash,
SpendingTx: bobCommit,
}
aliceNotifier.SpendChan <- bobSpend
// We should get a new spend event over the remote unilateral close
// event channel.
multi: address lingering TODO by no longer wiping out local HTLCs on remote close In this commit, we fix a lingering TOOD statement in the channel arb. Before this commitment, we would simply wipe our our local HTLC set of the HTLC set that was on the remote commitment transaction on force close. This was incorrect as if our commitment transaction had an HTLC that the remote commitment didn't, then we would fail to cancel that back, and cause both channels to time out on chain. In order to remedy this, we introduce a new `HtlcSetKey` struct to track all 3 possible in-flight set of HTLCs: ours, theirs, and their pending. We also we start to tack on additional data to all the unilateral close messages we send to subscribers. This new data is the CommitSet, or the set of valid commitments at channel closure time. This new information will be used by the channel arb in an upcoming commit to ensure it will cancel back HTLCs in the case of split commitment state. Finally, we start to thread through an optional *CommitSet to the advanceState method. This additional information will give the channel arb addition information it needs to ensure it properly cancels back HTLCs that are about to time out or may time out depending on which commitment is played. Within the htlcswitch pakage, we modify the `SignNextCommitment` method to return the new set of pending HTLCs for the remote party's commitment transaction and `ReceiveRevocation` to return the latest set of commitment transactions on the remote party's commitment as well. This is a preparatory change which is part of a larger change to address a lingering TODO in the cnct. Additionally, rather than just send of the set of HTLCs after the we revoke, we'll also send of the set of HTLCs after the remote party revokes, and we create a pending commitment state for it.
2019-05-17 03:23:26 +03:00
var uniClose *RemoteUnilateralCloseInfo
select {
case uniClose = <-chanEvents.RemoteUnilateralClosure:
case <-time.After(time.Second * 15):
t.Fatalf("didn't receive unilateral close event")
}
// The unilateral close should have properly located Alice's output in
// the commitment transaction.
if uniClose.CommitResolution == nil {
t.Fatalf("unable to find alice's commit resolution")
}
}
2019-05-05 01:35:37 +03:00
// dlpTestCase is a special struct that we'll use to generate randomized test
// cases for the main TestChainWatcherDataLossProtect test. This struct has a
// special Generate method that will generate a random state number, and a
// broadcast state number which is greater than that state number.
type dlpTestCase struct {
BroadcastStateNum uint8
NumUpdates uint8
}
// executeStateTransitions execute the given number of state transitions.
// Copies of Alice's channel state before each transition (including initial
// state) are returned.
func executeStateTransitions(t *testing.T, htlcAmount lnwire.MilliSatoshi,
aliceChannel, bobChannel *lnwallet.LightningChannel,
numUpdates uint8) ([]*channeldb.OpenChannel, func(), error) {
// We'll make a copy of the channel state before each transition.
var (
chanStates []*channeldb.OpenChannel
cleanupFuncs []func()
)
cleanAll := func() {
for _, f := range cleanupFuncs {
f()
}
}
state, f, err := copyChannelState(aliceChannel.State())
if err != nil {
return nil, nil, err
}
chanStates = append(chanStates, state)
cleanupFuncs = append(cleanupFuncs, f)
for i := 0; i < int(numUpdates); i++ {
addFakeHTLC(
t, htlcAmount, uint64(i), aliceChannel, bobChannel,
)
err := lnwallet.ForceStateTransition(aliceChannel, bobChannel)
if err != nil {
cleanAll()
return nil, nil, err
}
state, f, err := copyChannelState(aliceChannel.State())
if err != nil {
cleanAll()
return nil, nil, err
}
chanStates = append(chanStates, state)
cleanupFuncs = append(cleanupFuncs, f)
}
return chanStates, cleanAll, nil
}
// TestChainWatcherDataLossProtect tests that if we've lost data (and are
// behind the remote node), then we'll properly detect this case and dispatch a
// remote force close using the obtained data loss commitment point.
func TestChainWatcherDataLossProtect(t *testing.T) {
t.Parallel()
// dlpScenario is our primary quick check testing function for this
// test as whole. It ensures that if the remote party broadcasts a
// commitment that is beyond our best known commitment for them, and
// they don't have a pending commitment (one we sent but which hasn't
// been revoked), then we'll properly detect this case, and execute the
// DLP protocol on our end.
//
// broadcastStateNum is the number that we'll trick Alice into thinking
// was broadcast, while numUpdates is the actual number of updates
// we'll execute. Both of these will be random 8-bit values generated
// by testing/quick.
dlpScenario := func(t *testing.T, testCase dlpTestCase) bool {
// First, we'll create two channels which already have
// established a commitment contract between themselves.
aliceChannel, bobChannel, cleanUp, err := lnwallet.CreateTestChannels(
channeldb.SingleFunderBit,
)
if err != nil {
t.Fatalf("unable to create test channels: %v", err)
}
defer cleanUp()
// Based on the number of random updates for this state, make a
// new HTLC to add to the commitment, and then lock in a state
// transition.
const htlcAmt = 1000
states, cleanStates, err := executeStateTransitions(
t, htlcAmt, aliceChannel, bobChannel,
testCase.BroadcastStateNum,
)
if err != nil {
t.Errorf("unable to trigger state "+
"transition: %v", err)
return false
}
defer cleanStates()
// We'll use the state this test case wants Alice to start at.
aliceChanState := states[testCase.NumUpdates]
// With the channels created, we'll now create a chain watcher
// instance which will be watching for any closes of Alice's
// channel.
aliceNotifier := &mock.ChainNotifier{
SpendChan: make(chan *chainntnfs.SpendDetail),
EpochChan: make(chan *chainntnfs.BlockEpoch),
ConfChan: make(chan *chainntnfs.TxConfirmation),
}
aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{
chanState: aliceChanState,
notifier: aliceNotifier,
signer: aliceChannel.Signer,
extractStateNumHint: func(*wire.MsgTx,
[lnwallet.StateHintSize]byte) uint64 {
// We'll return the "fake" broadcast commitment
// number so we can simulate broadcast of an
// arbitrary state.
return uint64(testCase.BroadcastStateNum)
},
})
if err != nil {
t.Fatalf("unable to create chain watcher: %v", err)
}
if err := aliceChainWatcher.Start(); err != nil {
t.Fatalf("unable to start chain watcher: %v", err)
}
defer aliceChainWatcher.Stop()
// We'll request a new channel event subscription from Alice's
// chain watcher so we can be notified of our fake close below.
chanEvents := aliceChainWatcher.SubscribeChannelEvents()
// Otherwise, we'll feed in this new state number as a response
// to the query, and insert the expected DLP commit point.
dlpPoint := aliceChannel.State().RemoteCurrentRevocation
err = aliceChanState.MarkDataLoss(dlpPoint)
if err != nil {
t.Errorf("unable to insert dlp point: %v", err)
return false
}
// Now we'll trigger the channel close event to trigger the
// scenario.
bobCommit := bobChannel.State().LocalCommitment.CommitTx
bobTxHash := bobCommit.TxHash()
bobSpend := &chainntnfs.SpendDetail{
SpenderTxHash: &bobTxHash,
SpendingTx: bobCommit,
}
aliceNotifier.SpendChan <- bobSpend
// We should get a new uni close resolution that indicates we
// processed the DLP scenario.
multi: address lingering TODO by no longer wiping out local HTLCs on remote close In this commit, we fix a lingering TOOD statement in the channel arb. Before this commitment, we would simply wipe our our local HTLC set of the HTLC set that was on the remote commitment transaction on force close. This was incorrect as if our commitment transaction had an HTLC that the remote commitment didn't, then we would fail to cancel that back, and cause both channels to time out on chain. In order to remedy this, we introduce a new `HtlcSetKey` struct to track all 3 possible in-flight set of HTLCs: ours, theirs, and their pending. We also we start to tack on additional data to all the unilateral close messages we send to subscribers. This new data is the CommitSet, or the set of valid commitments at channel closure time. This new information will be used by the channel arb in an upcoming commit to ensure it will cancel back HTLCs in the case of split commitment state. Finally, we start to thread through an optional *CommitSet to the advanceState method. This additional information will give the channel arb addition information it needs to ensure it properly cancels back HTLCs that are about to time out or may time out depending on which commitment is played. Within the htlcswitch pakage, we modify the `SignNextCommitment` method to return the new set of pending HTLCs for the remote party's commitment transaction and `ReceiveRevocation` to return the latest set of commitment transactions on the remote party's commitment as well. This is a preparatory change which is part of a larger change to address a lingering TODO in the cnct. Additionally, rather than just send of the set of HTLCs after the we revoke, we'll also send of the set of HTLCs after the remote party revokes, and we create a pending commitment state for it.
2019-05-17 03:23:26 +03:00
var uniClose *RemoteUnilateralCloseInfo
select {
case uniClose = <-chanEvents.RemoteUnilateralClosure:
// If we processed this as a DLP case, then the remote
// party's commitment should be blank, as we don't have
// this up to date state.
blankCommit := channeldb.ChannelCommitment{}
if uniClose.RemoteCommit.FeePerKw != blankCommit.FeePerKw {
t.Errorf("DLP path not executed")
return false
}
// The resolution should have also read the DLP point
// we stored above, and used that to derive their sweep
// key for this output.
sweepTweak := input.SingleTweakBytes(
dlpPoint,
aliceChannel.State().LocalChanCfg.PaymentBasePoint.PubKey,
)
commitResolution := uniClose.CommitResolution
resolutionTweak := commitResolution.SelfOutputSignDesc.SingleTweak
if !bytes.Equal(sweepTweak, resolutionTweak) {
t.Errorf("sweep key mismatch: expected %x got %x",
sweepTweak, resolutionTweak)
return false
}
return true
case <-time.After(time.Second * 5):
t.Errorf("didn't receive unilateral close event")
return false
}
}
testCases := []dlpTestCase{
// For our first scenario, we'll ensure that if we're on state 1,
// and the remote party broadcasts state 2 and we don't have a
// pending commit for them, then we'll properly detect this as a
// DLP scenario.
{
BroadcastStateNum: 2,
NumUpdates: 1,
},
// We've completed a single update, but the remote party broadcasts
// a state that's 5 states byeond our best known state. We've lost
// data, but only partially, so we should enter a DLP secnario.
{
BroadcastStateNum: 6,
NumUpdates: 1,
},
// Similar to the case above, but we've done more than one
// update.
{
BroadcastStateNum: 6,
NumUpdates: 3,
},
// We've done zero updates, but our channel peer broadcasts a
// state beyond our knowledge.
{
BroadcastStateNum: 10,
NumUpdates: 0,
},
}
for _, testCase := range testCases {
testName := fmt.Sprintf("num_updates=%v,broadcast_state_num=%v",
testCase.NumUpdates, testCase.BroadcastStateNum)
testCase := testCase
t.Run(testName, func(t *testing.T) {
t.Parallel()
if !dlpScenario(t, testCase) {
t.Fatalf("test %v failed", testName)
}
})
}
}
// TestChainWatcherLocalForceCloseDetect tests we're able to always detect our
// commitment output based on only the outputs present on the transaction.
func TestChainWatcherLocalForceCloseDetect(t *testing.T) {
t.Parallel()
// localForceCloseScenario is the primary test we'll use to execute our
// table driven tests. We'll assert that for any number of state
// updates, and if the commitment transaction has our output or not,
// we're able to properly detect a local force close.
localForceCloseScenario := func(t *testing.T, numUpdates, localState uint8,
remoteOutputOnly, localOutputOnly bool) bool {
// First, we'll create two channels which already have
// established a commitment contract between themselves.
aliceChannel, bobChannel, cleanUp, err := lnwallet.CreateTestChannels(
channeldb.SingleFunderBit,
)
if err != nil {
t.Fatalf("unable to create test channels: %v", err)
}
defer cleanUp()
// We'll execute a number of state transitions based on the
// randomly selected number from testing/quick. We do this to
// get more coverage of various state hint encodings beyond 0
// and 1.
const htlcAmt = 1000
states, cleanStates, err := executeStateTransitions(
t, htlcAmt, aliceChannel, bobChannel, numUpdates,
)
if err != nil {
t.Errorf("unable to trigger state "+
"transition: %v", err)
return false
}
defer cleanStates()
// We'll use the state this test case wants Alice to start at.
aliceChanState := states[localState]
// With the channels created, we'll now create a chain watcher
// instance which will be watching for any closes of Alice's
// channel.
aliceNotifier := &mock.ChainNotifier{
SpendChan: make(chan *chainntnfs.SpendDetail),
EpochChan: make(chan *chainntnfs.BlockEpoch),
ConfChan: make(chan *chainntnfs.TxConfirmation),
}
aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{
chanState: aliceChanState,
notifier: aliceNotifier,
signer: aliceChannel.Signer,
extractStateNumHint: lnwallet.GetStateNumHint,
})
if err != nil {
t.Fatalf("unable to create chain watcher: %v", err)
}
if err := aliceChainWatcher.Start(); err != nil {
t.Fatalf("unable to start chain watcher: %v", err)
}
defer aliceChainWatcher.Stop()
// We'll request a new channel event subscription from Alice's
// chain watcher so we can be notified of our fake close below.
chanEvents := aliceChainWatcher.SubscribeChannelEvents()
// Next, we'll obtain Alice's commitment transaction and
// trigger a force close. This should cause her to detect a
// local force close, and dispatch a local close event.
aliceCommit := aliceChannel.State().LocalCommitment.CommitTx
// Since this is Alice's commitment, her output is always first
// since she's the one creating the HTLCs (lower balance). In
// order to simulate the commitment only having the remote
// party's output, we'll remove Alice's output.
if remoteOutputOnly {
aliceCommit.TxOut = aliceCommit.TxOut[1:]
}
if localOutputOnly {
aliceCommit.TxOut = aliceCommit.TxOut[:1]
}
aliceTxHash := aliceCommit.TxHash()
aliceSpend := &chainntnfs.SpendDetail{
SpenderTxHash: &aliceTxHash,
SpendingTx: aliceCommit,
}
aliceNotifier.SpendChan <- aliceSpend
// We should get a local force close event from Alice as she
// should be able to detect the close based on the commitment
// outputs.
select {
case summary := <-chanEvents.LocalUnilateralClosure:
// Make sure we correctly extracted the commit
// resolution if we had a local output.
if remoteOutputOnly {
if summary.CommitResolution != nil {
t.Fatalf("expected no commit resolution")
}
} else {
if summary.CommitResolution == nil {
t.Fatalf("expected commit resolution")
}
}
return true
case <-time.After(time.Second * 5):
t.Errorf("didn't get local for close for state #%v",
numUpdates)
return false
}
}
// For our test cases, we'll ensure that we test having a remote output
// present and absent with non or some number of updates in the channel.
testCases := []struct {
numUpdates uint8
localState uint8
remoteOutputOnly bool
localOutputOnly bool
}{
{
numUpdates: 0,
localState: 0,
remoteOutputOnly: true,
},
{
numUpdates: 0,
localState: 0,
remoteOutputOnly: false,
},
{
numUpdates: 0,
localState: 0,
localOutputOnly: true,
},
{
numUpdates: 20,
localState: 20,
remoteOutputOnly: false,
},
{
numUpdates: 20,
localState: 20,
remoteOutputOnly: true,
},
{
numUpdates: 20,
localState: 20,
localOutputOnly: true,
},
{
numUpdates: 20,
localState: 5,
remoteOutputOnly: false,
},
{
numUpdates: 20,
localState: 5,
remoteOutputOnly: true,
},
{
numUpdates: 20,
localState: 5,
localOutputOnly: true,
},
}
for _, testCase := range testCases {
testName := fmt.Sprintf(
"num_updates=%v,remote_output=%v,local_output=%v",
testCase.numUpdates, testCase.remoteOutputOnly,
testCase.localOutputOnly,
)
testCase := testCase
t.Run(testName, func(t *testing.T) {
t.Parallel()
localForceCloseScenario(
t, testCase.numUpdates, testCase.localState,
testCase.remoteOutputOnly,
testCase.localOutputOnly,
)
})
}
}