diff --git a/invoices/invoiceregistry_test.go b/invoices/invoiceregistry_test.go index 0e0f3fcb..23b2cfae 100644 --- a/invoices/invoiceregistry_test.go +++ b/invoices/invoiceregistry_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/lightningnetwork/lnd/amp" + "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/lntypes" @@ -1118,6 +1119,222 @@ func TestOldInvoiceRemovalOnStart(t *testing.T) { require.Equal(t, expected, response.Invoices) } +// TestHeightExpiryWithRegistry tests our height-based invoice expiry for +// invoices paid with single and multiple htlcs, testing the case where the +// invoice is settled before expiry (and thus not canceled), and the case +// where the invoice is expired. +func TestHeightExpiryWithRegistry(t *testing.T) { + t.Run("single shot settled before expiry", func(t *testing.T) { + testHeightExpiryWithRegistry(t, 1, true) + }) + + t.Run("single shot expires", func(t *testing.T) { + testHeightExpiryWithRegistry(t, 1, false) + }) + + t.Run("mpp settled before expiry", func(t *testing.T) { + testHeightExpiryWithRegistry(t, 2, true) + }) + + t.Run("mpp expires", func(t *testing.T) { + testHeightExpiryWithRegistry(t, 2, false) + }) +} + +func testHeightExpiryWithRegistry(t *testing.T, numParts int, settle bool) { + t.Parallel() + defer timeout()() + + ctx := newTestContext(t) + defer ctx.cleanup() + + require.Greater(t, numParts, 0, "test requires at least one part") + + // Add a hold invoice, we set a non-nil payment request so that this + // invoice is not considered a keysend by the expiry watcher. + invoice := *testInvoice + invoice.HodlInvoice = true + invoice.PaymentRequest = []byte{1, 2, 3} + + _, err := ctx.registry.AddInvoice(&invoice, testInvoicePaymentHash) + require.NoError(t, err) + + payLoad := testPayload + if numParts > 1 { + payLoad = &mockPayload{ + mpp: record.NewMPP(testInvoiceAmt, [32]byte{}), + } + } + + htlcAmt := invoice.Terms.Value / lnwire.MilliSatoshi(numParts) + hodlChan := make(chan interface{}, numParts) + for i := 0; i < numParts; i++ { + // We bump our expiry height for each htlc so that we can test + // that the lowest expiry height is used. + expiry := testHtlcExpiry + uint32(i) + + resolution, err := ctx.registry.NotifyExitHopHtlc( + testInvoicePaymentHash, htlcAmt, expiry, + testCurrentHeight, getCircuitKey(uint64(i)), hodlChan, + payLoad, + ) + require.NoError(t, err) + require.Nil(t, resolution, "did not expect direct resolution") + } + + require.Eventually(t, func() bool { + inv, err := ctx.registry.LookupInvoice(testInvoicePaymentHash) + require.NoError(t, err) + + return inv.State == channeldb.ContractAccepted + }, time.Second, time.Millisecond*100) + + // Now that we've added our htlc(s), we tick our test clock to our + // invoice expiry time. We don't expect the invoice to be canceled + // based on its expiry time now that we have active htlcs. + ctx.clock.SetTime(invoice.CreationDate.Add(invoice.Terms.Expiry + 1)) + + // The expiry watcher loop takes some time to process the new clock + // time. We mine the block before our expiry height, our mock will block + // until the expiry watcher consumes this height, so we can be sure + // that the expiry loop has run at least once after this block is + // consumed. + ctx.notifier.blockChan <- &chainntnfs.BlockEpoch{ + Height: int32(testHtlcExpiry - 1), + } + + // If we want to settle our invoice in this test, we do so now. + if settle { + err = ctx.registry.SettleHodlInvoice(testInvoicePreimage) + require.NoError(t, err) + + for i := 0; i < numParts; i++ { + htlcResolution := (<-hodlChan).(HtlcResolution) + require.NotNil(t, htlcResolution) + settleResolution := checkSettleResolution( + t, htlcResolution, testInvoicePreimage, + ) + require.Equal(t, ResultSettled, settleResolution.Outcome) + } + } + + // Now we mine our htlc's expiry height. + ctx.notifier.blockChan <- &chainntnfs.BlockEpoch{ + Height: int32(testHtlcExpiry), + } + + // If we did not settle the invoice before its expiry, we now expect + // a cancelation. + expectedState := channeldb.ContractSettled + if !settle { + expectedState = channeldb.ContractCanceled + + htlcResolution := (<-hodlChan).(HtlcResolution) + require.NotNil(t, htlcResolution) + checkFailResolution( + t, htlcResolution, ResultCanceled, + ) + } + + // Finally, lookup the invoice and assert that we have the state we + // expect. + inv, err := ctx.registry.LookupInvoice(testInvoicePaymentHash) + require.NoError(t, err) + require.Equal(t, expectedState, inv.State, "expected "+ + "hold invoice: %v, got: %v", expectedState, inv.State) +} + +// TestMultipleSetHeightExpiry pays a hold invoice with two mpp sets, testing +// that the invoice expiry watcher only uses the expiry height of the second, +// successful set to cancel the invoice, and does not cancel early using the +// expiry height of the first set that was canceled back due to mpp timeout. +func TestMultipleSetHeightExpiry(t *testing.T) { + t.Parallel() + defer timeout()() + + ctx := newTestContext(t) + defer ctx.cleanup() + + // Add a hold invoice. + invoice := *testInvoice + invoice.HodlInvoice = true + + _, err := ctx.registry.AddInvoice(&invoice, testInvoicePaymentHash) + require.NoError(t, err) + + mppPayload := &mockPayload{ + mpp: record.NewMPP(testInvoiceAmt, [32]byte{}), + } + + // Send htlc 1. + hodlChan1 := make(chan interface{}, 1) + resolution, err := ctx.registry.NotifyExitHopHtlc( + testInvoicePaymentHash, invoice.Terms.Value/2, + testHtlcExpiry, + testCurrentHeight, getCircuitKey(10), hodlChan1, mppPayload, + ) + require.NoError(t, err) + require.Nil(t, resolution, "did not expect direct resolution") + + // Simulate mpp timeout releasing htlc 1. + ctx.clock.SetTime(testTime.Add(30 * time.Second)) + + htlcResolution := (<-hodlChan1).(HtlcResolution) + failResolution, ok := htlcResolution.(*HtlcFailResolution) + require.True(t, ok, "expected fail resolution, got: %T", resolution) + require.Equal(t, ResultMppTimeout, failResolution.Outcome, + "expected MPP Timeout, got: %v", failResolution.Outcome) + + // Notify the expiry height for our first htlc. We don't expect the + // invoice to be expired based on block height because the htlc set + // was never completed. + ctx.notifier.blockChan <- &chainntnfs.BlockEpoch{ + Height: int32(testHtlcExpiry), + } + + // Now we will send a full set of htlcs for the invoice with a higher + // expiry height. We expect the invoice to move into the accepted state. + expiry := testHtlcExpiry + 5 + + // Send htlc 2. + hodlChan2 := make(chan interface{}, 1) + resolution, err = ctx.registry.NotifyExitHopHtlc( + testInvoicePaymentHash, invoice.Terms.Value/2, expiry, + testCurrentHeight, getCircuitKey(11), hodlChan2, mppPayload, + ) + require.NoError(t, err) + require.Nil(t, resolution, "did not expect direct resolution") + + // Send htlc 3. + hodlChan3 := make(chan interface{}, 1) + resolution, err = ctx.registry.NotifyExitHopHtlc( + testInvoicePaymentHash, invoice.Terms.Value/2, expiry, + testCurrentHeight, getCircuitKey(12), hodlChan3, mppPayload, + ) + require.NoError(t, err) + require.Nil(t, resolution, "did not expect direct resolution") + + // Assert that we've reached an accepted state because the invoice has + // been paid with a complete set. + inv, err := ctx.registry.LookupInvoice(testInvoicePaymentHash) + require.NoError(t, err) + require.Equal(t, channeldb.ContractAccepted, inv.State, "expected "+ + "hold invoice accepted") + + // Now we will notify the expiry height for the new set of htlcs. We + // expect the invoice to be canceled by the expiry watcher. + ctx.notifier.blockChan <- &chainntnfs.BlockEpoch{ + Height: int32(expiry), + } + + require.Eventuallyf(t, func() bool { + inv, err := ctx.registry.LookupInvoice(testInvoicePaymentHash) + require.NoError(t, err) + + return inv.State == channeldb.ContractCanceled + }, testTimeout, time.Millisecond*100, "invoice not canceled") +} + // TestSettleInvoicePaymentAddrRequired tests that if an incoming payment has // an invoice that requires the payment addr bit to be set, and the incoming // payment doesn't include an mpp payload, then the payment is rejected. diff --git a/invoices/test_utils_test.go b/invoices/test_utils_test.go index 51f41fc9..f013f0d5 100644 --- a/invoices/test_utils_test.go +++ b/invoices/test_utils_test.go @@ -181,6 +181,7 @@ func newTestChannelDB(clock clock.Clock) (*channeldb.DB, func(), error) { type testContext struct { cdb *channeldb.DB registry *InvoiceRegistry + notifier *mockChainNotifier clock *clock.TestClock cleanup func() @@ -195,8 +196,10 @@ func newTestContext(t *testing.T) *testContext { t.Fatal(err) } + notifier := newMockNotifier() + expiryWatcher := NewInvoiceExpiryWatcher( - clock, 0, uint32(testCurrentHeight), nil, newMockNotifier(), + clock, 0, uint32(testCurrentHeight), nil, notifier, ) // Instantiate and start the invoice ctx.registry. @@ -216,6 +219,7 @@ func newTestContext(t *testing.T) *testContext { ctx := testContext{ cdb: cdb, registry: registry, + notifier: notifier, clock: clock, t: t, cleanup: func() {