mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
liquidity: make swap suggestions fee-aware
This commit is contained in:
parent
1d8609bae3
commit
0212a41ed0
4 changed files with 274 additions and 27 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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…
Add table
Add a link
Reference in a new issue