liquidity: make swap suggestions fee-aware

pull/289/head
carla 4 years ago
parent 1d8609bae3
commit 0212a41ed0
No known key found for this signature in database
GPG Key ID: 4CA7FE54A6213C91

@ -13,10 +13,28 @@
// - Sweep Fee Rate Limit: the maximum sat/vByte fee estimate for our sweep // - Sweep Fee Rate Limit: the maximum sat/vByte fee estimate for our sweep
// transaction to confirm within our configured number of confirmations // transaction to confirm within our configured number of confirmations
// that we will suggest swaps for. // that we will suggest swaps for.
// - Maximum Swap Fee PPM: the maximum server fee, expressed as parts per
// million of the full swap amount
// - Maximum Routing Fee PPM: the maximum off-chain routing fees for the swap
// invoice, expressed as parts per million of the swap amount.
// - Maximum Prepay Routing Fee PPM: the maximum off-chain routing fees for the
// swap prepayment, expressed as parts per million of the prepay amount.
// - Maximum Prepay: the maximum now-show fee, expressed in satoshis. This
// amount is only payable in the case where the swap server broadcasts a htlc
// and the client fails to sweep the preimage.
// - Maximum miner fee: the maximum miner fee we are willing to pay to sweep the
// on chain htlc. Note that the client will use current fee estimates to
// sweep, so this value acts more as a sanity check in the case of a large fee
// spike.
//
// The maximum fee per-swap is calculated as follows:
// (swap amount * serverPPM/1e6) + miner fee + (swap amount * routingPPM/1e6)
// + (prepay amount * prepayPPM/1e6).
package liquidity package liquidity
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
@ -55,8 +73,10 @@ const (
defaultPrepayRoutingFeePPM = 5000 defaultPrepayRoutingFeePPM = 5000
// defaultMaximumMinerFee is the default limit we place on miner fees // defaultMaximumMinerFee is the default limit we place on miner fees
// per swap. // per swap. We apply a multiplier to this default fee to guard against
defaultMaximumMinerFee = 15000 // the case where we have broadcast the preimage, then fees spike and
// we need to sweep the preimage.
defaultMaximumMinerFee = 15000 * 100
// defaultMaximumPrepay is the default limit we place on prepay // defaultMaximumPrepay is the default limit we place on prepay
// invoices. // invoices.
@ -71,10 +91,15 @@ var (
// defaultParameters contains the default parameters that we start our // defaultParameters contains the default parameters that we start our
// liquidity manger with. // liquidity manger with.
defaultParameters = Parameters{ defaultParameters = Parameters{
ChannelRules: make(map[lnwire.ShortChannelID]*ThresholdRule), ChannelRules: make(map[lnwire.ShortChannelID]*ThresholdRule),
FailureBackOff: defaultFailureBackoff, FailureBackOff: defaultFailureBackoff,
SweepFeeRateLimit: defaultSweepFeeRateLimit, SweepFeeRateLimit: defaultSweepFeeRateLimit,
SweepConfTarget: loop.DefaultSweepConfTarget, SweepConfTarget: loop.DefaultSweepConfTarget,
MaximumSwapFeePPM: defaultSwapFeePPM,
MaximumRoutingFeePPM: defaultRoutingFeePPM,
MaximumPrepayRoutingFeePPM: defaultPrepayRoutingFeePPM,
MaximumMinerFee: defaultMaximumMinerFee,
MaximumPrepay: defaultMaximumPrepay,
} }
// ErrZeroChannelID is returned if we get a rule for a 0 channel ID. // ErrZeroChannelID is returned if we get a rule for a 0 channel ID.
@ -85,6 +110,21 @@ var (
ErrInvalidSweepFeeRateLimit = fmt.Errorf("sweep fee rate limit must "+ ErrInvalidSweepFeeRateLimit = fmt.Errorf("sweep fee rate limit must "+
"be > %v sat/vByte", "be > %v sat/vByte",
satPerKwToSatPerVByte(chainfee.AbsoluteFeePerKwFloor)) satPerKwToSatPerVByte(chainfee.AbsoluteFeePerKwFloor))
// ErrZeroMinerFee is returned if a zero maximum miner fee is set.
ErrZeroMinerFee = errors.New("maximum miner fee must be non-zero")
// ErrZeroSwapFeePPM is returned if a zero server fee ppm is set.
ErrZeroSwapFeePPM = errors.New("swap fee PPM must be non-zero")
// ErrZeroRoutingPPM is returned if a zero routing fee ppm is set.
ErrZeroRoutingPPM = errors.New("routing fee PPM must be non-zero")
// ErrZeroPrepayPPM is returned if a zero prepay routing fee ppm is set.
ErrZeroPrepayPPM = errors.New("prepay routing fee PPM must be non-zero")
// ErrZeroPrepay is returned if a zero maximum prepay is set.
ErrZeroPrepay = errors.New("maximum prepay must be non-zero")
) )
// Config contains the external functionality required to run the // Config contains the external functionality required to run the
@ -103,6 +143,11 @@ type Config struct {
// ListLoopIn returns all of the loop in swaps stored on disk. // ListLoopIn returns all of the loop in swaps stored on disk.
ListLoopIn func() ([]*loopdb.LoopIn, error) ListLoopIn func() ([]*loopdb.LoopIn, error)
// LoopOutQuote gets swap fee, estimated miner fee and prepay amount for
// a loop out swap.
LoopOutQuote func(ctx context.Context,
request *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error)
// Clock allows easy mocking of time in unit tests. // Clock allows easy mocking of time in unit tests.
Clock clock.Clock Clock clock.Clock
@ -129,6 +174,33 @@ type Parameters struct {
// transaction in. This value affects the on chain fees we will pay. // transaction in. This value affects the on chain fees we will pay.
SweepConfTarget int32 SweepConfTarget int32
// MaximumPrepay is the maximum prepay amount we are willing to pay per
// swap.
MaximumPrepay btcutil.Amount
// MaximumSwapFeePPM is the maximum server fee we are willing to pay per
// swap expressed as parts per million of the swap volume.
MaximumSwapFeePPM int
// MaximumRoutingFeePPM is the maximum off-chain routing fee we
// are willing to pay for off chain invoice routing fees per swap,
// expressed as parts per million of the swap amount.
MaximumRoutingFeePPM int
// MaximumPrepayRoutingFeePPM is the maximum off-chain routing fee we
// are willing to pay for off chain prepay routing fees per swap,
// expressed as parts per million of the prepay amount.
MaximumPrepayRoutingFeePPM int
// MaximumMinerFee is the maximum on chain fee that we cap our miner
// fee at in case where we need to claim on chain because we have
// revealed the preimage, but fees have spiked. We will not initiate a
// swap if we estimate that the sweep cost will be above our sweep
// fee limit, and we use fee estimates at time of sweep to set our fees,
// so this is just a sane cap covering the special case where we need to
// sweep during a fee spike.
MaximumMinerFee btcutil.Amount
// 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. // would like liquidity to be managed.
ChannelRules map[lnwire.ShortChannelID]*ThresholdRule ChannelRules map[lnwire.ShortChannelID]*ThresholdRule
@ -145,9 +217,13 @@ func (p Parameters) String() string {
} }
return fmt.Sprintf("channel rules: %v, failure backoff: %v, sweep "+ return fmt.Sprintf("channel rules: %v, failure backoff: %v, sweep "+
"fee rate limit: %v, sweep conf target: %v", "fee rate limit: %v, sweep conf target: %v, maximum prepay: "+
"%v, maximum miner fee: %v, maximum swap fee ppm: %v, maximum "+
"routing fee ppm: %v, maximum prepay routing fee ppm: %v",
strings.Join(channelRules, ","), p.FailureBackOff, strings.Join(channelRules, ","), p.FailureBackOff,
p.SweepFeeRateLimit, p.SweepConfTarget, p.SweepFeeRateLimit, p.SweepConfTarget, p.MaximumPrepay,
p.MaximumMinerFee, p.MaximumSwapFeePPM,
p.MaximumRoutingFeePPM, p.MaximumPrepayRoutingFeePPM,
) )
} }
@ -178,6 +254,27 @@ func (p Parameters) validate(minConfs int32) error {
minConfs) minConfs)
} }
// Check that we have non-zero fee limits.
if p.MaximumSwapFeePPM == 0 {
return ErrZeroSwapFeePPM
}
if p.MaximumRoutingFeePPM == 0 {
return ErrZeroRoutingPPM
}
if p.MaximumPrepayRoutingFeePPM == 0 {
return ErrZeroPrepayPPM
}
if p.MaximumPrepay == 0 {
return ErrZeroPrepay
}
if p.MaximumMinerFee == 0 {
return ErrZeroMinerFee
}
return nil return nil
} }
@ -317,25 +414,62 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
suggestion := rule.suggestSwap(balance, outRestrictions) suggestion := rule.suggestSwap(balance, outRestrictions)
// We can have nil suggestions in the case where no action is // We can have nil suggestions in the case where no action is
// required, so only add non-nil suggestions. // required, so we skip over them.
if suggestion != nil { if suggestion == nil {
outRequest := m.makeLoopOutRequest(suggestion) continue
suggestions = append(suggestions, outRequest)
} }
// Get a quote for a swap of this amount.
quote, err := m.cfg.LoopOutQuote(
ctx, &loop.LoopOutQuoteRequest{
Amount: suggestion.Amount,
SweepConfTarget: m.params.SweepConfTarget,
SwapPublicationDeadline: m.cfg.Clock.Now(),
},
)
if err != nil {
return nil, err
}
log.Debugf("quote for suggestion: %v, swap fee: %v, "+
"miner fee: %v, prepay: %v", suggestion, quote.SwapFee,
quote.MinerFee, quote.PrepayAmount)
// Check that the estimated fees for the suggested swap are
// below the fee limits configured by the manager.
err = m.checkFeeLimits(quote, suggestion.Amount)
if err != nil {
log.Infof("suggestion: %v expected fees too high: %v",
suggestion, err)
continue
}
outRequest := m.makeLoopOutRequest(suggestion, quote)
suggestions = append(suggestions, outRequest)
} }
return suggestions, nil return suggestions, nil
} }
// makeLoopOutRequest creates a loop out request from a suggestion, setting fee // makeLoopOutRequest creates a loop out request from a suggestion. Since we
// limits defined by our default fee values. // do not get any information about our off-chain routing fees when we request
func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation) loop.OutRequest { // a quote, we just set our prepay and route maximum fees directly from the
// amounts we expect to route. The estimation we use elsewhere is the repo is
// route-independent, which is a very poor estimation so we don't bother with
// checking against this inaccurate constant. We use the exact prepay amount
// and swap fee given to us by the server, but use our maximum miner fee anyway
// to give us some leeway when performing the swap.
func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation,
quote *loop.LoopOutQuote) loop.OutRequest {
prepayMaxFee := ppmToSat( prepayMaxFee := ppmToSat(
defaultMaximumPrepay, defaultPrepayRoutingFeePPM, quote.PrepayAmount, m.params.MaximumPrepayRoutingFeePPM,
) )
routeMaxFee := ppmToSat(suggestion.Amount, defaultRoutingFeePPM) routeMaxFee := ppmToSat(
maxSwapFee := ppmToSat(suggestion.Amount, defaultSwapFeePPM) suggestion.Amount, m.params.MaximumRoutingFeePPM,
)
return loop.OutRequest{ return loop.OutRequest{
Amount: suggestion.Amount, Amount: suggestion.Amount,
@ -344,9 +478,9 @@ func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation) loop.Out
}, },
MaxPrepayRoutingFee: prepayMaxFee, MaxPrepayRoutingFee: prepayMaxFee,
MaxSwapRoutingFee: routeMaxFee, MaxSwapRoutingFee: routeMaxFee,
MaxMinerFee: defaultMaximumMinerFee, MaxMinerFee: m.params.MaximumMinerFee,
MaxSwapFee: maxSwapFee, MaxSwapFee: quote.SwapFee,
MaxPrepayAmount: defaultMaximumPrepay, MaxPrepayAmount: quote.PrepayAmount,
SweepConfTarget: m.params.SweepConfTarget, SweepConfTarget: m.params.SweepConfTarget,
} }
} }
@ -484,6 +618,31 @@ func (m *Manager) getEligibleChannels(ctx context.Context,
return eligible, nil return eligible, nil
} }
// checkFeeLimits takes a set of fees for a swap and checks whether they exceed
// our swap limits.
func (m *Manager) checkFeeLimits(quote *loop.LoopOutQuote,
swapAmt btcutil.Amount) error {
maxFee := ppmToSat(swapAmt, m.params.MaximumSwapFeePPM)
if quote.SwapFee > maxFee {
return fmt.Errorf("quoted swap fee: %v > maximum swap fee: %v",
quote.SwapFee, maxFee)
}
if quote.MinerFee > m.params.MaximumMinerFee {
return fmt.Errorf("quoted miner fee: %v > maximum miner "+
"fee: %v", quote.MinerFee, m.params.MaximumMinerFee)
}
if quote.PrepayAmount > m.params.MaximumPrepay {
return fmt.Errorf("quoted prepay: %v > maximum prepay: %v",
quote.PrepayAmount, m.params.MaximumPrepay)
}
return nil
}
// satPerKwToSatPerVByte converts sat per kWeight to sat per vByte. // satPerKwToSatPerVByte converts sat per kWeight to sat per vByte.
func satPerKwToSatPerVByte(satPerKw chainfee.SatPerKWeight) int64 { func satPerKwToSatPerVByte(satPerKw chainfee.SatPerKWeight) int64 {
return int64(satPerKw.FeePerKVByte() / 1000) return int64(satPerKw.FeePerKVByte() / 1000)

@ -5,6 +5,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/btcsuite/btcutil"
"github.com/lightninglabs/lndclient" "github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop" "github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb"
@ -44,11 +45,16 @@ var (
// chanRule is a rule that produces chan1Rec. // chanRule is a rule that produces chan1Rec.
chanRule = NewThresholdRule(50, 0) chanRule = NewThresholdRule(50, 0)
testQuote = &loop.LoopOutQuote{
SwapFee: btcutil.Amount(1),
PrepayAmount: btcutil.Amount(500),
MinerFee: btcutil.Amount(50),
}
prepayFee = ppmToSat( prepayFee = ppmToSat(
defaultMaximumPrepay, defaultPrepayRoutingFeePPM, testQuote.PrepayAmount, defaultPrepayRoutingFeePPM,
) )
routingFee = ppmToSat(7500, defaultRoutingFeePPM) routingFee = ppmToSat(7500, defaultRoutingFeePPM)
swapFee = ppmToSat(7500, defaultSwapFeePPM)
// chan1Rec is the suggested swap for channel 1 when we use chanRule. // chan1Rec is the suggested swap for channel 1 when we use chanRule.
chan1Rec = loop.OutRequest{ chan1Rec = loop.OutRequest{
@ -57,8 +63,8 @@ var (
MaxPrepayRoutingFee: prepayFee, MaxPrepayRoutingFee: prepayFee,
MaxSwapRoutingFee: routingFee, MaxSwapRoutingFee: routingFee,
MaxMinerFee: defaultMaximumMinerFee, MaxMinerFee: defaultMaximumMinerFee,
MaxSwapFee: swapFee, MaxSwapFee: testQuote.SwapFee,
MaxPrepayAmount: defaultMaximumPrepay, MaxPrepayAmount: testQuote.PrepayAmount,
SweepConfTarget: loop.DefaultSweepConfTarget, SweepConfTarget: loop.DefaultSweepConfTarget,
} }
@ -69,8 +75,8 @@ var (
MaxPrepayRoutingFee: prepayFee, MaxPrepayRoutingFee: prepayFee,
MaxSwapRoutingFee: routingFee, MaxSwapRoutingFee: routingFee,
MaxMinerFee: defaultMaximumMinerFee, MaxMinerFee: defaultMaximumMinerFee,
MaxSwapFee: swapFee, MaxPrepayAmount: testQuote.PrepayAmount,
MaxPrepayAmount: defaultMaximumPrepay, MaxSwapFee: testQuote.SwapFee,
SweepConfTarget: loop.DefaultSweepConfTarget, SweepConfTarget: loop.DefaultSweepConfTarget,
} }
@ -110,6 +116,12 @@ func newTestConfig() (*Config, *test.LndMockServices) {
ListLoopIn: func() ([]*loopdb.LoopIn, error) { ListLoopIn: func() ([]*loopdb.LoopIn, error) {
return nil, nil return nil, nil
}, },
LoopOutQuote: func(_ context.Context,
_ *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote,
error) {
return testQuote, nil
},
}, lnd }, lnd
} }
@ -448,6 +460,73 @@ func TestSuggestSwaps(t *testing.T) {
} }
} }
// TestFeeLimits tests limiting of swap suggestions by fees.
func TestFeeLimits(t *testing.T) {
tests := []struct {
name string
quote *loop.LoopOutQuote
expected []loop.OutRequest
}{
{
name: "fees ok",
quote: testQuote,
expected: []loop.OutRequest{
chan1Rec,
},
},
{
name: "insufficient prepay",
quote: &loop.LoopOutQuote{
SwapFee: 1,
PrepayAmount: defaultMaximumPrepay + 1,
MinerFee: 50,
},
},
{
name: "insufficient miner fee",
quote: &loop.LoopOutQuote{
SwapFee: 1,
PrepayAmount: 100,
MinerFee: defaultMaximumMinerFee + 1,
},
},
{
// Swap fee limited to 0.5% of 7500 = 37,5.
name: "insufficient swap fee",
quote: &loop.LoopOutQuote{
SwapFee: 38,
PrepayAmount: 100,
MinerFee: 500,
},
},
}
for _, testCase := range tests {
testCase := testCase
t.Run(testCase.name, func(t *testing.T) {
cfg, lnd := newTestConfig()
cfg.LoopOutQuote = func(context.Context,
*loop.LoopOutQuoteRequest) (*loop.LoopOutQuote,
error) {
return testCase.quote, nil
}
channels := []lndclient.ChannelInfo{
channel1,
}
rules := map[lnwire.ShortChannelID]*ThresholdRule{
chanID1: chanRule,
}
testSuggestSwaps(
t, cfg, lnd, channels, rules, testCase.expected,
)
})
}
}
// testSuggestSwaps tests getting swap suggestions. // testSuggestSwaps tests getting swap suggestions.
func testSuggestSwaps(t *testing.T, cfg *Config, lnd *test.LndMockServices, func testSuggestSwaps(t *testing.T, cfg *Config, lnd *test.LndMockServices,
channels []lndclient.ChannelInfo, channels []lndclient.ChannelInfo,

@ -1,6 +1,8 @@
package liquidity package liquidity
import ( import (
"fmt"
"github.com/btcsuite/btcutil" "github.com/btcsuite/btcutil"
"github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/lnwire"
) )
@ -15,6 +17,12 @@ type LoopOutRecommendation struct {
Channel lnwire.ShortChannelID Channel lnwire.ShortChannelID
} }
// String returns a string representation of a loop out recommendation.
func (l *LoopOutRecommendation) String() string {
return fmt.Sprintf("loop out: %v over %v", l.Amount,
l.Channel.ToUint64())
}
// newLoopOutRecommendation creates a new loop out swap. // newLoopOutRecommendation creates a new loop out swap.
func newLoopOutRecommendation(amount btcutil.Amount, func newLoopOutRecommendation(amount btcutil.Amount,
channelID lnwire.ShortChannelID) *LoopOutRecommendation { channelID lnwire.ShortChannelID) *LoopOutRecommendation {

@ -49,6 +49,7 @@ func getLiquidityManager(client *loop.Client) *liquidity.Manager {
}, },
Lnd: client.LndServices, Lnd: client.LndServices,
Clock: clock.NewDefaultClock(), Clock: clock.NewDefaultClock(),
LoopOutQuote: client.LoopOutQuote,
ListLoopOut: client.Store.FetchLoopOutSwaps, ListLoopOut: client.Store.FetchLoopOutSwaps,
ListLoopIn: client.Store.FetchLoopInSwaps, ListLoopIn: client.Store.FetchLoopInSwaps,
MinimumConfirmations: minConfTarget, MinimumConfirmations: minConfTarget,

Loading…
Cancel
Save