diff --git a/liquidity/autoloop_test.go b/liquidity/autoloop_test.go index 72e0e5a..fe0caac 100644 --- a/liquidity/autoloop_test.go +++ b/liquidity/autoloop_test.go @@ -99,6 +99,7 @@ func TestAutoLoopEnabled(t *testing.T) { chanID1: chanRule, chanID2: chanRule, }, + HtlcConfTarget: defaultHtlcConfTarget, } ) c := newAutoloopTestCtx(t, params, channels, testRestrictions) @@ -318,6 +319,7 @@ func TestCompositeRules(t *testing.T) { PeerRules: map[route.Vertex]*SwapRule{ peer2: chanRule, }, + HtlcConfTarget: defaultHtlcConfTarget, } ) diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index 97548b9..fd12219 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -90,6 +90,10 @@ const ( ) var ( + // defaultHtlcConfTarget is the default confirmation target we use for + // loop in swap htlcs, set to the same default at the client. + defaultHtlcConfTarget = loop.DefaultHtlcConfTarget + // defaultBudget is the default autoloop budget we set. This budget will // only be used for automatically dispatched swaps if autoloop is // explicitly enabled, so we are happy to set a non-zero value here. The @@ -107,6 +111,7 @@ var ( PeerRules: make(map[route.Vertex]*SwapRule), FailureBackOff: defaultFailureBackoff, SweepConfTarget: defaultConfTarget, + HtlcConfTarget: defaultHtlcConfTarget, FeeLimit: defaultFeePortion(), } @@ -170,6 +175,10 @@ type Config struct { LoopOutQuote func(ctx context.Context, request *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error) + // LoopInQuote provides a quote for a loop in swap. + LoopInQuote func(ctx context.Context, + request *loop.LoopInQuoteRequest) (*loop.LoopInQuote, error) + // LoopOut dispatches a loop out. LoopOut func(ctx context.Context, request *loop.OutRequest) ( *loop.LoopOutSwapInfo, error) @@ -212,6 +221,10 @@ type Parameters struct { // transaction in. This value affects the on chain fees we will pay. SweepConfTarget int32 + // HtlcConfTarget is the confirmation target that we use for publishing + // loop in swap htlcs on chain. + HtlcConfTarget int32 + // FeeLimit controls the fee limit we place on swaps. FeeLimit FeeLimit @@ -249,10 +262,11 @@ func (p Parameters) String() string { } return fmt.Sprintf("rules: %v, failure backoff: %v, sweep "+ - "sweep conf target: %v, fees: %v, auto budget: %v, budget "+ - "start: %v, max auto in flight: %v, minimum swap size=%v, "+ - "maximum swap size=%v", strings.Join(ruleList, ","), - p.FailureBackOff, p.SweepConfTarget, p.FeeLimit, + "sweep conf target: %v, htlc conf target: %v,fees: %v, "+ + "auto budget: %v, budget start: %v, max auto in flight: %v, "+ + "minimum swap size=%v, maximum swap size=%v", + strings.Join(ruleList, ","), p.FailureBackOff, + p.SweepConfTarget, p.HtlcConfTarget, p.FeeLimit, p.AutoFeeBudget, p.AutoFeeStartDate, p.MaxAutoInFlight, p.ClientRestrictions.Minimum, p.ClientRestrictions.Maximum) } @@ -329,6 +343,10 @@ func (p Parameters) validate(minConfs int32, openChans []lndclient.ChannelInfo, minConfs) } + if p.HtlcConfTarget < 1 { + return fmt.Errorf("htlc confirmation target must be > 0") + } + if err := p.FeeLimit.validate(); err != nil { return err } diff --git a/liquidity/loopin_builder.go b/liquidity/loopin_builder.go new file mode 100644 index 0000000..ff475f3 --- /dev/null +++ b/liquidity/loopin_builder.go @@ -0,0 +1,126 @@ +package liquidity + +import ( + "context" + + "github.com/btcsuite/btcutil" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/labels" + "github.com/lightninglabs/loop/swap" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Compile-time assertion that loopInBuilder satisfies the swapBuilder +// interface. +var _ swapBuilder = (*loopInBuilder)(nil) + +func newLoopInBuilder(cfg *Config) *loopInBuilder { + return &loopInBuilder{ + cfg: cfg, + } +} + +type loopInBuilder struct { + // cfg contains all the external functionality we require to create + // swaps. + cfg *Config +} + +// swapType returns the swap type that the builder is responsible for creating. +func (b *loopInBuilder) swapType() swap.Type { + return swap.TypeIn +} + +// maySwap checks whether we can currently execute a swap, examining the +// current on-chain fee conditions against relevant to our swap type against +// our fee restrictions. +// +// For loop in, we cannot check any upfront costs because we do not know how +// many inputs will be used for our on-chain htlc before it is made, so we can't +// make nay estimations. +func (b *loopInBuilder) maySwap(_ context.Context, _ Parameters) error { + return nil +} + +// inUse examines our current swap traffic to determine whether we should +// suggest the builder's type of swap for the peer and channels suggested. +func (b *loopInBuilder) inUse(traffic *swapTraffic, peer route.Vertex, + channels []lnwire.ShortChannelID) error { + + for _, chanID := range channels { + if traffic.ongoingLoopOut[chanID] { + log.Debugf("Channel: %v not eligible for suggestions, "+ + "ongoing loop out utilizing channel", chanID) + + return newReasonError(ReasonLoopOut) + } + } + + if traffic.ongoingLoopIn[peer] { + log.Debugf("Peer: %x not eligible for suggestions ongoing "+ + "loop in utilizing peer", peer) + + return newReasonError(ReasonLoopIn) + } + + lastFail, recentFail := traffic.failedLoopIn[peer] + if recentFail { + log.Debugf("Peer: %v not eligible for suggestions, "+ + "was part of a failed swap at: %v", peer, + lastFail) + + return newReasonError(ReasonFailureBackoff) + } + + return nil +} + +// buildSwap creates a swap for the target peer/channels provided. The autoloop +// boolean indicates whether this swap will actually be executed. +// +// For loop in, we do not add the autoloop label for dry runs. +func (b *loopInBuilder) buildSwap(ctx context.Context, pubkey route.Vertex, + _ []lnwire.ShortChannelID, amount btcutil.Amount, + autoloop bool, params Parameters) (swapSuggestion, error) { + + quote, err := b.cfg.LoopInQuote(ctx, &loop.LoopInQuoteRequest{ + Amount: amount, + LastHop: &pubkey, + HtlcConfTarget: params.HtlcConfTarget, + }) + if err != nil { + // If the server fails our quote, we're not reachable right + // now, so we want to catch this error and fail with a + // structured error so that we know why we can't swap. + status, ok := status.FromError(err) + if ok && status.Code() == codes.FailedPrecondition { + return nil, newReasonError(ReasonLoopInUnreachable) + } + + return nil, err + } + + if err := params.FeeLimit.loopInLimits(amount, quote); err != nil { + return nil, err + } + + request := loop.LoopInRequest{ + Amount: amount, + MaxSwapFee: quote.SwapFee, + MaxMinerFee: quote.MinerFee, + HtlcConfTarget: params.HtlcConfTarget, + LastHop: &pubkey, + Initiator: autoloopSwapInitiator, + } + + if autoloop { + request.Label = labels.AutoloopLabel(swap.TypeIn) + } + + return &loopInSwapSuggestion{ + LoopInRequest: request, + }, nil +} diff --git a/liquidity/loopin_builder_test.go b/liquidity/loopin_builder_test.go new file mode 100644 index 0000000..faef546 --- /dev/null +++ b/liquidity/loopin_builder_test.go @@ -0,0 +1,193 @@ +package liquidity + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcutil" + "github.com/lightninglabs/loop" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// TestLoopinInUse tests that the loop in swap builder prevents dispatching +// swaps for peers when there is already a swap running for that peer. +func TestLoopinInUse(t *testing.T) { + var ( + peer1 = route.Vertex{1} + chan1 = lnwire.NewShortChanIDFromInt(1) + + peer2 = route.Vertex{2} + chan2 = lnwire.NewShortChanIDFromInt(2) + ) + + tests := []struct { + name string + ongoingLoopOut *lnwire.ShortChannelID + ongoingLoopIn *route.Vertex + failedLoopIn *route.Vertex + expectedErr error + }{ + { + name: "swap allowed", + ongoingLoopIn: &peer2, + ongoingLoopOut: &chan2, + failedLoopIn: &peer2, + expectedErr: nil, + }, + { + name: "conflicts with loop out", + ongoingLoopOut: &chan1, + expectedErr: newReasonError(ReasonLoopOut), + }, + { + name: "conflicts with loop in", + ongoingLoopIn: &peer1, + expectedErr: newReasonError(ReasonLoopIn), + }, + { + name: "previous failed loopin", + failedLoopIn: &peer1, + expectedErr: newReasonError(ReasonFailureBackoff), + }, + } + + for _, testCase := range tests { + traffic := newSwapTraffic() + + if testCase.ongoingLoopOut != nil { + traffic.ongoingLoopOut[*testCase.ongoingLoopOut] = true + } + + if testCase.ongoingLoopIn != nil { + traffic.ongoingLoopIn[*testCase.ongoingLoopIn] = true + } + + if testCase.failedLoopIn != nil { + traffic.failedLoopIn[*testCase.failedLoopIn] = testTime + } + + builder := newLoopInBuilder(nil) + err := builder.inUse(traffic, peer1, []lnwire.ShortChannelID{ + chan1, + }) + + require.Equal(t, testCase.expectedErr, err) + } +} + +// TestLoopinBuildSwap tests construction of loop in swaps for autoloop, +// including the case where the client cannot get a quote because it is not +// reachable from the server. +func TestLoopinBuildSwap(t *testing.T) { + var ( + peer1 = route.Vertex{1} + chan1 = lnwire.NewShortChanIDFromInt(1) + + htlcConfTarget int32 = 6 + swapAmt btcutil.Amount = 100000 + + quote = &loop.LoopInQuote{ + SwapFee: 1, + MinerFee: 2, + } + + expectedSwap = &loopInSwapSuggestion{ + loop.LoopInRequest{ + Amount: swapAmt, + MaxSwapFee: quote.SwapFee, + MaxMinerFee: quote.MinerFee, + HtlcConfTarget: htlcConfTarget, + LastHop: &peer1, + Initiator: autoloopSwapInitiator, + }, + } + + quoteRequest = &loop.LoopInQuoteRequest{ + Amount: swapAmt, + LastHop: &peer1, + HtlcConfTarget: htlcConfTarget, + } + + errPrecondition = status.Error(codes.FailedPrecondition, "failed") + errOtherCode = status.Error(codes.DeadlineExceeded, "timeout") + errNoCode = errors.New("failure") + ) + + tests := []struct { + name string + prepareMock func(*mockCfg) + expectedSwap swapSuggestion + expectedErr error + }{ + { + name: "quote successful", + prepareMock: func(m *mockCfg) { + m.On( + "LoopInQuote", mock.Anything, + quoteRequest, + ).Return(quote, nil) + }, + expectedSwap: expectedSwap, + }, + { + name: "client unreachable", + prepareMock: func(m *mockCfg) { + m.On( + "LoopInQuote", mock.Anything, + quoteRequest, + ).Return(quote, errPrecondition) + }, + expectedSwap: nil, + expectedErr: newReasonError(ReasonLoopInUnreachable), + }, + { + name: "other error code", + prepareMock: func(m *mockCfg) { + m.On( + "LoopInQuote", mock.Anything, + quoteRequest, + ).Return(quote, errOtherCode) + }, + expectedSwap: nil, + expectedErr: errOtherCode, + }, + { + name: "no error code", + prepareMock: func(m *mockCfg) { + m.On( + "LoopInQuote", mock.Anything, + quoteRequest, + ).Return(quote, errNoCode) + }, + expectedSwap: nil, + expectedErr: errNoCode, + }, + } + + for _, testCase := range tests { + mock, cfg := newMockConfig() + params := defaultParameters + params.HtlcConfTarget = htlcConfTarget + params.AutoFeeBudget = 100000 + + testCase.prepareMock(mock) + + builder := newLoopInBuilder(cfg) + swap, err := builder.buildSwap( + context.Background(), peer1, []lnwire.ShortChannelID{ + chan1, + }, swapAmt, false, params, + ) + assert.Equal(t, testCase.expectedSwap, swap) + assert.Equal(t, testCase.expectedErr, err) + + mock.AssertExpectations(t) + } +} diff --git a/liquidity/mock.go b/liquidity/mock.go new file mode 100644 index 0000000..a23de57 --- /dev/null +++ b/liquidity/mock.go @@ -0,0 +1,33 @@ +package liquidity + +import ( + "context" + + "github.com/lightninglabs/loop" + "github.com/stretchr/testify/mock" +) + +// newMockConfig returns a liquidity config with mocked calls. Note that +// functions that are not implemented by the mock will panic if called. +func newMockConfig() (*mockCfg, *Config) { + mockCfg := &mockCfg{} + + // Create a liquidity config which calls our mock. + config := &Config{ + LoopInQuote: mockCfg.LoopInQuote, + } + + return mockCfg, config +} + +type mockCfg struct { + mock.Mock +} + +// LoopInQuote mocks a call to get a loop in quote from the server. +func (m *mockCfg) LoopInQuote(ctx context.Context, + request *loop.LoopInQuoteRequest) (*loop.LoopInQuote, error) { + + args := m.Called(ctx, request) + return args.Get(0).(*loop.LoopInQuote), args.Error(1) +} diff --git a/liquidity/reasons.go b/liquidity/reasons.go index 00601e8..aee4a4c 100644 --- a/liquidity/reasons.go +++ b/liquidity/reasons.go @@ -65,6 +65,10 @@ const ( // ReasonFeePPMInsufficient indicates that the fees a swap would require // are greater than the portion of swap amount allocated to fees. ReasonFeePPMInsufficient + + // ReasonLoopInUnreachable indicates that the server does not have a + // path to the client, so cannot perform a loop in swap at this time. + ReasonLoopInUnreachable ) // String returns a string representation of a reason. @@ -112,6 +116,9 @@ func (r Reason) String() string { case ReasonFeePPMInsufficient: return "fee portion insufficient" + case ReasonLoopInUnreachable: + return "loop in unreachable" + default: return "unknown" } diff --git a/loopd/utils.go b/loopd/utils.go index 59e8cca..9db9c85 100644 --- a/loopd/utils.go +++ b/loopd/utils.go @@ -65,6 +65,7 @@ func getLiquidityManager(client *loop.Client) *liquidity.Manager { Lnd: client.LndServices, Clock: clock.NewDefaultClock(), LoopOutQuote: client.LoopOutQuote, + LoopInQuote: client.LoopInQuote, ListLoopOut: client.Store.FetchLoopOutSwaps, ListLoopIn: client.Store.FetchLoopInSwaps, MinimumConfirmations: minConfTarget,