mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
multi: add loop in swap builder implementation
This commit is contained in:
parent
b09969e167
commit
5e47a0c6e9
7 changed files with 384 additions and 4 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
126
liquidity/loopin_builder.go
Normal file
126
liquidity/loopin_builder.go
Normal file
|
|
@ -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
|
||||
}
|
||||
193
liquidity/loopin_builder_test.go
Normal file
193
liquidity/loopin_builder_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
33
liquidity/mock.go
Normal file
33
liquidity/mock.go
Normal file
|
|
@ -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…
Add table
Add a link
Reference in a new issue