Browse Source

liquidity: make swap suggestions fee-aware

pull/289/head
carla 3 months ago
parent
commit
0212a41ed0
No known key found for this signature in database GPG Key ID: 4CA7FE54A6213C91
4 changed files with 274 additions and 27 deletions
  1. +180
    -21
      liquidity/liquidity.go
  2. +85
    -6
      liquidity/liquidity_test.go
  3. +8
    -0
      liquidity/suggestions.go
  4. +1
    -0
      loopd/utils.go

+ 180
- 21
liquidity/liquidity.go View File

@ -13,10 +13,28 @@
// - Sweep Fee Rate Limit: the maximum sat/vByte fee estimate for our sweep
// transaction to confirm within our configured number of confirmations
// 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
import (
"context"
"errors"
"fmt"
"strings"
"sync"
@ -55,8 +73,10 @@ const (
defaultPrepayRoutingFeePPM = 5000
// defaultMaximumMinerFee is the default limit we place on miner fees
// per swap.
defaultMaximumMinerFee = 15000
// per swap. We apply a multiplier to this default fee to guard against
// 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
// invoices.
@ -71,10 +91,15 @@ var (
// defaultParameters contains the default parameters that we start our
// liquidity manger with.
defaultParameters = Parameters{
ChannelRules: make(map[lnwire.ShortChannelID]*ThresholdRule),
FailureBackOff: defaultFailureBackoff,
SweepFeeRateLimit: defaultSweepFeeRateLimit,
SweepConfTarget: loop.DefaultSweepConfTarget,
ChannelRules: make(map[lnwire.ShortChannelID]*ThresholdRule),
FailureBackOff: defaultFailureBackoff,
SweepFeeRateLimit: defaultSweepFeeRateLimit,
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.
@ -85,6 +110,21 @@ var (
ErrInvalidSweepFeeRateLimit = fmt.Errorf("sweep fee rate limit must "+
"be > %v sat/vByte",
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
@ -103,6 +143,11 @@ type Config struct {
// ListLoopIn returns all of the loop in swaps stored on disk.
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 clock.Clock
@ -129,6 +174,33 @@ type Parameters struct {
// transaction in. This value affects the on chain fees we will pay.
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
// would like liquidity to be managed.
ChannelRules map[lnwire.ShortChannelID]*ThresholdRule
@ -145,9 +217,13 @@ func (p Parameters) String() string {
}
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,
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)
}
// 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
}
@ -317,25 +414,62 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
suggestion := rule.suggestSwap(balance, outRestrictions)
// We can have nil suggestions in the case where no action is
// required, so only add non-nil suggestions.
if suggestion != nil {
outRequest := m.makeLoopOutRequest(suggestion)
suggestions = append(suggestions, outRequest)
// required, so we skip over them.
if suggestion == nil {
continue
}
// 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
}
// makeLoopOutRequest creates a loop out request from a suggestion, setting fee
// limits defined by our default fee values.
func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation) loop.OutRequest {
// makeLoopOutRequest creates a loop out request from a suggestion. Since we
// do not get any information about our off-chain routing fees when we request
// 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(
defaultMaximumPrepay, defaultPrepayRoutingFeePPM,
quote.PrepayAmount, m.params.MaximumPrepayRoutingFeePPM,
)
routeMaxFee := ppmToSat(suggestion.Amount, defaultRoutingFeePPM)
maxSwapFee := ppmToSat(suggestion.Amount, defaultSwapFeePPM)
routeMaxFee := ppmToSat(
suggestion.Amount, m.params.MaximumRoutingFeePPM,
)
return loop.OutRequest{
Amount: suggestion.Amount,
@ -344,9 +478,9 @@ func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation) loop.Out
},
MaxPrepayRoutingFee: prepayMaxFee,
MaxSwapRoutingFee: routeMaxFee,
MaxMinerFee: defaultMaximumMinerFee,
MaxSwapFee: maxSwapFee,
MaxPrepayAmount: defaultMaximumPrepay,
MaxMinerFee: m.params.MaximumMinerFee,
MaxSwapFee: quote.SwapFee,
MaxPrepayAmount: quote.PrepayAmount,
SweepConfTarget: m.params.SweepConfTarget,
}
}
@ -484,6 +618,31 @@ func (m *Manager) getEligibleChannels(ctx context.Context,
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.
func satPerKwToSatPerVByte(satPerKw chainfee.SatPerKWeight) int64 {
return int64(satPerKw.FeePerKVByte() / 1000)

+ 85
- 6
liquidity/liquidity_test.go View File

@ -5,6 +5,7 @@ import (
"testing"
"time"
"github.com/btcsuite/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/loopdb"
@ -44,11 +45,16 @@ var (
// chanRule is a rule that produces chan1Rec.
chanRule = NewThresholdRule(50, 0)
testQuote = &loop.LoopOutQuote{
SwapFee: btcutil.Amount(1),
PrepayAmount: btcutil.Amount(500),
MinerFee: btcutil.Amount(50),
}
prepayFee = ppmToSat(
defaultMaximumPrepay, defaultPrepayRoutingFeePPM,
testQuote.PrepayAmount, defaultPrepayRoutingFeePPM,
)
routingFee = ppmToSat(7500, defaultRoutingFeePPM)
swapFee = ppmToSat(7500, defaultSwapFeePPM)
// chan1Rec is the suggested swap for channel 1 when we use chanRule.
chan1Rec = loop.OutRequest{
@ -57,8 +63,8 @@ var (
MaxPrepayRoutingFee: prepayFee,
MaxSwapRoutingFee: routingFee,
MaxMinerFee: defaultMaximumMinerFee,
MaxSwapFee: swapFee,
MaxPrepayAmount: defaultMaximumPrepay,
MaxSwapFee: testQuote.SwapFee,
MaxPrepayAmount: testQuote.PrepayAmount,
SweepConfTarget: loop.DefaultSweepConfTarget,
}
@ -69,8 +75,8 @@ var (
MaxPrepayRoutingFee: prepayFee,
MaxSwapRoutingFee: routingFee,
MaxMinerFee: defaultMaximumMinerFee,
MaxSwapFee: swapFee,
MaxPrepayAmount: defaultMaximumPrepay,
MaxPrepayAmount: testQuote.PrepayAmount,
MaxSwapFee: testQuote.SwapFee,
SweepConfTarget: loop.DefaultSweepConfTarget,
}
@ -110,6 +116,12 @@ func newTestConfig() (*Config, *test.LndMockServices) {
ListLoopIn: func() ([]*loopdb.LoopIn, error) {
return nil, nil
},
LoopOutQuote: func(_ context.Context,
_ *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote,
error) {
return testQuote, nil
},
}, 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.
func testSuggestSwaps(t *testing.T, cfg *Config, lnd *test.LndMockServices,
channels []lndclient.ChannelInfo,

+ 8
- 0
liquidity/suggestions.go View File

@ -1,6 +1,8 @@
package liquidity
import (
"fmt"
"github.com/btcsuite/btcutil"
"github.com/lightningnetwork/lnd/lnwire"
)
@ -15,6 +17,12 @@ type LoopOutRecommendation struct {
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.
func newLoopOutRecommendation(amount btcutil.Amount,
channelID lnwire.ShortChannelID) *LoopOutRecommendation {

+ 1
- 0
loopd/utils.go View File

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

Loading…
Cancel
Save