multi: add loop in swap builder implementation

pull/419/head
carla 2 years ago
parent b09969e167
commit 5e47a0c6e9
No known key found for this signature in database
GPG Key ID: 4CA7FE54A6213C91

@ -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,
}
)

@ -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
}

@ -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
}

@ -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)
}
}

@ -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)
}

@ -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"
}

@ -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,

Loading…
Cancel
Save