mirror of
https://github.com/lightningnetwork/lnd.git
synced 2025-03-04 09:48:19 +01:00
invoices: add invoice htlc interceptor service
This commit introduces a new invoice htlc interceptor service that intercepts invoice HTLCs during their settlement phase. It forwards HTLCs to a subscribed client to determine their settlement outcomes. This commit also introduces an interface to facilitate integrating the interceptor with other packages.
This commit is contained in:
parent
cdad5d988d
commit
b8c8774b5d
4 changed files with 385 additions and 0 deletions
|
@ -207,3 +207,71 @@ type InvoiceUpdater interface {
|
||||||
// Finalize finalizes the update before it is written to the database.
|
// Finalize finalizes the update before it is written to the database.
|
||||||
Finalize(updateType UpdateType) error
|
Finalize(updateType UpdateType) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HtlcModifyRequest is the request that is passed to the client via callback
|
||||||
|
// during a HTLC interceptor session. The request contains the invoice that the
|
||||||
|
// given HTLC is attempting to settle.
|
||||||
|
type HtlcModifyRequest struct {
|
||||||
|
// WireCustomRecords are the custom records that were parsed from the
|
||||||
|
// HTLC wire message. These are the records of the current HTLC to be
|
||||||
|
// accepted/settled. All previously accepted/settled HTLCs for the same
|
||||||
|
// invoice are present in the Invoice field below.
|
||||||
|
WireCustomRecords lnwire.CustomRecords
|
||||||
|
|
||||||
|
// ExitHtlcCircuitKey is the circuit key that identifies the HTLC which
|
||||||
|
// is involved in the invoice settlement.
|
||||||
|
ExitHtlcCircuitKey CircuitKey
|
||||||
|
|
||||||
|
// ExitHtlcAmt is the amount of the HTLC which is involved in the
|
||||||
|
// invoice settlement.
|
||||||
|
ExitHtlcAmt lnwire.MilliSatoshi
|
||||||
|
|
||||||
|
// ExitHtlcExpiry is the absolute expiry height of the HTLC which is
|
||||||
|
// involved in the invoice settlement.
|
||||||
|
ExitHtlcExpiry uint32
|
||||||
|
|
||||||
|
// CurrentHeight is the current block height.
|
||||||
|
CurrentHeight uint32
|
||||||
|
|
||||||
|
// Invoice is the invoice that is being intercepted. The HTLCs within
|
||||||
|
// the invoice are only those previously accepted/settled for the same
|
||||||
|
// invoice.
|
||||||
|
Invoice Invoice
|
||||||
|
}
|
||||||
|
|
||||||
|
// HtlcModifyResponse is the response that the client should send back to the
|
||||||
|
// interceptor after processing the HTLC modify request.
|
||||||
|
type HtlcModifyResponse struct {
|
||||||
|
// AmountPaid is the amount that the client has decided the HTLC is
|
||||||
|
// actually worth. This might be different from the amount that the
|
||||||
|
// HTLC was originally sent with, in case additional value is carried
|
||||||
|
// along with it (which might be the case in custom channels).
|
||||||
|
AmountPaid lnwire.MilliSatoshi
|
||||||
|
}
|
||||||
|
|
||||||
|
// HtlcModifyCallback is a function that is called when an invoice is
|
||||||
|
// intercepted by the invoice interceptor.
|
||||||
|
type HtlcModifyCallback func(HtlcModifyRequest) (*HtlcModifyResponse, error)
|
||||||
|
|
||||||
|
// HtlcModifier is an interface that allows an intercept client to register
|
||||||
|
// itself as a modifier of HTLCs that are settling an invoice. The client can
|
||||||
|
// then modify the HTLCs based on the invoice and the HTLC that is settling it.
|
||||||
|
type HtlcModifier interface {
|
||||||
|
// RegisterInterceptor sets the client callback function that will be
|
||||||
|
// called when an invoice is intercepted. If a callback is already set,
|
||||||
|
// an error is returned. The returned function must be used to reset the
|
||||||
|
// callback to nil once the client is done or disconnects. The read-only
|
||||||
|
// channel closes when the server stops.
|
||||||
|
RegisterInterceptor(HtlcModifyCallback) (func(), <-chan struct{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HtlcInterceptor is an interface that allows the invoice registry to let
|
||||||
|
// clients intercept invoices before they are settled.
|
||||||
|
type HtlcInterceptor interface {
|
||||||
|
// Intercept generates a new intercept session for the given invoice.
|
||||||
|
// The call blocks until the client has responded to the request or an
|
||||||
|
// error occurs. The response callback is only called if a session was
|
||||||
|
// created in the first place, which is only the case if a client is
|
||||||
|
// registered.
|
||||||
|
Intercept(HtlcModifyRequest, func(HtlcModifyResponse)) error
|
||||||
|
}
|
||||||
|
|
|
@ -83,3 +83,34 @@ func (m *MockInvoiceDB) DeleteCanceledInvoices(ctx context.Context) error {
|
||||||
|
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MockHtlcModifier is a mock implementation of the HtlcModifier interface.
|
||||||
|
type MockHtlcModifier struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept generates a new intercept session for the given invoice.
|
||||||
|
// The call blocks until the client has responded to the request or an
|
||||||
|
// error occurs. The response callback is only called if a session was
|
||||||
|
// created in the first place, which is only the case if a client is
|
||||||
|
// registered.
|
||||||
|
func (m *MockHtlcModifier) Intercept(
|
||||||
|
_ HtlcModifyRequest, _ func(HtlcModifyResponse)) error {
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterInterceptor sets the client callback function that will be
|
||||||
|
// called when an invoice is intercepted. If a callback is already set,
|
||||||
|
// an error is returned. The returned function must be used to reset the
|
||||||
|
// callback to nil once the client is done or disconnects. The read-only channel
|
||||||
|
// closes when the server stops.
|
||||||
|
func (m *MockHtlcModifier) RegisterInterceptor(HtlcModifyCallback) (func(),
|
||||||
|
<-chan struct{}, error) {
|
||||||
|
|
||||||
|
return func() {}, make(chan struct{}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that MockHtlcModifier implements the HtlcInterceptor and HtlcModifier
|
||||||
|
// interfaces.
|
||||||
|
var _ HtlcInterceptor = (*MockHtlcModifier)(nil)
|
||||||
|
var _ HtlcModifier = (*MockHtlcModifier)(nil)
|
||||||
|
|
179
invoices/modification_interceptor.go
Normal file
179
invoices/modification_interceptor.go
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
package invoices
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/lightningnetwork/lnd/fn"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInterceptorClientAlreadyConnected is an error that is returned
|
||||||
|
// when a client tries to connect to the interceptor service while
|
||||||
|
// another client is already connected.
|
||||||
|
ErrInterceptorClientAlreadyConnected = errors.New(
|
||||||
|
"interceptor client already connected",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInterceptorClientDisconnected is an error that is returned when
|
||||||
|
// the client disconnects during an interceptor session.
|
||||||
|
ErrInterceptorClientDisconnected = errors.New(
|
||||||
|
"interceptor client disconnected",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// safeCallback is a wrapper around a callback function that is safe for
|
||||||
|
// concurrent access.
|
||||||
|
type safeCallback struct {
|
||||||
|
// callback is the actual callback function that is called when an
|
||||||
|
// invoice is intercepted. This might be nil if no client is currently
|
||||||
|
// connected.
|
||||||
|
callback atomic.Pointer[HtlcModifyCallback]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set atomically sets the callback function. If a callback is already set, an
|
||||||
|
// error is returned. The returned function can be used to reset the callback to
|
||||||
|
// nil once the client is done.
|
||||||
|
func (s *safeCallback) Set(callback HtlcModifyCallback) (func(), error) {
|
||||||
|
if !s.callback.CompareAndSwap(nil, &callback) {
|
||||||
|
return nil, ErrInterceptorClientAlreadyConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
s.callback.Store(nil)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected returns true if a client is currently connected.
|
||||||
|
func (s *safeCallback) IsConnected() bool {
|
||||||
|
return s.callback.Load() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec executes the callback function if it is set. If the callback is not set,
|
||||||
|
// an error is returned.
|
||||||
|
func (s *safeCallback) Exec(req HtlcModifyRequest) (*HtlcModifyResponse,
|
||||||
|
error) {
|
||||||
|
|
||||||
|
callback := s.callback.Load()
|
||||||
|
if callback == nil {
|
||||||
|
return nil, ErrInterceptorClientDisconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
return (*callback)(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HtlcModificationInterceptor is a service that intercepts HTLCs that aim to
|
||||||
|
// settle an invoice, enabling a subscribed client to modify certain aspects of
|
||||||
|
// those HTLCs.
|
||||||
|
type HtlcModificationInterceptor struct {
|
||||||
|
// callback is the wrapped client callback function that is called when
|
||||||
|
// an invoice is intercepted. This function gives the client the ability
|
||||||
|
// to determine how the invoice should be settled.
|
||||||
|
callback *safeCallback
|
||||||
|
|
||||||
|
// quit is a channel that is closed when the interceptor is stopped.
|
||||||
|
quit chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHtlcModificationInterceptor creates a new HtlcModificationInterceptor.
|
||||||
|
func NewHtlcModificationInterceptor() *HtlcModificationInterceptor {
|
||||||
|
return &HtlcModificationInterceptor{
|
||||||
|
callback: &safeCallback{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept generates a new intercept session for the given invoice. The call
|
||||||
|
// blocks until the client has responded to the request or an error occurs. The
|
||||||
|
// response callback is only called if a session was created in the first place,
|
||||||
|
// which is only the case if a client is registered.
|
||||||
|
func (s *HtlcModificationInterceptor) Intercept(clientRequest HtlcModifyRequest,
|
||||||
|
responseCallback func(HtlcModifyResponse)) error {
|
||||||
|
|
||||||
|
// If there is no client callback set we will not handle the invoice
|
||||||
|
// further.
|
||||||
|
if !s.callback.IsConnected() {
|
||||||
|
log.Debugf("Not intercepting invoice with circuit key %v, no "+
|
||||||
|
"intercept client connected",
|
||||||
|
clientRequest.ExitHtlcCircuitKey)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// We'll block until the client has responded to the request or an error
|
||||||
|
// occurs.
|
||||||
|
var (
|
||||||
|
responseChan = make(chan *HtlcModifyResponse, 1)
|
||||||
|
errChan = make(chan error, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
// The callback function will block at the client's discretion. We will
|
||||||
|
// therefore execute it in a separate goroutine. We don't need a wait
|
||||||
|
// group because we wait for the response directly below. The caller
|
||||||
|
// needs to make sure they don't block indefinitely, by selecting on the
|
||||||
|
// quit channel they receive when registering the callback.
|
||||||
|
go func() {
|
||||||
|
log.Debugf("Waiting for client response from invoice HTLC "+
|
||||||
|
"interceptor session with circuit key %v",
|
||||||
|
clientRequest.ExitHtlcCircuitKey)
|
||||||
|
|
||||||
|
// By this point, we've already checked that the client callback
|
||||||
|
// is set. However, if the client disconnected since that check
|
||||||
|
// then Exec will return an error.
|
||||||
|
result, err := s.callback.Exec(clientRequest)
|
||||||
|
if err != nil {
|
||||||
|
_ = fn.SendOrQuit(errChan, err, s.quit)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = fn.SendOrQuit(responseChan, result, s.quit)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for the client to respond or an error to occur.
|
||||||
|
select {
|
||||||
|
case response := <-responseChan:
|
||||||
|
log.Debugf("Received invoice HTLC interceptor response: %v",
|
||||||
|
response)
|
||||||
|
|
||||||
|
responseCallback(*response)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case err := <-errChan:
|
||||||
|
log.Errorf("Error from invoice HTLC interceptor session: %v",
|
||||||
|
err)
|
||||||
|
|
||||||
|
return err
|
||||||
|
|
||||||
|
case <-s.quit:
|
||||||
|
return ErrInterceptorClientDisconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterInterceptor sets the client callback function that will be called
|
||||||
|
// when an invoice is intercepted. If a callback is already set, an error is
|
||||||
|
// returned. The returned function must be used to reset the callback to nil
|
||||||
|
// once the client is done or disconnects.
|
||||||
|
func (s *HtlcModificationInterceptor) RegisterInterceptor(
|
||||||
|
callback HtlcModifyCallback) (func(), <-chan struct{}, error) {
|
||||||
|
|
||||||
|
done, err := s.callback.Set(callback)
|
||||||
|
return done, s.quit, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the service.
|
||||||
|
func (s *HtlcModificationInterceptor) Start() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the service.
|
||||||
|
func (s *HtlcModificationInterceptor) Stop() error {
|
||||||
|
close(s.quit)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that HtlcModificationInterceptor implements the HtlcInterceptor and
|
||||||
|
// HtlcModifier interfaces.
|
||||||
|
var _ HtlcInterceptor = (*HtlcModificationInterceptor)(nil)
|
||||||
|
var _ HtlcModifier = (*HtlcModificationInterceptor)(nil)
|
107
invoices/modification_interceptor_test.go
Normal file
107
invoices/modification_interceptor_test.go
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
package invoices
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lightningnetwork/lnd/lnwire"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultTimeout = 50 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHtlcModificationInterceptor tests the basic functionality of the HTLC
|
||||||
|
// modification interceptor.
|
||||||
|
func TestHtlcModificationInterceptor(t *testing.T) {
|
||||||
|
interceptor := NewHtlcModificationInterceptor()
|
||||||
|
request := HtlcModifyRequest{
|
||||||
|
WireCustomRecords: lnwire.CustomRecords{
|
||||||
|
lnwire.MinCustomRecordsTlvType: []byte{1, 2, 3},
|
||||||
|
},
|
||||||
|
ExitHtlcCircuitKey: CircuitKey{
|
||||||
|
ChanID: lnwire.NewShortChanIDFromInt(1),
|
||||||
|
HtlcID: 1,
|
||||||
|
},
|
||||||
|
ExitHtlcAmt: 1234,
|
||||||
|
}
|
||||||
|
expectedResponse := HtlcModifyResponse{
|
||||||
|
AmountPaid: 345,
|
||||||
|
}
|
||||||
|
interceptCallbackCalled := make(chan HtlcModifyRequest, 1)
|
||||||
|
successInterceptCallback := func(
|
||||||
|
req HtlcModifyRequest) (*HtlcModifyResponse, error) {
|
||||||
|
|
||||||
|
interceptCallbackCalled <- req
|
||||||
|
|
||||||
|
return &expectedResponse, nil
|
||||||
|
}
|
||||||
|
errorInterceptCallback := func(
|
||||||
|
req HtlcModifyRequest) (*HtlcModifyResponse, error) {
|
||||||
|
|
||||||
|
interceptCallbackCalled <- req
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("something went wrong")
|
||||||
|
}
|
||||||
|
responseCallbackCalled := make(chan HtlcModifyResponse, 1)
|
||||||
|
responseCallback := func(resp HtlcModifyResponse) {
|
||||||
|
responseCallbackCalled <- resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a session without setting a callback first.
|
||||||
|
err := interceptor.Intercept(request, responseCallback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set the callback and create a new session.
|
||||||
|
done, _, err := interceptor.RegisterInterceptor(
|
||||||
|
successInterceptCallback,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = interceptor.Intercept(request, responseCallback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The intercept callback should be called now.
|
||||||
|
select {
|
||||||
|
case req := <-interceptCallbackCalled:
|
||||||
|
require.Equal(t, request, req)
|
||||||
|
|
||||||
|
case <-time.After(defaultTimeout):
|
||||||
|
t.Fatal("intercept callback not called")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the result should make it back to the response callback.
|
||||||
|
select {
|
||||||
|
case resp := <-responseCallbackCalled:
|
||||||
|
require.Equal(t, expectedResponse, resp)
|
||||||
|
|
||||||
|
case <-time.After(defaultTimeout):
|
||||||
|
t.Fatal("response callback not called")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we try to set a new callback without first returning the previous
|
||||||
|
// one, we should get an error.
|
||||||
|
_, _, err = interceptor.RegisterInterceptor(successInterceptCallback)
|
||||||
|
require.ErrorIs(t, err, ErrInterceptorClientAlreadyConnected)
|
||||||
|
|
||||||
|
// Reset the callback, then try to set a new one.
|
||||||
|
done()
|
||||||
|
done2, _, err := interceptor.RegisterInterceptor(errorInterceptCallback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer done2()
|
||||||
|
|
||||||
|
// We should now get an error when intercepting.
|
||||||
|
err = interceptor.Intercept(request, responseCallback)
|
||||||
|
require.ErrorContains(t, err, "something went wrong")
|
||||||
|
|
||||||
|
// The success callback should not be called.
|
||||||
|
select {
|
||||||
|
case resp := <-responseCallbackCalled:
|
||||||
|
t.Fatalf("unexpected response: %v", resp)
|
||||||
|
|
||||||
|
case <-time.After(defaultTimeout):
|
||||||
|
// Expected.
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue