diff --git a/contractcourt/htlc_incoming_resolver_test.go b/contractcourt/htlc_incoming_resolver_test.go new file mode 100644 index 00000000..5f623428 --- /dev/null +++ b/contractcourt/htlc_incoming_resolver_test.go @@ -0,0 +1,274 @@ +package contractcourt + +import ( + "bytes" + "testing" + + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/lnwallet" + + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/lntypes" +) + +const ( + testInitialBlockHeight = 100 + testHtlcExpiry = 150 +) + +var ( + testResPreimage = lntypes.Preimage{1, 2, 3} + testResHash = testResPreimage.Hash() +) + +// TestHtlcIncomingResolverFwdPreimageKnown tests resolution of a forwarded htlc +// for which the preimage is already known initially. +func TestHtlcIncomingResolverFwdPreimageKnown(t *testing.T) { + t.Parallel() + defer timeout(t)() + + ctx := newIncomingResolverTestContext(t) + ctx.registry.notifyErr = channeldb.ErrInvoiceNotFound + ctx.witnessBeacon.lookupPreimage[testResHash] = testResPreimage + ctx.resolve() + ctx.waitForResult(true) +} + +// TestHtlcIncomingResolverFwdContestedSuccess tests resolution of a forwarded +// htlc for which the preimage becomes known after the resolver has been +// started. +func TestHtlcIncomingResolverFwdContestedSuccess(t *testing.T) { + t.Parallel() + defer timeout(t)() + + ctx := newIncomingResolverTestContext(t) + ctx.registry.notifyErr = channeldb.ErrInvoiceNotFound + ctx.resolve() + + // Simulate a new block coming in. HTLC is not yet expired. + ctx.notifyEpoch(testInitialBlockHeight + 1) + + ctx.witnessBeacon.preImageUpdates <- testResPreimage + ctx.waitForResult(true) +} + +// TestHtlcIncomingResolverFwdContestedTimeout tests resolution of a forwarded +// htlc that times out after the resolver has been started. +func TestHtlcIncomingResolverFwdContestedTimeout(t *testing.T) { + t.Parallel() + defer timeout(t)() + + ctx := newIncomingResolverTestContext(t) + ctx.registry.notifyErr = channeldb.ErrInvoiceNotFound + ctx.resolve() + + // Simulate a new block coming in. HTLC expires. + ctx.notifyEpoch(testHtlcExpiry) + + ctx.waitForResult(false) +} + +// TestHtlcIncomingResolverFwdTimeout tests resolution of a forwarded htlc that +// has already expired when the resolver starts. +func TestHtlcIncomingResolverFwdTimeout(t *testing.T) { + t.Parallel() + defer timeout(t)() + + ctx := newIncomingResolverTestContext(t) + + ctx.registry.notifyErr = channeldb.ErrInvoiceNotFound + ctx.witnessBeacon.lookupPreimage[testResHash] = testResPreimage + ctx.resolver.htlcExpiry = 90 + ctx.resolve() + ctx.waitForResult(false) +} + +// TestHtlcIncomingResolverExitSettle tests resolution of an exit hop htlc for +// which the invoice has already been settled when the resolver starts. +func TestHtlcIncomingResolverExitSettle(t *testing.T) { + t.Parallel() + defer timeout(t)() + + ctx := newIncomingResolverTestContext(t) + ctx.registry.notifyEvent = &invoices.HodlEvent{ + Hash: testResHash, + Preimage: &testResPreimage, + } + ctx.resolve() + + data := <-ctx.registry.notifyChan + if data.expiry != testHtlcExpiry { + t.Fatal("incorrect expiry") + } + if data.currentHeight != testInitialBlockHeight { + t.Fatal("incorrect block height") + } + + ctx.waitForResult(true) +} + +// TestHtlcIncomingResolverExitCancel tests resolution of an exit hop htlc for +// an invoice that is already canceled when the resolver starts. +func TestHtlcIncomingResolverExitCancel(t *testing.T) { + t.Parallel() + defer timeout(t)() + + ctx := newIncomingResolverTestContext(t) + ctx.registry.notifyEvent = &invoices.HodlEvent{ + Hash: testResHash, + } + ctx.resolve() + ctx.waitForResult(false) +} + +// TestHtlcIncomingResolverExitSettleHodl tests resolution of an exit hop htlc +// for a hodl invoice that is settled after the resolver has started. +func TestHtlcIncomingResolverExitSettleHodl(t *testing.T) { + t.Parallel() + defer timeout(t)() + + ctx := newIncomingResolverTestContext(t) + ctx.resolve() + + notifyData := <-ctx.registry.notifyChan + notifyData.hodlChan <- invoices.HodlEvent{ + Hash: testResHash, + Preimage: &testResPreimage, + } + + ctx.waitForResult(true) +} + +// TestHtlcIncomingResolverExitTimeoutHodl tests resolution of an exit hop htlc +// for a hodl invoice that times out. +func TestHtlcIncomingResolverExitTimeoutHodl(t *testing.T) { + t.Parallel() + defer timeout(t)() + + ctx := newIncomingResolverTestContext(t) + ctx.resolve() + ctx.notifyEpoch(testHtlcExpiry) + ctx.waitForResult(false) +} + +// TestHtlcIncomingResolverExitCancelHodl tests resolution of an exit hop htlc +// for a hodl invoice that is canceled after the resolver has started. +func TestHtlcIncomingResolverExitCancelHodl(t *testing.T) { + t.Parallel() + defer timeout(t)() + + ctx := newIncomingResolverTestContext(t) + ctx.resolve() + notifyData := <-ctx.registry.notifyChan + notifyData.hodlChan <- invoices.HodlEvent{ + Hash: testResHash, + } + ctx.waitForResult(false) +} + +type incomingResolverTestContext struct { + registry *mockRegistry + witnessBeacon *mockWitnessBeacon + resolver *htlcIncomingContestResolver + notifier *mockNotifier + resolveErr chan error + nextResolver ContractResolver + t *testing.T +} + +func newIncomingResolverTestContext(t *testing.T) *incomingResolverTestContext { + notifier := &mockNotifier{ + epochChan: make(chan *chainntnfs.BlockEpoch), + spendChan: make(chan *chainntnfs.SpendDetail), + confChan: make(chan *chainntnfs.TxConfirmation), + } + witnessBeacon := newMockWitnessBeacon() + registry := &mockRegistry{ + notifyChan: make(chan notifyExitHopData, 1), + } + + checkPointChan := make(chan struct{}, 1) + + chainCfg := ChannelArbitratorConfig{ + ChainArbitratorConfig: ChainArbitratorConfig{ + Notifier: notifier, + PreimageDB: witnessBeacon, + Registry: registry, + }, + } + + resolver := &htlcIncomingContestResolver{ + htlcSuccessResolver: htlcSuccessResolver{ + ResolverKit: ResolverKit{ + ChannelArbitratorConfig: chainCfg, + Checkpoint: func(_ ContractResolver) error { + checkPointChan <- struct{}{} + return nil + }, + }, + htlcResolution: lnwallet.IncomingHtlcResolution{}, + payHash: testResHash, + }, + htlcExpiry: testHtlcExpiry, + } + + return &incomingResolverTestContext{ + registry: registry, + witnessBeacon: witnessBeacon, + resolver: resolver, + notifier: notifier, + t: t, + } +} + +func (i *incomingResolverTestContext) resolve() { + // Start resolver. + i.resolveErr = make(chan error, 1) + go func() { + var err error + i.nextResolver, err = i.resolver.Resolve() + i.resolveErr <- err + }() + + // Notify initial block height. + i.notifyEpoch(testInitialBlockHeight) +} + +func (i *incomingResolverTestContext) notifyEpoch(height int32) { + i.notifier.epochChan <- &chainntnfs.BlockEpoch{ + Height: height, + } +} + +func (i *incomingResolverTestContext) waitForResult(expectSuccessRes bool) { + i.t.Helper() + + err := <-i.resolveErr + if err != nil { + i.t.Fatal(err) + } + + if !expectSuccessRes { + if err != nil { + i.t.Fatal("expected no next resolver") + } + return + } + + successResolver, ok := i.nextResolver.(*htlcSuccessResolver) + if !ok { + i.t.Fatal("expected htlcSuccessResolver") + } + + if successResolver.htlcResolution.Preimage != testResPreimage { + i.t.Fatal("invalid preimage") + } + + successTx := successResolver.htlcResolution.SignedSuccessTx + if successTx != nil && + !bytes.Equal(successTx.TxIn[0].Witness[3], testResPreimage[:]) { + + i.t.Fatal("invalid preimage") + } +} diff --git a/contractcourt/mock_registry_test.go b/contractcourt/mock_registry_test.go new file mode 100644 index 00000000..f54a1465 --- /dev/null +++ b/contractcourt/mock_registry_test.go @@ -0,0 +1,45 @@ +package contractcourt + +import ( + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" +) + +type notifyExitHopData struct { + payHash lntypes.Hash + paidAmount lnwire.MilliSatoshi + hodlChan chan<- interface{} + expiry uint32 + currentHeight int32 +} + +type mockRegistry struct { + notifyChan chan notifyExitHopData + notifyErr error + notifyEvent *invoices.HodlEvent +} + +func (r *mockRegistry) NotifyExitHopHtlc(payHash lntypes.Hash, + paidAmount lnwire.MilliSatoshi, expiry uint32, currentHeight int32, + hodlChan chan<- interface{}) (*invoices.HodlEvent, error) { + + r.notifyChan <- notifyExitHopData{ + hodlChan: hodlChan, + payHash: payHash, + paidAmount: paidAmount, + expiry: expiry, + currentHeight: currentHeight, + } + + return r.notifyEvent, r.notifyErr +} + +func (r *mockRegistry) HodlUnsubscribeAll(subscriber chan<- interface{}) {} + +func (r *mockRegistry) LookupInvoice(lntypes.Hash) (channeldb.Invoice, uint32, + error) { + + return channeldb.Invoice{}, 0, channeldb.ErrInvoiceNotFound +} diff --git a/contractcourt/utils_test.go b/contractcourt/utils_test.go new file mode 100644 index 00000000..2bf81b41 --- /dev/null +++ b/contractcourt/utils_test.go @@ -0,0 +1,26 @@ +package contractcourt + +import ( + "os" + "runtime/pprof" + "testing" + "time" +) + +// timeout implements a test level timeout. +func timeout(t *testing.T) func() { + done := make(chan struct{}) + go func() { + select { + case <-time.After(5 * time.Second): + pprof.Lookup("goroutine").WriteTo(os.Stdout, 1) + + panic("test timeout") + case <-done: + } + }() + + return func() { + close(done) + } +}