Merge pull request #433 from carlaKC/419-swaptype

liquidity: add swap type and loop in fee logic
pull/434/head
Carla Kirk-Cohen 3 years ago committed by GitHub
commit 2e6adeaa3f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -168,6 +168,7 @@ func setRule(ctx *cli.Context) error {
newRule := &looprpc.LiquidityRule{ newRule := &looprpc.LiquidityRule{
ChannelId: chanID, ChannelId: chanID,
Type: looprpc.LiquidityRuleType_THRESHOLD, Type: looprpc.LiquidityRuleType_THRESHOLD,
SwapType: looprpc.SwapType_LOOP_OUT,
} }
if pubkeyRule { if pubkeyRule {

@ -27,7 +27,7 @@ func TestAutoLoopDisabled(t *testing.T) {
} }
params := defaultParameters params := defaultParameters
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule, chanID1: chanRule,
} }
@ -95,7 +95,7 @@ func TestAutoLoopEnabled(t *testing.T) {
swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner, swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner,
prepayAmount, 20000, prepayAmount, 20000,
), ),
ChannelRules: map[lnwire.ShortChannelID]*ThresholdRule{ ChannelRules: map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule, chanID1: chanRule,
chanID2: chanRule, chanID2: chanRule,
}, },
@ -312,10 +312,10 @@ func TestCompositeRules(t *testing.T) {
MaxAutoInFlight: 2, MaxAutoInFlight: 2,
FailureBackOff: time.Hour, FailureBackOff: time.Hour,
SweepConfTarget: 10, SweepConfTarget: 10,
ChannelRules: map[lnwire.ShortChannelID]*ThresholdRule{ ChannelRules: map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule, chanID1: chanRule,
}, },
PeerRules: map[route.Vertex]*ThresholdRule{ PeerRules: map[route.Vertex]*SwapRule{
peer2: chanRule, peer2: chanRule,
}, },
} }

@ -223,6 +223,27 @@ func (f *FeeCategoryLimit) loopOutLimits(amount btcutil.Amount,
return nil return nil
} }
func (f *FeeCategoryLimit) loopInLimits(amount btcutil.Amount,
quote *loop.LoopInQuote) error {
maxServerFee := ppmToSat(amount, f.MaximumSwapFeePPM)
if quote.SwapFee > maxServerFee {
log.Debugf("quoted swap fee: %v > maximum swap fee: %v",
quote.SwapFee, maxServerFee)
return newReasonError(ReasonSwapFee)
}
if quote.MinerFee > f.MaximumMinerFee {
log.Debugf("quoted miner fee: %v > maximum miner "+
"fee: %v", quote.MinerFee, f.MaximumMinerFee)
return newReasonError(ReasonMinerFee)
}
return nil
}
// loopOutFees returns the prepay and routing and miner fees we are willing to // loopOutFees returns the prepay and routing and miner fees we are willing to
// pay for a loop out swap. // pay for a loop out swap.
func (f *FeeCategoryLimit) loopOutFees(amount btcutil.Amount, func (f *FeeCategoryLimit) loopOutFees(amount btcutil.Amount,
@ -384,3 +405,42 @@ func splitOffChain(available, prepayAmt,
func scaleMinerFee(estimate btcutil.Amount) btcutil.Amount { func scaleMinerFee(estimate btcutil.Amount) btcutil.Amount {
return estimate * btcutil.Amount(minerMultiplier) return estimate * btcutil.Amount(minerMultiplier)
} }
func (f *FeePortion) loopInLimits(amount btcutil.Amount,
quote *loop.LoopInQuote) error {
// Calculate the total amount that this swap may spend in fees, as a
// portion of the swap amount.
totalFeeSpend := ppmToSat(amount, f.PartsPerMillion)
// Check individual fee components so that we can give more specific
// feedback.
if quote.MinerFee > totalFeeSpend {
log.Debugf("miner fee: %v greater than fee limit: %v, at "+
"%v ppm", quote.MinerFee, totalFeeSpend,
f.PartsPerMillion)
return newReasonError(ReasonMinerFee)
}
if quote.SwapFee > totalFeeSpend {
log.Debugf("swap fee: %v greater than fee limit: %v, at "+
"%v ppm", quote.SwapFee, totalFeeSpend,
f.PartsPerMillion)
return newReasonError(ReasonSwapFee)
}
fees := worstCaseInFees(
quote.MinerFee, quote.SwapFee, defaultLoopInSweepFee,
)
if fees > totalFeeSpend {
log.Debugf("total fees for swap: %v > fee limit: %v, at "+
"%v ppm", fees, totalFeeSpend, f.PartsPerMillion)
return newReasonError(ReasonFeePPMInsufficient)
}
return nil
}

@ -32,6 +32,11 @@ type FeeLimit interface {
// a swap amount and quote. // a swap amount and quote.
loopOutFees(amount btcutil.Amount, quote *loop.LoopOutQuote) ( loopOutFees(amount btcutil.Amount, quote *loop.LoopOutQuote) (
btcutil.Amount, btcutil.Amount, btcutil.Amount) btcutil.Amount, btcutil.Amount, btcutil.Amount)
// loopInLimits checks whether the quote provided is within our fee
// limits for the swap amount.
loopInLimits(amount btcutil.Amount,
quote *loop.LoopInQuote) error
} }
// swapBuilder is an interface used to build our different swap types. // swapBuilder is an interface used to build our different swap types.

@ -81,6 +81,12 @@ const (
// autoloopSwapInitiator is the value we send in the initiator field of // autoloopSwapInitiator is the value we send in the initiator field of
// a swap request when issuing an automatic swap. // a swap request when issuing an automatic swap.
autoloopSwapInitiator = "autoloop" autoloopSwapInitiator = "autoloop"
// We use a static fee rate to estimate our sweep fee, because we
// can't realistically estimate what our fee estimate will be by the
// time we reach timeout. We set this to a high estimate so that we can
// account for worst-case fees, (1250 * 4 / 1000) = 50 sat/byte.
defaultLoopInSweepFee = chainfee.SatPerKWeight(1250)
) )
var ( var (
@ -97,8 +103,8 @@ var (
defaultParameters = Parameters{ defaultParameters = Parameters{
AutoFeeBudget: defaultBudget, AutoFeeBudget: defaultBudget,
MaxAutoInFlight: defaultMaxInFlight, MaxAutoInFlight: defaultMaxInFlight,
ChannelRules: make(map[lnwire.ShortChannelID]*ThresholdRule), ChannelRules: make(map[lnwire.ShortChannelID]*SwapRule),
PeerRules: make(map[route.Vertex]*ThresholdRule), PeerRules: make(map[route.Vertex]*SwapRule),
FailureBackOff: defaultFailureBackoff, FailureBackOff: defaultFailureBackoff,
SweepConfTarget: defaultConfTarget, SweepConfTarget: defaultConfTarget,
FeeLimit: defaultFeePortion(), FeeLimit: defaultFeePortion(),
@ -216,13 +222,13 @@ type Parameters struct {
// ChannelRules maps a short channel ID to a rule that describes how we // ChannelRules maps a short channel ID to a rule that describes how we
// would like liquidity to be managed. These rules and PeerRules are // would like liquidity to be managed. These rules and PeerRules are
// exclusively set to prevent overlap between peer and channel rules. // exclusively set to prevent overlap between peer and channel rules.
ChannelRules map[lnwire.ShortChannelID]*ThresholdRule ChannelRules map[lnwire.ShortChannelID]*SwapRule
// PeerRules maps a peer's pubkey to a rule that applies to all the // PeerRules maps a peer's pubkey to a rule that applies to all the
// channels that we have with the peer collectively. These rules and // channels that we have with the peer collectively. These rules and
// ChannelRules are exclusively set to prevent overlap between peer // ChannelRules are exclusively set to prevent overlap between peer
// and channel rules map to avoid ambiguity. // and channel rules map to avoid ambiguity.
PeerRules map[route.Vertex]*ThresholdRule PeerRules map[route.Vertex]*SwapRule
} }
// String returns the string representation of our parameters. // String returns the string representation of our parameters.
@ -386,10 +392,6 @@ type Manager struct {
// current liquidity balance. // current liquidity balance.
cfg *Config cfg *Config
// builder is the swap builder responsible for creating swaps of our
// chosen type for us.
builder swapBuilder
// params is the set of parameters we are currently using. These may be // params is the set of parameters we are currently using. These may be
// updated at runtime. // updated at runtime.
params Parameters params Parameters
@ -428,9 +430,8 @@ func (m *Manager) Run(ctx context.Context) error {
// NewManager creates a liquidity manager which has no rules set. // NewManager creates a liquidity manager which has no rules set.
func NewManager(cfg *Config) *Manager { func NewManager(cfg *Config) *Manager {
return &Manager{ return &Manager{
cfg: cfg, cfg: cfg,
params: defaultParameters, params: defaultParameters,
builder: newLoopOutBuilder(cfg),
} }
} }
@ -473,7 +474,7 @@ func (m *Manager) SetParameters(ctx context.Context, params Parameters) error {
func cloneParameters(params Parameters) Parameters { func cloneParameters(params Parameters) Parameters {
paramCopy := params paramCopy := params
paramCopy.ChannelRules = make( paramCopy.ChannelRules = make(
map[lnwire.ShortChannelID]*ThresholdRule, map[lnwire.ShortChannelID]*SwapRule,
len(params.ChannelRules), len(params.ChannelRules),
) )
@ -483,7 +484,7 @@ func cloneParameters(params Parameters) Parameters {
} }
paramCopy.PeerRules = make( paramCopy.PeerRules = make(
map[route.Vertex]*ThresholdRule, map[route.Vertex]*SwapRule,
len(params.PeerRules), len(params.PeerRules),
) )
@ -617,23 +618,8 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) (
return m.singleReasonSuggestion(ReasonBudgetNotStarted), nil return m.singleReasonSuggestion(ReasonBudgetNotStarted), nil
} }
// Before we get any swap suggestions, we check what the current fee // Get restrictions placed on swaps by the server.
// estimate is to sweep within our target number of confirmations. If outRestrictions, err := m.getSwapRestrictions(ctx, swap.TypeOut)
// This fee exceeds the fee limit we have set, we will not suggest any
// swaps at present.
if err := m.builder.maySwap(ctx, m.params); err != nil {
var reasonErr *reasonError
if errors.As(err, &reasonErr) {
return m.singleReasonSuggestion(reasonErr.reason), nil
}
return nil, err
}
// Get the current server side restrictions, combined with the client
// set restrictions, if any.
restrictions, err := m.getSwapRestrictions(ctx, m.builder.swapType())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -653,7 +639,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) (
// Get a summary of our existing swaps so that we can check our autoloop // Get a summary of our existing swaps so that we can check our autoloop
// budget. // budget.
summary, err := m.checkExistingAutoLoops(ctx, loopOut) summary, err := m.checkExistingAutoLoops(ctx, loopOut, loopIn)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -721,7 +707,8 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) (
} }
suggestion, err := m.suggestSwap( suggestion, err := m.suggestSwap(
ctx, traffic, balances, rule, restrictions, autoloop, ctx, traffic, balances, rule, outRestrictions,
autoloop,
) )
var reasonErr *reasonError var reasonErr *reasonError
if errors.As(err, &reasonErr) { if errors.As(err, &reasonErr) {
@ -746,7 +733,8 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) (
} }
suggestion, err := m.suggestSwap( suggestion, err := m.suggestSwap(
ctx, traffic, balance, rule, restrictions, autoloop, ctx, traffic, balance, rule, outRestrictions,
autoloop,
) )
var reasonErr *reasonError var reasonErr *reasonError
@ -841,12 +829,34 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) (
// suggestSwap checks whether we can currently perform a swap, and creates a // suggestSwap checks whether we can currently perform a swap, and creates a
// swap request for the rule provided. // swap request for the rule provided.
func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic, func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic,
balance *balances, rule *ThresholdRule, restrictions *Restrictions, balance *balances, rule *SwapRule, outRestrictions *Restrictions,
autoloop bool) (swapSuggestion, error) { autoloop bool) (swapSuggestion, error) {
var (
builder swapBuilder
restrictions *Restrictions
)
switch rule.Type {
case swap.TypeOut:
builder = newLoopOutBuilder(m.cfg)
restrictions = outRestrictions
default:
return nil, fmt.Errorf("unsupported swap type: %v", rule.Type)
}
// Before we get any swap suggestions, we check what the current fee
// estimate is to sweep within our target number of confirmations. If
// This fee exceeds the fee limit we have set, we will not suggest any
// swaps at present.
if err := builder.maySwap(ctx, m.params); err != nil {
return nil, err
}
// First, check whether this peer/channel combination is already in use // First, check whether this peer/channel combination is already in use
// for our swap. // for our swap.
err := m.builder.inUse(traffic, balance.pubkey, balance.channels) err := builder.inUse(traffic, balance.pubkey, balance.channels)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -858,7 +868,7 @@ func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic,
return nil, newReasonError(ReasonLiquidityOk) return nil, newReasonError(ReasonLiquidityOk)
} }
return m.builder.buildSwap( return builder.buildSwap(
ctx, balance.pubkey, balance.channels, amount, autoloop, ctx, balance.pubkey, balance.channels, amount, autoloop,
m.params, m.params,
) )
@ -948,7 +958,8 @@ func (e *existingAutoLoopSummary) totalFees() btcutil.Amount {
// total for our set of ongoing, automatically dispatched swaps as well as a // total for our set of ongoing, automatically dispatched swaps as well as a
// current in-flight count. // current in-flight count.
func (m *Manager) checkExistingAutoLoops(ctx context.Context, func (m *Manager) checkExistingAutoLoops(ctx context.Context,
loopOuts []*loopdb.LoopOut) (*existingAutoLoopSummary, error) { loopOuts []*loopdb.LoopOut, loopIns []*loopdb.LoopIn) (
*existingAutoLoopSummary, error) {
var summary existingAutoLoopSummary var summary existingAutoLoopSummary
@ -987,6 +998,28 @@ func (m *Manager) checkExistingAutoLoops(ctx context.Context,
} }
} }
for _, in := range loopIns {
if in.Contract.Label != labels.AutoloopLabel(swap.TypeIn) {
continue
}
pending := in.State().State.Type() == loopdb.StateTypePending
inBudget := !in.LastUpdateTime().Before(m.params.AutoFeeStartDate)
// If an autoloop is in a pending state, we always count it in
// our current budget, and record the worst-case fees for it,
// because we do not know how it will resolve.
if pending {
summary.inFlightCount++
summary.pendingFees += worstCaseInFees(
in.Contract.MaxMinerFee, in.Contract.MaxSwapFee,
defaultLoopInSweepFee,
)
} else if inBudget {
summary.spentFees += in.State().Cost.Total()
}
}
return &summary, nil return &summary, nil
} }
@ -1051,17 +1084,28 @@ func (m *Manager) currentSwapTraffic(loopOut []*loopdb.LoopOut,
} }
for _, in := range loopIn { for _, in := range loopIn {
// Skip completed swaps, they can't affect our channel balances.
if in.State().State.Type() != loopdb.StateTypePending {
continue
}
// Skip over swaps that may come through any peer. // Skip over swaps that may come through any peer.
if in.Contract.LastHop == nil { if in.Contract.LastHop == nil {
continue continue
} }
traffic.ongoingLoopIn[*in.Contract.LastHop] = true pubkey := *in.Contract.LastHop
switch {
// Include any pending swaps in our ongoing set of swaps.
case in.State().State.Type() == loopdb.StateTypePending:
traffic.ongoingLoopIn[pubkey] = true
// If a swap failed with an on-chain timeout, the server could
// not route to us. We add it to our backoff list so that
// there's some time for routing conditions to improve.
case in.State().State == loopdb.StateFailTimeout:
failedAt := in.LastUpdate().Time
if failedAt.After(failureCutoff) {
traffic.failedLoopIn[pubkey] = failedAt
}
}
} }
return traffic return traffic
@ -1072,6 +1116,7 @@ type swapTraffic struct {
ongoingLoopOut map[lnwire.ShortChannelID]bool ongoingLoopOut map[lnwire.ShortChannelID]bool
ongoingLoopIn map[route.Vertex]bool ongoingLoopIn map[route.Vertex]bool
failedLoopOut map[lnwire.ShortChannelID]time.Time failedLoopOut map[lnwire.ShortChannelID]time.Time
failedLoopIn map[route.Vertex]time.Time
} }
func newSwapTraffic() *swapTraffic { func newSwapTraffic() *swapTraffic {
@ -1079,6 +1124,7 @@ func newSwapTraffic() *swapTraffic {
ongoingLoopOut: make(map[lnwire.ShortChannelID]bool), ongoingLoopOut: make(map[lnwire.ShortChannelID]bool),
ongoingLoopIn: make(map[route.Vertex]bool), ongoingLoopIn: make(map[route.Vertex]bool),
failedLoopOut: make(map[lnwire.ShortChannelID]time.Time), failedLoopOut: make(map[lnwire.ShortChannelID]time.Time),
failedLoopIn: make(map[route.Vertex]time.Time),
} }
} }

@ -47,7 +47,10 @@ var (
} }
// chanRule is a rule that produces chan1Rec. // chanRule is a rule that produces chan1Rec.
chanRule = NewThresholdRule(50, 0) chanRule = &SwapRule{
ThresholdRule: NewThresholdRule(50, 0),
Type: swap.TypeOut,
}
testQuote = &loop.LoopOutQuote{ testQuote = &loop.LoopOutQuote{
SwapFee: btcutil.Amount(5), SwapFee: btcutil.Amount(5),
@ -104,6 +107,13 @@ var (
OutgoingChanSet: loopdb.ChannelSet{999}, OutgoingChanSet: loopdb.ChannelSet{999},
} }
autoInContract = &loopdb.LoopInContract{
SwapContract: loopdb.SwapContract{
Label: labels.AutoloopLabel(swap.TypeIn),
InitiationTime: testBudgetStart,
},
}
testRestrictions = NewRestrictions(1, 10000) testRestrictions = NewRestrictions(1, 10000)
// noneDisqualified can be used in tests where we don't have any // noneDisqualified can be used in tests where we don't have any
@ -188,7 +198,10 @@ func TestParameters(t *testing.T) {
require.Equal(t, defaultParameters, startParams) require.Equal(t, defaultParameters, startParams)
// Mutate the parameters returned by our get function. // Mutate the parameters returned by our get function.
startParams.ChannelRules[chanID] = NewThresholdRule(1, 1) startParams.ChannelRules[chanID] = &SwapRule{
ThresholdRule: NewThresholdRule(1, 1),
Type: swap.TypeOut,
}
// Make sure that we have not mutated the liquidity manager's params // Make sure that we have not mutated the liquidity manager's params
// by making this change. // by making this change.
@ -197,9 +210,13 @@ func TestParameters(t *testing.T) {
// Provide a valid set of parameters and validate assert that they are // Provide a valid set of parameters and validate assert that they are
// set. // set.
originalRule := NewThresholdRule(10, 10) originalRule := &SwapRule{
ThresholdRule: NewThresholdRule(10, 10),
Type: swap.TypeOut,
}
expected := defaultParameters expected := defaultParameters
expected.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ expected.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
chanID: originalRule, chanID: originalRule,
} }
@ -208,15 +225,21 @@ func TestParameters(t *testing.T) {
// Check that changing the parameters we just set does not mutate // Check that changing the parameters we just set does not mutate
// our liquidity manager's parameters. // our liquidity manager's parameters.
expected.ChannelRules[chanID] = NewThresholdRule(11, 11) expected.ChannelRules[chanID] = &SwapRule{
ThresholdRule: NewThresholdRule(11, 11),
Type: swap.TypeOut,
}
params = manager.GetParameters() params = manager.GetParameters()
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, originalRule, params.ChannelRules[chanID]) require.Equal(t, originalRule, params.ChannelRules[chanID])
// Set invalid parameters and assert that we fail. // Set invalid parameters and assert that we fail.
expected.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ expected.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
lnwire.NewShortChanIDFromInt(0): NewThresholdRule(1, 2), lnwire.NewShortChanIDFromInt(0): {
ThresholdRule: NewThresholdRule(1, 2),
Type: swap.TypeOut,
},
} }
err = manager.SetParameters(context.Background(), expected) err = manager.SetParameters(context.Background(), expected)
require.Equal(t, ErrZeroChannelID, err) require.Equal(t, ErrZeroChannelID, err)
@ -310,7 +333,7 @@ func TestRestrictedSuggestions(t *testing.T) {
), ),
} }
chanRules = map[lnwire.ShortChannelID]*ThresholdRule{ chanRules = map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule, chanID1: chanRule,
chanID2: chanRule, chanID2: chanRule,
} }
@ -321,8 +344,8 @@ func TestRestrictedSuggestions(t *testing.T) {
channels []lndclient.ChannelInfo channels []lndclient.ChannelInfo
loopOut []*loopdb.LoopOut loopOut []*loopdb.LoopOut
loopIn []*loopdb.LoopIn loopIn []*loopdb.LoopIn
chanRules map[lnwire.ShortChannelID]*ThresholdRule chanRules map[lnwire.ShortChannelID]*SwapRule
peerRules map[route.Vertex]*ThresholdRule peerRules map[route.Vertex]*SwapRule
expected *Suggestions expected *Suggestions
}{ }{
{ {
@ -511,8 +534,11 @@ func TestRestrictedSuggestions(t *testing.T) {
Contract: chan1Out, Contract: chan1Out,
}, },
}, },
peerRules: map[route.Vertex]*ThresholdRule{ peerRules: map[route.Vertex]*SwapRule{
peer1: NewThresholdRule(0, 50), peer1: {
ThresholdRule: NewThresholdRule(0, 50),
Type: swap.TypeOut,
},
}, },
expected: &Suggestions{ expected: &Suggestions{
DisqualifiedChans: noneDisqualified, DisqualifiedChans: noneDisqualified,
@ -629,7 +655,7 @@ func TestSweepFeeLimit(t *testing.T) {
ppmToSat(7500, defaultPrepayRoutingFeePPM) + ppmToSat(7500, defaultPrepayRoutingFeePPM) +
ppmToSat(7500, defaultRoutingFeePPM) ppmToSat(7500, defaultRoutingFeePPM)
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule, chanID1: chanRule,
} }
@ -654,21 +680,21 @@ func TestSuggestSwaps(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
channels []lndclient.ChannelInfo channels []lndclient.ChannelInfo
rules map[lnwire.ShortChannelID]*ThresholdRule rules map[lnwire.ShortChannelID]*SwapRule
peerRules map[route.Vertex]*ThresholdRule peerRules map[route.Vertex]*SwapRule
suggestions *Suggestions suggestions *Suggestions
err error err error
}{ }{
{ {
name: "no rules", name: "no rules",
channels: singleChannel, channels: singleChannel,
rules: map[lnwire.ShortChannelID]*ThresholdRule{}, rules: map[lnwire.ShortChannelID]*SwapRule{},
err: ErrNoRules, err: ErrNoRules,
}, },
{ {
name: "loop out", name: "loop out",
channels: singleChannel, channels: singleChannel,
rules: map[lnwire.ShortChannelID]*ThresholdRule{ rules: map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule, chanID1: chanRule,
}, },
suggestions: &Suggestions{ suggestions: &Suggestions{
@ -682,8 +708,11 @@ func TestSuggestSwaps(t *testing.T) {
{ {
name: "no rule for channel", name: "no rule for channel",
channels: singleChannel, channels: singleChannel,
rules: map[lnwire.ShortChannelID]*ThresholdRule{ rules: map[lnwire.ShortChannelID]*SwapRule{
chanID2: NewThresholdRule(10, 10), chanID2: {
ThresholdRule: NewThresholdRule(10, 10),
Type: swap.TypeOut,
},
}, },
suggestions: &Suggestions{ suggestions: &Suggestions{
DisqualifiedChans: noneDisqualified, DisqualifiedChans: noneDisqualified,
@ -715,9 +744,15 @@ func TestSuggestSwaps(t *testing.T) {
RemoteBalance: 3000, RemoteBalance: 3000,
}, },
}, },
peerRules: map[route.Vertex]*ThresholdRule{ peerRules: map[route.Vertex]*SwapRule{
peer1: NewThresholdRule(80, 0), peer1: {
peer2: NewThresholdRule(40, 50), ThresholdRule: NewThresholdRule(80, 0),
Type: swap.TypeOut,
},
peer2: {
ThresholdRule: NewThresholdRule(40, 50),
Type: swap.TypeOut,
},
}, },
suggestions: &Suggestions{ suggestions: &Suggestions{
OutSwaps: []loop.OutRequest{ OutSwaps: []loop.OutRequest{
@ -869,7 +904,7 @@ func TestFeeLimits(t *testing.T) {
ppmToSat(7500, defaultPrepayRoutingFeePPM) + ppmToSat(7500, defaultPrepayRoutingFeePPM) +
ppmToSat(7500, defaultRoutingFeePPM) ppmToSat(7500, defaultRoutingFeePPM)
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule, chanID1: chanRule,
} }
@ -1061,7 +1096,7 @@ func TestFeeBudget(t *testing.T) {
} }
params := defaultParameters params := defaultParameters
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule, chanID1: chanRule,
chanID2: chanRule, chanID2: chanRule,
} }
@ -1095,12 +1130,13 @@ func TestFeeBudget(t *testing.T) {
// that are allowed. // that are allowed.
func TestInFlightLimit(t *testing.T) { func TestInFlightLimit(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
maxInFlight int maxInFlight int
existingSwaps []*loopdb.LoopOut existingSwaps []*loopdb.LoopOut
existingInSwaps []*loopdb.LoopIn
// peerRules will only be set (instead of test default values) // peerRules will only be set (instead of test default values)
// is it is non-nil. // is it is non-nil.
peerRules map[route.Vertex]*ThresholdRule peerRules map[route.Vertex]*SwapRule
suggestions *Suggestions suggestions *Suggestions
}{ }{
{ {
@ -1166,8 +1202,10 @@ func TestInFlightLimit(t *testing.T) {
{ {
Contract: autoOutContract, Contract: autoOutContract,
}, },
},
existingInSwaps: []*loopdb.LoopIn{
{ {
Contract: autoOutContract, Contract: autoInContract,
}, },
}, },
suggestions: &Suggestions{ suggestions: &Suggestions{
@ -1189,9 +1227,15 @@ func TestInFlightLimit(t *testing.T) {
// Create two peer-level rules, both in need of a swap, // Create two peer-level rules, both in need of a swap,
// but peer 1 needs a larger swap so will be // but peer 1 needs a larger swap so will be
// prioritized. // prioritized.
peerRules: map[route.Vertex]*ThresholdRule{ peerRules: map[route.Vertex]*SwapRule{
peer1: NewThresholdRule(50, 0), peer1: {
peer2: NewThresholdRule(40, 0), ThresholdRule: NewThresholdRule(50, 0),
Type: swap.TypeOut,
},
peer2: {
ThresholdRule: NewThresholdRule(40, 0),
Type: swap.TypeOut,
},
}, },
suggestions: &Suggestions{ suggestions: &Suggestions{
OutSwaps: []loop.OutRequest{ OutSwaps: []loop.OutRequest{
@ -1213,6 +1257,9 @@ func TestInFlightLimit(t *testing.T) {
cfg.ListLoopOut = func() ([]*loopdb.LoopOut, error) { cfg.ListLoopOut = func() ([]*loopdb.LoopOut, error) {
return testCase.existingSwaps, nil return testCase.existingSwaps, nil
} }
cfg.ListLoopIn = func() ([]*loopdb.LoopIn, error) {
return testCase.existingInSwaps, nil
}
lnd.Channels = []lndclient.ChannelInfo{ lnd.Channels = []lndclient.ChannelInfo{
channel1, channel2, channel1, channel2,
@ -1224,7 +1271,7 @@ func TestInFlightLimit(t *testing.T) {
params.PeerRules = testCase.peerRules params.PeerRules = testCase.peerRules
} else { } else {
params.ChannelRules = params.ChannelRules =
map[lnwire.ShortChannelID]*ThresholdRule{ map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule, chanID1: chanRule,
chanID2: chanRule, chanID2: chanRule,
} }
@ -1364,7 +1411,7 @@ func TestSizeRestrictions(t *testing.T) {
params := defaultParameters params := defaultParameters
params.ClientRestrictions = testCase.clientRestrictions params.ClientRestrictions = testCase.clientRestrictions
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule, chanID1: chanRule,
} }
@ -1522,10 +1569,185 @@ func TestFeePercentage(t *testing.T) {
params := defaultParameters params := defaultParameters
params.FeeLimit = NewFeePortion(testCase.feePPM) params.FeeLimit = NewFeePortion(testCase.feePPM)
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule,
}
testSuggestSwaps(
t, newSuggestSwapsSetup(cfg, lnd, params),
testCase.suggestions, nil,
)
})
}
}
// TestBudgetWithLoopin tests that our autoloop budget accounts for loop in
// swaps that have been automatically dispatched. It tests out swaps that have
// already completed and those that are pending, inside and outside of our
// budget period to ensure that we account for all relevant swaps.
func TestBudgetWithLoopin(t *testing.T) {
var (
budget btcutil.Amount = 10000
outsideBudget = testBudgetStart.Add(-5)
insideBudget = testBudgetStart.Add(5)
contractOutsideBudget = &loopdb.LoopInContract{
SwapContract: loopdb.SwapContract{
InitiationTime: outsideBudget,
MaxSwapFee: budget,
},
Label: labels.AutoloopLabel(swap.TypeIn),
}
// Set our spend equal to our budget so we don't need to
// calculate exact costs.
eventOutsideBudget = &loopdb.LoopEvent{
SwapStateData: loopdb.SwapStateData{
Cost: loopdb.SwapCost{
Server: budget,
},
State: loopdb.StateSuccess,
},
Time: outsideBudget,
}
successWithinBudget = &loopdb.LoopEvent{
SwapStateData: loopdb.SwapStateData{
Cost: loopdb.SwapCost{
Server: budget,
},
State: loopdb.StateSuccess,
},
Time: insideBudget,
}
okQuote = &loop.LoopOutQuote{
SwapFee: 15,
PrepayAmount: 30,
MinerFee: 1,
}
rec = loop.OutRequest{
Amount: 7500,
OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()},
MaxMinerFee: scaleMinerFee(okQuote.MinerFee),
MaxSwapFee: okQuote.SwapFee,
MaxPrepayAmount: okQuote.PrepayAmount,
SweepConfTarget: defaultConfTarget,
Initiator: autoloopSwapInitiator,
}
testPPM uint64 = 100000
)
rec.MaxPrepayRoutingFee, rec.MaxSwapRoutingFee = testPPMFees(
testPPM, okQuote, 7500,
)
tests := []struct {
name string
// loopIns is the set of loop in swaps that the client has
// performed.
loopIns []*loopdb.LoopIn
// suggestions is the set of swaps that we expect to be
// suggested given our current traffic.
suggestions *Suggestions
}{
{
name: "completed swap outside of budget",
loopIns: []*loopdb.LoopIn{
{
Loop: loopdb.Loop{
Events: []*loopdb.LoopEvent{
eventOutsideBudget,
},
},
Contract: contractOutsideBudget,
},
},
suggestions: &Suggestions{
OutSwaps: []loop.OutRequest{
rec,
},
DisqualifiedChans: noneDisqualified,
DisqualifiedPeers: noPeersDisqualified,
},
},
{
name: "completed within budget",
loopIns: []*loopdb.LoopIn{
{
Loop: loopdb.Loop{
Events: []*loopdb.LoopEvent{
successWithinBudget,
},
},
Contract: contractOutsideBudget,
},
},
suggestions: &Suggestions{
DisqualifiedChans: map[lnwire.ShortChannelID]Reason{
chanID1: ReasonBudgetElapsed,
},
DisqualifiedPeers: noPeersDisqualified,
},
},
{
name: "pending created before budget",
loopIns: []*loopdb.LoopIn{
{
Contract: contractOutsideBudget,
},
},
suggestions: &Suggestions{
DisqualifiedChans: map[lnwire.ShortChannelID]Reason{
chanID1: ReasonBudgetElapsed,
},
DisqualifiedPeers: noPeersDisqualified,
},
},
}
for _, testCase := range tests {
testCase := testCase
t.Run(testCase.name, func(t *testing.T) {
cfg, lnd := newTestConfig()
// Set our channel and rules so that we will need to
// swap 7500 sats and our fee limit is 10% of that
// amount (750 sats).
lnd.Channels = []lndclient.ChannelInfo{
channel1,
}
cfg.ListLoopIn = func() ([]*loopdb.LoopIn, error) {
return testCase.loopIns, nil
}
cfg.LoopOutQuote = func(_ context.Context,
_ *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote,
error) {
return okQuote, nil
}
params := defaultParameters
params.AutoFeeBudget = budget
params.AutoFeeStartDate = testBudgetStart
params.FeeLimit = NewFeePortion(testPPM)
params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule, chanID1: chanRule,
} }
// Allow more than one in flight swap, to ensure that
// we restrict based on budget, not in-flight.
params.MaxAutoInFlight = 2
testSuggestSwaps( testSuggestSwaps(
t, newSuggestSwapsSetup(cfg, lnd, params), t, newSuggestSwapsSetup(cfg, lnd, params),
testCase.suggestions, nil, testCase.suggestions, nil,
@ -1572,7 +1794,7 @@ func testSuggestSwaps(t *testing.T, setup *testSuggestSwapsSetup,
} }
params := defaultParameters params := defaultParameters
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule, chanID1: chanRule,
chanID2: chanRule, chanID2: chanRule,
} }
@ -1595,3 +1817,184 @@ func testSuggestSwaps(t *testing.T, setup *testSuggestSwapsSetup,
require.Equal(t, expectedErr, err) require.Equal(t, expectedErr, err)
require.Equal(t, expected, actual) require.Equal(t, expected, actual)
} }
// TestCurrentTraffic tests recording of our current set of ongoing swaps.
func TestCurrentTraffic(t *testing.T) {
var (
backoff = time.Hour * 5
withinBackoff = testTime.Add(time.Hour * -1)
outsideBackoff = testTime.Add(backoff * -2)
success = []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateSuccess,
},
},
}
failedInBackoff = []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateFailOffchainPayments,
},
Time: withinBackoff,
},
}
failedOutsideBackoff = []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateFailOffchainPayments,
},
Time: outsideBackoff,
},
}
failedTimeoutInBackoff = []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateFailTimeout,
},
Time: withinBackoff,
},
}
failedTimeoutOutsideBackoff = []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateFailTimeout,
},
Time: outsideBackoff,
},
}
)
tests := []struct {
name string
loopOut []*loopdb.LoopOut
loopIn []*loopdb.LoopIn
expected *swapTraffic
}{
{
name: "completed swaps ignored",
loopOut: []*loopdb.LoopOut{
{
Loop: loopdb.Loop{
Events: success,
},
Contract: &loopdb.LoopOutContract{},
},
},
loopIn: []*loopdb.LoopIn{
{
Loop: loopdb.Loop{
Events: success,
},
Contract: &loopdb.LoopInContract{},
},
},
expected: newSwapTraffic(),
},
{
// No events indicates that the swap is still pending.
name: "pending swaps included",
loopOut: []*loopdb.LoopOut{
{
Contract: &loopdb.LoopOutContract{
OutgoingChanSet: []uint64{
chanID1.ToUint64(),
},
},
},
},
loopIn: []*loopdb.LoopIn{
{
Contract: &loopdb.LoopInContract{
LastHop: &peer2,
},
},
},
expected: &swapTraffic{
ongoingLoopOut: map[lnwire.ShortChannelID]bool{
chanID1: true,
},
ongoingLoopIn: map[route.Vertex]bool{
peer2: true,
},
// Make empty maps so that we can assert equal.
failedLoopOut: make(
map[lnwire.ShortChannelID]time.Time,
),
failedLoopIn: make(map[route.Vertex]time.Time),
},
},
{
name: "failure backoff included",
loopOut: []*loopdb.LoopOut{
{
Contract: &loopdb.LoopOutContract{
OutgoingChanSet: []uint64{
chanID1.ToUint64(),
},
},
Loop: loopdb.Loop{
Events: failedInBackoff,
},
},
{
Contract: &loopdb.LoopOutContract{
OutgoingChanSet: []uint64{
chanID2.ToUint64(),
},
},
Loop: loopdb.Loop{
Events: failedOutsideBackoff,
},
},
},
loopIn: []*loopdb.LoopIn{
{
Contract: &loopdb.LoopInContract{
LastHop: &peer1,
},
Loop: loopdb.Loop{
Events: failedTimeoutInBackoff,
},
},
{
Contract: &loopdb.LoopInContract{
LastHop: &peer2,
},
Loop: loopdb.Loop{
Events: failedTimeoutOutsideBackoff,
},
},
},
expected: &swapTraffic{
ongoingLoopOut: make(
map[lnwire.ShortChannelID]bool,
),
ongoingLoopIn: make(map[route.Vertex]bool),
failedLoopOut: map[lnwire.ShortChannelID]time.Time{
chanID1: withinBackoff,
},
failedLoopIn: map[route.Vertex]time.Time{
peer1: withinBackoff,
},
},
},
}
for _, testCase := range tests {
cfg, _ := newTestConfig()
m := NewManager(cfg)
params := m.GetParameters()
params.FailureBackOff = backoff
require.NoError(t, m.SetParameters(context.Background(), params))
actual := m.currentSwapTraffic(testCase.loopOut, testCase.loopIn)
require.Equal(t, testCase.expected, actual)
}
}

@ -0,0 +1,82 @@
package liquidity
import (
"github.com/btcsuite/btcutil"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/swap"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
)
// Compile time assertion that loop in suggestions satisfy our interface.
var _ swapSuggestion = (*loopInSwapSuggestion)(nil)
type loopInSwapSuggestion struct {
loop.LoopInRequest
}
// amount returns the amount of the swap suggestion.
func (l *loopInSwapSuggestion) amount() btcutil.Amount {
return l.Amount
}
// fees returns the highest fees that we could pay for the swap suggestion.
func (l *loopInSwapSuggestion) fees() btcutil.Amount {
return worstCaseInFees(
l.MaxMinerFee, l.MaxSwapFee, defaultLoopInSweepFee,
)
}
// channels returns no channels for loop in swap suggestions because we do not
// restrict loop in swaps by channel id.
func (l *loopInSwapSuggestion) channels() []lnwire.ShortChannelID {
return nil
}
// peers returns the peer that a loop in swap is restricted to, if it is set.
func (l *loopInSwapSuggestion) peers(_ map[uint64]route.Vertex) []route.Vertex {
if l.LastHop == nil {
return nil
}
return []route.Vertex{
*l.LastHop,
}
}
// worstCaseInFees returns the largest possible fees for a loop in swap.
func worstCaseInFees(maxMinerFee, swapFee btcutil.Amount,
sweepEst chainfee.SatPerKWeight) btcutil.Amount {
failureFee := maxMinerFee + loopInSweepFee(sweepEst)
successFee := maxMinerFee + swapFee
if failureFee > successFee {
return failureFee
}
return successFee
}
// loopInSweepFee provides an estimated fee for our sweep transaction, based
// on the fee rate provided. We can calculate our fees for htlcv2 and p2wkh
// timeout addresses because automated loop ins will be handled entirely by the
// client, so we know what types will be used.
func loopInSweepFee(fee chainfee.SatPerKWeight) btcutil.Amount {
var estimator input.TxWeightEstimator
// We sweep loop in swaps to wpkh addresses provided by lnd.
estimator.AddP2WKHOutput()
// Create a htlcv2, which is what all autoloops will use, so that we
// can get our maximum timeout witness size.
htlc := swap.HtlcScriptV2{}
maxSize := htlc.MaxTimeoutWitnessSize()
estimator.AddWitnessInput(maxSize)
weight := int64(estimator.Weight())
return fee.FeeForWeight(weight)
}

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"github.com/btcsuite/btcutil" "github.com/btcsuite/btcutil"
"github.com/lightninglabs/loop/swap"
) )
var ( var (
@ -19,6 +20,12 @@ var (
"percentages must be < 100") "percentages must be < 100")
) )
// SwapRule is a liquidity rule with a specific swap type.
type SwapRule struct {
*ThresholdRule
swap.Type
}
// ThresholdRule is a liquidity rule that implements minimum incoming and // ThresholdRule is a liquidity rule that implements minimum incoming and
// outgoing liquidity threshold. // outgoing liquidity threshold.
type ThresholdRule struct { type ThresholdRule struct {
@ -65,73 +72,99 @@ func (r *ThresholdRule) validate() error {
// swapAmount suggests a swap based on the liquidity thresholds configured, // swapAmount suggests a swap based on the liquidity thresholds configured,
// returning zero if no swap is recommended. // returning zero if no swap is recommended.
func (r *ThresholdRule) swapAmount(channel *balances, func (r *ThresholdRule) swapAmount(channel *balances,
outRestrictions *Restrictions) btcutil.Amount { restrictions *Restrictions) btcutil.Amount {
var (
// For loop out swaps, we want to adjust our incoming liquidity
// so the channel's incoming balance is our target.
targetBalance = channel.incoming
// For loop out swaps, we target a minimum amount of incoming
// liquidity, so the minimum incoming threshold is our target
// percentage.
targetPercentage = uint64(r.MinimumIncoming)
// For loop out swaps, we may want to preserve some of our
// outgoing balance, so the channel's outgoing balance is our
// reserve.
reserveBalance = channel.outgoing
// For loop out swaps, we may want to preserve some percentage
// of our outgoing balance, so the minimum outgoing threshold
// is our reserve percentage.
reservePercentage = uint64(r.MinimumOutgoing)
)
// Examine our total balance and required ratios to decide whether we // Examine our total balance and required ratios to decide whether we
// need to swap. // need to swap.
amount := loopOutSwapAmount( amount := calculateSwapAmount(
channel, r.MinimumIncoming, r.MinimumOutgoing, targetBalance, reserveBalance, channel.capacity,
targetPercentage, reservePercentage,
) )
// Limit our swap amount by the minimum/maximum thresholds set. // Limit our swap amount by the minimum/maximum thresholds set.
switch { switch {
case amount < outRestrictions.Minimum: case amount < restrictions.Minimum:
return 0 return 0
case amount > outRestrictions.Maximum: case amount > restrictions.Maximum:
return outRestrictions.Maximum return restrictions.Maximum
default: default:
return amount return amount
} }
} }
// loopOutSwapAmount determines whether we can perform a loop out swap, and // calculateSwapAmount calculates amount for a swap based on thresholds.
// returns the amount we need to swap to reach the desired liquidity balance // This function can be used for loop out or loop in, but the concept is the
// specified by the incoming and outgoing thresholds. // same - we want liquidity in one (target) direction, while preserving some
func loopOutSwapAmount(balances *balances, incomingThresholdPercent, // minimum in the other (reserve) direction.
outgoingThresholdPercent int) btcutil.Amount { // * target: this is the side of the channel(s) where we want to acquire some
// liquidity. We aim for this liquidity to reach the threshold amount set.
minimumIncoming := btcutil.Amount(uint64( // * reserve: this is the side of the channel(s) that we will move liquidity
balances.capacity) * // away from. This may not drop below a certain reserve threshold.
uint64(incomingThresholdPercent) / 100, func calculateSwapAmount(targetAmount, reserveAmount,
capacity btcutil.Amount, targetThresholdPercentage,
reserveThresholdPercentage uint64) btcutil.Amount {
targetGoal := btcutil.Amount(
uint64(capacity) * targetThresholdPercentage / 100,
) )
minimumOutgoing := btcutil.Amount( reserveMinimum := btcutil.Amount(
uint64(balances.capacity) * uint64(capacity) * reserveThresholdPercentage / 100,
uint64(outgoingThresholdPercent) / 100,
) )
switch { switch {
// If we have sufficient incoming capacity, we do not need to loop out. // If we have sufficient target capacity, we do not need to swap.
case balances.incoming >= minimumIncoming: case targetAmount >= targetGoal:
return 0 return 0
// If we are already below the threshold set for outgoing capacity, we // If we are already below the threshold set for reserve capacity, we
// cannot take any further action. // cannot take any further action.
case balances.outgoing <= minimumOutgoing: case reserveAmount <= reserveMinimum:
return 0 return 0
} }
// Express our minimum outgoing amount as a maximum incoming amount. // Express our minimum reserve amount as a maximum target amount.
// We will use this value to limit the amount that we swap, so that we // We will use this value to limit the amount that we swap, so that we
// do not dip below our outgoing threshold. // do not dip below our reserve threshold.
maximumIncoming := balances.capacity - minimumOutgoing maximumTarget := capacity - reserveMinimum
// Calculate the midpoint between our minimum and maximum incoming // Calculate the midpoint between our minimum and maximum target values.
// values. We will aim to swap this amount so that we do not tip our // We will aim to swap this amount so that we do not tip our reserve
// outgoing balance beneath the desired level. // balance beneath the desired level.
midpoint := (minimumIncoming + maximumIncoming) / 2 midpoint := (targetGoal + maximumTarget) / 2
// Calculate the amount of incoming balance we need to shift to reach // Calculate the amount of target balance we need to shift to reach
// this desired midpoint. // this desired midpoint.
required := midpoint - balances.incoming required := midpoint - targetAmount
// Since we can have pending htlcs on our channel, we check the amount // Since we can have pending htlcs on our channel, we check the amount
// of outbound capacity that we can shift before we fall below our // of reserve capacity that we can shift before we fall below our
// threshold. // threshold.
available := balances.outgoing - minimumOutgoing available := reserveAmount - reserveMinimum
// If we do not have enough balance available to reach our midpoint, we // If we do not have enough balance available to reach our midpoint, we
// take no action. This is the case when we have a large portion of // take no action. This is the case when we have a large portion of

@ -93,18 +93,18 @@ func TestValidateThreshold(t *testing.T) {
} }
} }
// TestLoopOutAmount tests assessing of a set of balances to determine whether // TestCalculateAmount tests calculation of the amount we recommend for a given
// we should perform a loop out. // set of balances and threshold rule.
func TestLoopOutAmount(t *testing.T) { func TestCalculateAmount(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
minIncoming int minIncoming uint64
minOutgoing int minOutgoing uint64
balances *balances balances *balances
amt btcutil.Amount amt btcutil.Amount
}{ }{
{ {
name: "insufficient surplus", name: "insufficient outgoing",
balances: &balances{ balances: &balances{
capacity: 100, capacity: 100,
incoming: 20, incoming: 20,
@ -166,8 +166,9 @@ func TestLoopOutAmount(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
t.Parallel() t.Parallel()
amt := loopOutSwapAmount( amt := calculateSwapAmount(
test.balances, test.minIncoming, test.balances.incoming, test.balances.outgoing,
test.balances.capacity, test.minIncoming,
test.minOutgoing, test.minOutgoing,
) )
require.Equal(t, test.amt, amt) require.Equal(t, test.amt, amt)

@ -750,15 +750,22 @@ func (s *swapClientServer) GetLiquidityParams(_ context.Context,
} }
func newRPCRule(channelID uint64, peer []byte, func newRPCRule(channelID uint64, peer []byte,
rule *liquidity.ThresholdRule) *looprpc.LiquidityRule { rule *liquidity.SwapRule) *looprpc.LiquidityRule {
return &looprpc.LiquidityRule{ rpcRule := &looprpc.LiquidityRule{
ChannelId: channelID, ChannelId: channelID,
Pubkey: peer, Pubkey: peer,
Type: looprpc.LiquidityRuleType_THRESHOLD, Type: looprpc.LiquidityRuleType_THRESHOLD,
IncomingThreshold: uint32(rule.MinimumIncoming), IncomingThreshold: uint32(rule.MinimumIncoming),
OutgoingThreshold: uint32(rule.MinimumOutgoing), OutgoingThreshold: uint32(rule.MinimumOutgoing),
SwapType: looprpc.SwapType_LOOP_OUT,
} }
if rule.Type == swap.TypeIn {
rpcRule.SwapType = looprpc.SwapType_LOOP_IN
}
return rpcRule
} }
// SetLiquidityParams attempts to set our current liquidity manager's // SetLiquidityParams attempts to set our current liquidity manager's
@ -781,10 +788,10 @@ func (s *swapClientServer) SetLiquidityParams(ctx context.Context,
AutoFeeBudget: btcutil.Amount(in.Parameters.AutoloopBudgetSat), AutoFeeBudget: btcutil.Amount(in.Parameters.AutoloopBudgetSat),
MaxAutoInFlight: int(in.Parameters.AutoMaxInFlight), MaxAutoInFlight: int(in.Parameters.AutoMaxInFlight),
ChannelRules: make( ChannelRules: make(
map[lnwire.ShortChannelID]*liquidity.ThresholdRule, map[lnwire.ShortChannelID]*liquidity.SwapRule,
), ),
PeerRules: make( PeerRules: make(
map[route.Vertex]*liquidity.ThresholdRule, map[route.Vertex]*liquidity.SwapRule,
), ),
ClientRestrictions: liquidity.Restrictions{ ClientRestrictions: liquidity.Restrictions{
Minimum: btcutil.Amount(in.Parameters.MinSwapAmount), Minimum: btcutil.Amount(in.Parameters.MinSwapAmount),
@ -890,16 +897,24 @@ func rpcToFee(req *looprpc.LiquidityParameters) (liquidity.FeeLimit,
} }
// rpcToRule switches on rpc rule type to convert to our rule interface. // rpcToRule switches on rpc rule type to convert to our rule interface.
func rpcToRule(rule *looprpc.LiquidityRule) (*liquidity.ThresholdRule, error) { func rpcToRule(rule *looprpc.LiquidityRule) (*liquidity.SwapRule, error) {
swapType := swap.TypeOut
if rule.SwapType == looprpc.SwapType_LOOP_IN {
swapType = swap.TypeIn
}
switch rule.Type { switch rule.Type {
case looprpc.LiquidityRuleType_UNKNOWN: case looprpc.LiquidityRuleType_UNKNOWN:
return nil, fmt.Errorf("rule type field must be set") return nil, fmt.Errorf("rule type field must be set")
case looprpc.LiquidityRuleType_THRESHOLD: case looprpc.LiquidityRuleType_THRESHOLD:
return liquidity.NewThresholdRule( return &liquidity.SwapRule{
int(rule.IncomingThreshold), ThresholdRule: liquidity.NewThresholdRule(
int(rule.OutgoingThreshold), int(rule.IncomingThreshold),
), nil int(rule.OutgoingThreshold),
),
Type: swapType,
}, nil
default: default:
return nil, fmt.Errorf("unknown rule: %T", rule) return nil, fmt.Errorf("unknown rule: %T", rule)

@ -2299,6 +2299,8 @@ type LiquidityRule struct {
//The short channel ID of the channel that this rule should be applied to. //The short channel ID of the channel that this rule should be applied to.
//This field may not be set when the pubkey field is set. //This field may not be set when the pubkey field is set.
ChannelId uint64 `protobuf:"varint,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` ChannelId uint64 `protobuf:"varint,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"`
// The type of swap that will be dispatched for this rule.
SwapType SwapType `protobuf:"varint,6,opt,name=swap_type,json=swapType,proto3,enum=looprpc.SwapType" json:"swap_type,omitempty"`
// //
//The public key of the peer that this rule should be applied to. This field //The public key of the peer that this rule should be applied to. This field
//may not be set when the channel id field is set. //may not be set when the channel id field is set.
@ -2358,6 +2360,13 @@ func (x *LiquidityRule) GetChannelId() uint64 {
return 0 return 0
} }
func (x *LiquidityRule) GetSwapType() SwapType {
if x != nil {
return x.SwapType
}
return SwapType_LOOP_OUT
}
func (x *LiquidityRule) GetPubkey() []byte { func (x *LiquidityRule) GetPubkey() []byte {
if x != nil { if x != nil {
return x.Pubkey return x.Pubkey
@ -2905,10 +2914,13 @@ var file_client_proto_rawDesc = []byte{
0x6e, 0x53, 0x77, 0x61, 0x70, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6d, 0x6e, 0x53, 0x77, 0x61, 0x70, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6d,
0x61, 0x78, 0x5f, 0x73, 0x77, 0x61, 0x70, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0f, 0x61, 0x78, 0x5f, 0x73, 0x77, 0x61, 0x70, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0f,
0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x6d, 0x61, 0x78, 0x53, 0x77, 0x61, 0x70, 0x41, 0x6d, 0x6f, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x6d, 0x61, 0x78, 0x53, 0x77, 0x61, 0x70, 0x41, 0x6d, 0x6f,
0x75, 0x6e, 0x74, 0x22, 0xd4, 0x01, 0x0a, 0x0d, 0x4c, 0x69, 0x71, 0x75, 0x69, 0x64, 0x69, 0x74, 0x75, 0x6e, 0x74, 0x22, 0x84, 0x02, 0x0a, 0x0d, 0x4c, 0x69, 0x71, 0x75, 0x69, 0x64, 0x69, 0x74,
0x79, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x79, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c,
0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x63, 0x68, 0x61, 0x6e, 0x6e,
0x65, 0x6c, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x18, 0x05, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x2e, 0x0a, 0x09, 0x73, 0x77, 0x61, 0x70, 0x5f, 0x74, 0x79, 0x70,
0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x11, 0x2e, 0x6c, 0x6f, 0x6f, 0x70, 0x72, 0x70,
0x63, 0x2e, 0x53, 0x77, 0x61, 0x70, 0x54, 0x79, 0x70, 0x65, 0x52, 0x08, 0x73, 0x77, 0x61, 0x70,
0x54, 0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x18, 0x05,
0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x04,
0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6c, 0x6f, 0x6f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6c, 0x6f, 0x6f,
0x70, 0x72, 0x70, 0x63, 0x2e, 0x4c, 0x69, 0x71, 0x75, 0x69, 0x64, 0x69, 0x74, 0x79, 0x52, 0x75, 0x70, 0x72, 0x70, 0x63, 0x2e, 0x4c, 0x69, 0x71, 0x75, 0x69, 0x64, 0x69, 0x74, 0x79, 0x52, 0x75,
@ -3122,44 +3134,45 @@ var file_client_proto_depIdxs = []int32{
32, // 5: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint 32, // 5: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint
23, // 6: looprpc.TokensResponse.tokens:type_name -> looprpc.LsatToken 23, // 6: looprpc.TokensResponse.tokens:type_name -> looprpc.LsatToken
26, // 7: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule 26, // 7: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule
3, // 8: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType 0, // 8: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType
25, // 9: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters 3, // 9: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType
4, // 10: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason 25, // 10: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters
5, // 11: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest 4, // 11: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason
30, // 12: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified 5, // 12: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest
5, // 13: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest 30, // 13: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified
6, // 14: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest 5, // 14: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest
8, // 15: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest 6, // 15: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest
10, // 16: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest 8, // 16: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest
12, // 17: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest 10, // 17: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest
13, // 18: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest 12, // 18: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest
16, // 19: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest 13, // 19: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest
13, // 20: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest 16, // 20: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest
16, // 21: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest 13, // 21: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest
19, // 22: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest 16, // 22: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest
21, // 23: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest 19, // 23: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest
24, // 24: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest 21, // 24: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest
27, // 25: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest 24, // 25: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest
29, // 26: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest 27, // 26: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest
7, // 27: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse 29, // 27: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest
7, // 28: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse 7, // 28: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse
9, // 29: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus 7, // 29: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse
11, // 30: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse 9, // 30: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus
9, // 31: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus 11, // 31: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse
15, // 32: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse 9, // 32: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus
18, // 33: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse 15, // 33: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse
14, // 34: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse 18, // 34: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse
17, // 35: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse 14, // 35: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse
20, // 36: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse 17, // 36: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse
22, // 37: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse 20, // 37: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse
25, // 38: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters 22, // 38: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse
28, // 39: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse 25, // 39: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters
31, // 40: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse 28, // 40: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse
27, // [27:41] is the sub-list for method output_type 31, // 41: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse
13, // [13:27] is the sub-list for method input_type 28, // [28:42] is the sub-list for method output_type
13, // [13:13] is the sub-list for extension type_name 14, // [14:28] is the sub-list for method input_type
13, // [13:13] is the sub-list for extension extendee 14, // [14:14] is the sub-list for extension type_name
0, // [0:13] is the sub-list for field type_name 14, // [14:14] is the sub-list for extension extendee
0, // [0:14] is the sub-list for field type_name
} }
func init() { file_client_proto_init() } func init() { file_client_proto_init() }

@ -843,6 +843,9 @@ message LiquidityRule {
*/ */
uint64 channel_id = 1; uint64 channel_id = 1;
// The type of swap that will be dispatched for this rule.
SwapType swap_type = 6;
/* /*
The public key of the peer that this rule should be applied to. This field The public key of the peer that this rule should be applied to. This field
may not be set when the channel id field is set. may not be set when the channel id field is set.

@ -672,6 +672,10 @@
"format": "uint64", "format": "uint64",
"description": "The short channel ID of the channel that this rule should be applied to.\nThis field may not be set when the pubkey field is set." "description": "The short channel ID of the channel that this rule should be applied to.\nThis field may not be set when the pubkey field is set."
}, },
"swap_type": {
"$ref": "#/definitions/looprpcSwapType",
"description": "The type of swap that will be dispatched for this rule."
},
"pubkey": { "pubkey": {
"type": "string", "type": "string",
"format": "byte", "format": "byte",

Loading…
Cancel
Save