diff --git a/htlcswitch/mock.go b/htlcswitch/mock.go index 6d27a5be9..aea001374 100644 --- a/htlcswitch/mock.go +++ b/htlcswitch/mock.go @@ -1014,6 +1014,7 @@ func newMockRegistry(minDelta uint32) *mockInvoiceRegistry { panic(err) } + modifierMock := &invoices.MockHtlcModifier{} registry := invoices.NewRegistry( cdb, invoices.NewInvoiceExpiryWatcher( @@ -1022,6 +1023,7 @@ func newMockRegistry(minDelta uint32) *mockInvoiceRegistry { ), &invoices.RegistryConfig{ FinalCltvRejectDelta: 5, + HtlcInterceptor: modifierMock, }, ) registry.Start() diff --git a/invoices/invoiceregistry.go b/invoices/invoiceregistry.go index 7b3caa34a..c7f4cdb7b 100644 --- a/invoices/invoiceregistry.go +++ b/invoices/invoiceregistry.go @@ -74,6 +74,10 @@ type RegistryConfig struct { // KeysendHoldTime indicates for how long we want to accept and hold // spontaneous keysend payments. KeysendHoldTime time.Duration + + // HtlcInterceptor is an interface that allows the invoice registry to + // let clients intercept invoices before they are settled. + HtlcInterceptor HtlcInterceptor } // htlcReleaseEvent describes an htlc auto-release event. It is used to release @@ -1019,13 +1023,61 @@ func (i *InvoiceRegistry) notifyExitHopHtlcLocked( ctx *invoiceUpdateCtx, hodlChan chan<- interface{}) ( HtlcResolution, invoiceExpiry, error) { + invoiceRef := ctx.invoiceRef() + setID := (*SetID)(ctx.setID()) + + // We need to look up the current state of the invoice in order to send + // the previously accepted/settled HTLCs to the interceptor. + existingInvoice, err := i.idb.LookupInvoice( + context.Background(), invoiceRef, + ) + switch { + case errors.Is(err, ErrInvoiceNotFound) || + errors.Is(err, ErrNoInvoicesCreated): + + // If the invoice was not found, return a failure resolution + // with an invoice not found result. + return NewFailResolution( + ctx.circuitKey, ctx.currentHeight, + ResultInvoiceNotFound, + ), nil, nil + + case err != nil: + ctx.log(err.Error()) + return nil, nil, err + } + + // Provide the invoice to the settlement interceptor to allow + // the interceptor's client an opportunity to manipulate the + // settlement process. + err = i.cfg.HtlcInterceptor.Intercept(HtlcModifyRequest{ + ExitHtlcCircuitKey: ctx.circuitKey, + ExitHtlcAmt: ctx.amtPaid, + ExitHtlcExpiry: ctx.expiry, + CurrentHeight: uint32(ctx.currentHeight), + Invoice: existingInvoice, + }, func(resp HtlcModifyResponse) { + log.Debugf("Received invoice HTLC interceptor response: %v", + resp) + + if resp.AmountPaid != 0 { + ctx.amtPaid = resp.AmountPaid + } + }) + if err != nil { + err := fmt.Errorf("error during invoice HTLC interception: %w", + err) + ctx.log(err.Error()) + + return nil, nil, err + } + // We'll attempt to settle an invoice matching this rHash on disk (if // one exists). The callback will update the invoice state and/or htlcs. var ( resolution HtlcResolution updateSubscribers bool ) - callback := func(inv *Invoice) (*InvoiceUpdateDesc, error) { updateDesc, res, err := updateInvoice(ctx, inv) if err != nil { @@ -1042,8 +1094,6 @@ func (i *InvoiceRegistry) notifyExitHopHtlcLocked( return updateDesc, nil } - invoiceRef := ctx.invoiceRef() - setID := (*SetID)(ctx.setID()) invoice, err := i.idb.UpdateInvoice( context.Background(), invoiceRef, setID, callback, ) @@ -1080,6 +1130,8 @@ func (i *InvoiceRegistry) notifyExitHopHtlcLocked( var invoiceToExpire invoiceExpiry + log.Tracef("Settlement resolution: %T %v", resolution, resolution) + switch res := resolution.(type) { case *HtlcFailResolution: // Inspect latest htlc state on the invoice. If it is found, @@ -1212,7 +1264,7 @@ func (i *InvoiceRegistry) notifyExitHopHtlcLocked( } // Now that the links have been notified of any state changes to their - // HTLCs, we'll go ahead and notify any clients wiaiting on the invoice + // HTLCs, we'll go ahead and notify any clients waiting on the invoice // state changes. if updateSubscribers { // We'll add a setID onto the notification, but only if this is diff --git a/invoices/invoiceregistry_test.go b/invoices/invoiceregistry_test.go index b0c019522..b7b3d574a 100644 --- a/invoices/invoiceregistry_test.go +++ b/invoices/invoiceregistry_test.go @@ -23,6 +23,12 @@ import ( "github.com/stretchr/testify/require" ) +var ( + // htlcModifierMock is a mock implementation of the invoice HtlcModifier + // interface. + htlcModifierMock = &invpkg.MockHtlcModifier{} +) + // TestInvoiceRegistry is a master test which encompasses all tests using an // InvoiceDB instance. The purpose of this test is to be able to run all tests // with a custom DB instance, so that we can test the same logic with different @@ -517,6 +523,7 @@ func testSettleHoldInvoice(t *testing.T, cfg := invpkg.RegistryConfig{ FinalCltvRejectDelta: testFinalCltvRejectDelta, Clock: clock, + HtlcInterceptor: htlcModifierMock, } expiryWatcher := invpkg.NewInvoiceExpiryWatcher( @@ -683,6 +690,7 @@ func testCancelHoldInvoice(t *testing.T, cfg := invpkg.RegistryConfig{ FinalCltvRejectDelta: testFinalCltvRejectDelta, Clock: testClock, + HtlcInterceptor: htlcModifierMock, } expiryWatcher := invpkg.NewInvoiceExpiryWatcher( cfg.Clock, 0, uint32(testCurrentHeight), nil, newMockNotifier(), @@ -1200,6 +1208,7 @@ func testInvoiceExpiryWithRegistry(t *testing.T, cfg := invpkg.RegistryConfig{ FinalCltvRejectDelta: testFinalCltvRejectDelta, Clock: testClock, + HtlcInterceptor: htlcModifierMock, } expiryWatcher := invpkg.NewInvoiceExpiryWatcher( @@ -1310,6 +1319,7 @@ func testOldInvoiceRemovalOnStart(t *testing.T, FinalCltvRejectDelta: testFinalCltvRejectDelta, Clock: testClock, GcCanceledInvoicesOnStartup: true, + HtlcInterceptor: htlcModifierMock, } expiryWatcher := invpkg.NewInvoiceExpiryWatcher( diff --git a/invoices/test_utils_test.go b/invoices/test_utils_test.go index ed7bfccdd..fe69cad6f 100644 --- a/invoices/test_utils_test.go +++ b/invoices/test_utils_test.go @@ -153,6 +153,7 @@ func defaultRegistryConfig() invpkg.RegistryConfig { return invpkg.RegistryConfig{ FinalCltvRejectDelta: testFinalCltvRejectDelta, HtlcHoldDuration: 30 * time.Second, + HtlcInterceptor: &invpkg.MockHtlcModifier{}, } }